diff --git a/modules/sdk-coin-xrp/src/lib/accountDeleteBuilder.ts b/modules/sdk-coin-xrp/src/lib/accountDeleteBuilder.ts new file mode 100644 index 0000000000..68d3e1aca5 --- /dev/null +++ b/modules/sdk-coin-xrp/src/lib/accountDeleteBuilder.ts @@ -0,0 +1,75 @@ +import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { AccountDelete } from 'xrpl'; +import { Transaction } from './transaction'; +import { TransactionBuilder } from './transactionBuilder'; +import utils from './utils'; + +/** + * Builder for XRPL AccountDelete transactions. + * + * An AccountDelete transaction removes an XRP Ledger account and any objects + * it owns, sending the full remaining balance (including the base reserve) to + * the specified destination address. The protocol requires a minimum fee equal + * to the owner-reserve increment (currently 2 XRP / 2 000 000 drops) and the + * account sequence number must satisfy: Sequence + 256 <= current ledger. + */ +export class AccountDeleteBuilder extends TransactionBuilder { + private _destination: string; + private _destinationTag?: number; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.AccountDelete; + } + + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + + const { destination, destinationTag } = tx.toJson(); + if (!destination) { + throw new BuildTransactionError('Missing destination'); + } + + const normalizeAddress = utils.normalizeAddress({ address: destination, destinationTag }); + this.to(normalizeAddress); + } + + /** + * Set the destination address (and optional destination tag). + * @param address - bech32 XRP address, optionally with ?dt= query string + */ + to(address: string): AccountDeleteBuilder { + const { address: xrpAddress, destinationTag } = utils.getAddressDetails(address); + this._destination = xrpAddress; + this._destinationTag = destinationTag; + return this; + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + if (!this._sender) { + throw new BuildTransactionError('Sender must be set before building the transaction'); + } + if (!this._destination) { + throw new BuildTransactionError('Destination must be set before building the transaction'); + } + + const accountDeleteFields: AccountDelete = { + TransactionType: 'AccountDelete', + Account: this._sender, + Destination: this._destination, + }; + + if (typeof this._destinationTag === 'number') { + accountDeleteFields.DestinationTag = this._destinationTag; + } + + this._specificFields = accountDeleteFields; + + return await super.buildImplementation(); + } +} diff --git a/modules/sdk-coin-xrp/src/lib/iface.ts b/modules/sdk-coin-xrp/src/lib/iface.ts index e9849bad7c..b79099ae2f 100644 --- a/modules/sdk-coin-xrp/src/lib/iface.ts +++ b/modules/sdk-coin-xrp/src/lib/iface.ts @@ -5,16 +5,17 @@ import { VerifyAddressOptions as BaseVerifyAddressOptions, TransactionPrebuild, } from '@bitgo/sdk-core'; -import { AccountSet, Amount, Payment, Signer, SignerEntry, SignerListSet, TrustSet } from 'xrpl'; +import { AccountDelete, AccountSet, Amount, Payment, Signer, SignerEntry, SignerListSet, TrustSet } from 'xrpl'; export enum XrpTransactionType { + AccountDelete = 'AccountDelete', AccountSet = 'AccountSet', Payment = 'Payment', SignerListSet = 'SignerListSet', TrustSet = 'TrustSet', } -export type XrpTransaction = Payment | AccountSet | SignerListSet | TrustSet; +export type XrpTransaction = AccountDelete | Payment | AccountSet | SignerListSet | TrustSet; export interface Address { address: string; @@ -69,6 +70,9 @@ export interface RecoveryOptions { krsProvider?: string; issuerAddress?: string; currencyCode?: string; + /** When true, builds an AccountDelete transaction to withdraw the full balance + * including the base reserve (currently 10 XRP) instead of a normal Payment. */ + reserveWithdrawal?: boolean; } export interface HalfSignedTransaction { @@ -124,7 +128,7 @@ export interface TxData { signingPubKey?: string; // if '' then it is a multi sig txnSignature?: string; // only for single sig signers?: Signer[]; // only for multi sig - // transfer xrp fields + // transfer xrp / account-delete fields destination?: string; destinationTag?: number; amount?: Amount; diff --git a/modules/sdk-coin-xrp/src/lib/index.ts b/modules/sdk-coin-xrp/src/lib/index.ts index ed2cf8984e..545457273c 100644 --- a/modules/sdk-coin-xrp/src/lib/index.ts +++ b/modules/sdk-coin-xrp/src/lib/index.ts @@ -1,5 +1,6 @@ import Utils from './utils'; +export { AccountDeleteBuilder } from './accountDeleteBuilder'; export { AccountSetBuilder } from './accountSetBuilder'; export * from './constants'; export * from './iface'; diff --git a/modules/sdk-coin-xrp/src/lib/transaction.ts b/modules/sdk-coin-xrp/src/lib/transaction.ts index e41208928f..fe5eb6efac 100644 --- a/modules/sdk-coin-xrp/src/lib/transaction.ts +++ b/modules/sdk-coin-xrp/src/lib/transaction.ts @@ -78,6 +78,11 @@ export class Transaction extends BaseTransaction { } switch (this._xrpTransaction.TransactionType) { + case XrpTransactionType.AccountDelete: + txData.destination = this._xrpTransaction.Destination; + txData.destinationTag = this._xrpTransaction.DestinationTag; + return txData; + case XrpTransactionType.Payment: txData.destination = this._xrpTransaction.Destination; txData.destinationTag = this._xrpTransaction.DestinationTag; @@ -179,6 +184,8 @@ export class Transaction extends BaseTransaction { explainTransaction(): TransactionExplanation { switch (this._xrpTransaction.TransactionType) { + case XrpTransactionType.AccountDelete: + return this.explainAccountDeleteTransaction(); case XrpTransactionType.Payment: return this.explainPaymentTransaction(); case XrpTransactionType.AccountSet: @@ -190,6 +197,29 @@ export class Transaction extends BaseTransaction { } } + private explainAccountDeleteTransaction(): BaseTransactionExplanation { + const tx = this._xrpTransaction as xrpl.AccountDelete; + const address = utils.normalizeAddress({ address: tx.Destination, destinationTag: tx.DestinationTag }); + + return { + displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee'], + id: this._id as string, + changeOutputs: [], + outputAmount: '0', // full balance is swept; exact amount unknown at build time + changeAmount: 0, + outputs: [ + { + address, + amount: '0', + }, + ], + fee: { + fee: tx.Fee as string, + feeRate: undefined, + }, + }; + } + private explainPaymentTransaction(): BaseTransactionExplanation { const tx = this._xrpTransaction as xrpl.Payment; const address = utils.normalizeAddress({ address: tx.Destination, destinationTag: tx.DestinationTag }); @@ -312,6 +342,9 @@ export class Transaction extends BaseTransaction { this._id = this.calculateIdFromRawTx(txHex); switch (this._xrpTransaction.TransactionType) { + case XrpTransactionType.AccountDelete: + this.setTransactionType(TransactionType.AccountDelete); + break; case XrpTransactionType.SignerListSet: this.setTransactionType(TransactionType.WalletInitialization); break; @@ -339,6 +372,21 @@ export class Transaction extends BaseTransaction { if (!this._xrpTransaction) { return; } + const coin = this._coinConfig.name; + + if (this._xrpTransaction.TransactionType === XrpTransactionType.AccountDelete) { + const { Account, Destination, DestinationTag } = this._xrpTransaction; + // For AccountDelete the exact amount swept is unknown at build time (full balance minus fee). + // We record '0' as a placeholder; the actual amount is determined on-chain. + this.inputs.push({ address: Account, value: '0', coin }); + this.outputs.push({ + address: utils.normalizeAddress({ address: Destination, destinationTag: DestinationTag }), + value: '0', + coin, + }); + return; + } + if (this._xrpTransaction.TransactionType === XrpTransactionType.Payment) { let value: string; const { Account, Destination, Amount, DestinationTag } = this._xrpTransaction; @@ -347,7 +395,6 @@ export class Transaction extends BaseTransaction { } else { value = Amount.value; } - const coin = this._coinConfig.name; this.inputs.push({ address: Account, value, diff --git a/modules/sdk-coin-xrp/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-xrp/src/lib/transactionBuilderFactory.ts index e0dde26728..ed726c168d 100644 --- a/modules/sdk-coin-xrp/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-xrp/src/lib/transactionBuilderFactory.ts @@ -1,6 +1,7 @@ import { BaseTransactionBuilderFactory, InvalidTransactionError, TransactionType } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import xrpl from 'xrpl'; +import { AccountDeleteBuilder } from './accountDeleteBuilder'; import { AccountSetBuilder } from './accountSetBuilder'; import { TokenTransferBuilder } from './tokenTransferBuilder'; import { Transaction } from './transaction'; @@ -28,6 +29,8 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const tx = this.parseTransaction(txHex); try { switch (tx.type) { + case TransactionType.AccountDelete: + return this.getAccountDeleteBuilder(tx); case TransactionType.AccountUpdate: return this.getAccountUpdateBuilder(tx); case TransactionType.Send: @@ -51,6 +54,11 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new WalletInitializationBuilder(this._coinConfig)); } + /** @inheritdoc */ + public getAccountDeleteBuilder(tx?: Transaction): AccountDeleteBuilder { + return this.initializeBuilder(tx, new AccountDeleteBuilder(this._coinConfig)); + } + /** @inheritdoc */ public getTransferBuilder(tx?: Transaction): TransferBuilder { return this.initializeBuilder(tx, new TransferBuilder(this._coinConfig)); diff --git a/modules/sdk-coin-xrp/src/xrp.ts b/modules/sdk-coin-xrp/src/xrp.ts index fd05e7c756..5eab9194a4 100644 --- a/modules/sdk-coin-xrp/src/xrp.ts +++ b/modules/sdk-coin-xrp/src/xrp.ts @@ -30,7 +30,7 @@ import * as rippleBinaryCodec from 'ripple-binary-codec'; import * as rippleKeypairs from 'ripple-keypairs'; import * as xrpl from 'xrpl'; -import { TokenTransferBuilder, TransactionBuilderFactory, TransferBuilder } from './lib'; +import { AccountDeleteBuilder, TokenTransferBuilder, TransactionBuilderFactory, TransferBuilder } from './lib'; import { ExplainTransactionOptions, FeeInfo, @@ -510,11 +510,17 @@ export class Xrp extends BaseCoin { const isKrsRecovery = params.backupKey.startsWith('xpub') && !params.userKey.startsWith('xpub'); const isUnsignedSweep = params.backupKey.startsWith('xpub') && params.userKey.startsWith('xpub'); + // Strip any ?dt= query string from the root address before sending to the XRPL RPC. + // BitGo encodes destination tags as `rXXXX?dt=N` in its UI/API, but the rippled JSON-RPC + // only accepts the bare r-address. + const rootAddressDetails = url.parse(params.rootAddress); + const rootAccount = rootAddressDetails.pathname as string; + const accountInfoParams = { method: 'account_info', params: [ { - account: params.rootAddress, + account: rootAccount, ledger_index: 'current', queue: true, strict: true, @@ -527,13 +533,17 @@ export class Xrp extends BaseCoin { method: 'account_lines', params: [ { - account: params.rootAddress, + account: rootAccount, ledger_index: 'validated', }, ], }; - if (isKrsRecovery) { + // For AccountDelete (reserveWithdrawal) the KRS provider coin-family check is skipped because: + // 1. AccountDelete is an emergency recovery; KRS coin-family lists are irrelevant here. + // 2. The signing path inside the reserveWithdrawal block already handles isKrsRecovery correctly + // (user signs, backup signature comes from KRS or is omitted for an unsigned sweep). + if (isKrsRecovery && !params.reserveWithdrawal) { checkKrsProvider(this, params.krsProvider); } @@ -666,11 +676,113 @@ export class Xrp extends BaseCoin { } const factory = new TransactionBuilderFactory(coins.get(this.getChain())); + + // --- AccountDelete path: withdraw full balance including the base reserve --- + if (params.reserveWithdrawal) { + // 1. No trustlines with non-zero balances may exist. + const lines: Array<{ currency: string; account: string; balance: string }> = accountLines.body.result.lines ?? []; + const nonZeroLines = lines.filter((l) => parseFloat(l.balance) !== 0); + if (nonZeroLines.length > 0) { + throw new Error( + `Account has ${nonZeroLines.length} trustline(s) with non-zero balances. ` + + 'Clear all token trustlines before using reserve withdrawal.' + ); + } + + // 2. No owned objects other than SignerList entries. + const accountObjectsParams = { + method: 'account_objects', + params: [{ account: rootAccount, ledger_index: 'validated' }], + }; + const accountObjectsResponse = await this.bitgo.post(rippledUrl).send(accountObjectsParams); + const ownedObjects: Array<{ LedgerEntryType: string }> = accountObjectsResponse.body.result.account_objects ?? []; + const blockingObjects = ownedObjects.filter((o) => o.LedgerEntryType !== 'SignerList'); + if (blockingObjects.length > 0) { + const types = [...new Set(blockingObjects.map((o) => o.LedgerEntryType))].join(', '); + throw new Error( + `Account owns objects that must be removed before account deletion: ${types}. ` + + 'Please resolve these objects using the standard recovery flow first.' + ); + } + + // 3. Sequence + 256 must be <= current validated ledger. + if (sequenceId + 256 > currentLedger) { + throw new Error( + `Account is too new for AccountDelete: Sequence(${sequenceId}) + 256 > currentLedger(${currentLedger}). ` + + `Wait at least ${sequenceId + 256 - currentLedger} more ledger(s).` + ); + } + + // 4. Destination must be a funded, existing XRPL account. + // Strip any ?dt= suffix — the XRPL RPC only accepts the bare r-address. + const destAccount = url.parse(params.recoveryDestination).pathname as string; + const destInfoParams = { + method: 'account_info', + params: [{ account: destAccount, ledger_index: 'validated', strict: true }], + }; + const destInfo = await this.bitgo.post(rippledUrl).send(destInfoParams); + if (destInfo.body.result.error) { + throw new Error( + `Recovery destination "${destAccount}" does not exist on the ledger. ` + + 'AccountDelete requires a funded destination account.' + ); + } + + // AccountDelete fee must be >= owner-reserve increment (reserveDelta, currently 2 XRP). + // We use reserveDelta directly (not openLedgerFee × 3) per XRPL protocol. + const accountDeleteFee = reserveDelta; + + const txBuilder = factory.getAccountDeleteBuilder() as AccountDeleteBuilder; + txBuilder + .to(params.recoveryDestination as string) + .sender(rootAccount) + .flags(2147483648) + .lastLedgerSequence(currentLedger + 1000000) + .fee(accountDeleteFee.toFixed(0)) + .sequence(sequenceId); + + const tx = await txBuilder.build(); + const serializedTx = tx.toBroadcastFormat(); + + if (isUnsignedSweep) { + return { txHex: serializedTx, coin: this.getChain() }; + } + + if (!keys[0].privateKey) { + throw new Error('userKey is not a private key'); + } + const userKey = keys[0].privateKey.toString('hex'); + const userSignature = ripple.signWithPrivateKey(serializedTx, userKey, { signAs: userAddress }); + + let signedTransaction: string; + if (isKrsRecovery) { + signedTransaction = userSignature.signedTransaction; + } else { + if (!keys[1].privateKey) { + throw new Error('backupKey is not a private key'); + } + const backupKey = keys[1].privateKey.toString('hex'); + const backupSignature = ripple.signWithPrivateKey(serializedTx, backupKey, { signAs: backupAddress }); + signedTransaction = ripple.multisign([userSignature.signedTransaction, backupSignature.signedTransaction]); + } + + const accountDeleteExplanation: RecoveryInfo = (await this.explainTransaction({ + txHex: signedTransaction, + })) as RecoveryInfo; + accountDeleteExplanation.txHex = signedTransaction; + if (isKrsRecovery) { + accountDeleteExplanation.backupKey = params.backupKey; + accountDeleteExplanation.coin = this.getChain(); + } + return accountDeleteExplanation; + } + // --- End AccountDelete path --- + const txBuilder = factory.getTransferBuilder() as TransferBuilder; txBuilder .to(params.recoveryDestination as string) .amount(recoverableBalance.toFixed(0)) - .sender(params.rootAddress) + .sender(rootAccount) .flags(2147483648) .lastLedgerSequence(currentLedger + 1000000) // give it 1 million ledgers' time (~1 month, suitable for KRS) .fee(openLedgerFee.times(3).toFixed(0)) // the factor three is for the multisigning diff --git a/modules/sdk-coin-xrp/test/resources/xrp.ts b/modules/sdk-coin-xrp/test/resources/xrp.ts index c790b493b2..5047090e14 100644 --- a/modules/sdk-coin-xrp/test/resources/xrp.ts +++ b/modules/sdk-coin-xrp/test/resources/xrp.ts @@ -397,6 +397,113 @@ export const serverInfoResponse = { }, }; +// ─── AccountDelete test fixtures ───────────────────────────────────────────── + +/** account_lines response with no trustlines – satisfies the reserveWithdrawal pre-check */ +export const accountlinesResponseEmpty = { + body: { + result: { + account: 'rNTfZB1h4TDdF9QXw37nbWk9euZmRby4qn', + ledger_hash: 'E6F38D1D7B94153BF7FFC8D8CC1DF57D57151D26FC2EB7647B5631786B955EFF', + ledger_index: 1848964, + lines: [], + validated: true, + }, + status: 'success', + type: 'response', + }, +}; + +/** account_objects response containing only a SignerList – passes the "no blocking objects" check */ +export const accountObjectsResponse = { + body: { + result: { + account: 'rNTfZB1h4TDdF9QXw37nbWk9euZmRby4qn', + account_objects: [ + { + Flags: 65536, + LedgerEntryType: 'SignerList', + OwnerNode: '0', + SignerEntries: [ + { SignerEntry: { Account: 'rwFcXstMseu91iejAdoYWCPaVR4GgdiV5i', SignerWeight: 1 } }, + { SignerEntry: { Account: 'r45kBeT5cmtaW6DHGAXzfjYHQzsVFhPX3M', SignerWeight: 1 } }, + { SignerEntry: { Account: 'r3mykfPQZt4eJZKLUGMNVB49eDSJiE9zh3', SignerWeight: 1 } }, + ], + SignerListID: 0, + SignerQuorum: 2, + index: '00B47042E37B5F11E6325D7BECAA08D165C6681DB4F6528AF7D1CA6ED50075B7', + }, + ], + ledger_current_index: 1851200, + validated: false, + }, + status: 'success', + type: 'response', + }, +}; + +/** account_objects response with a blocking Offer object – triggers the "blocking objects" error */ +export const accountObjectsResponseBlocking = { + body: { + result: { + account: 'rNTfZB1h4TDdF9QXw37nbWk9euZmRby4qn', + account_objects: [ + { + Flags: 0, + LedgerEntryType: 'Offer', + OwnerNode: '0', + index: 'AABBCCDD', + }, + ], + ledger_current_index: 1851200, + validated: false, + }, + status: 'success', + type: 'response', + }, +}; + +/** account_info for the destination address – funded account, no error */ +export const destAccountInfoResponse = { + body: { + result: { + account_data: { + Account: 'raBSn6ipeWXYe7rNbNafZSx9dV2fU3zRyP', + Balance: '20000000', + Flags: 0, + LedgerEntryType: 'AccountRoot', + Sequence: 1, + index: '000000000000000000000000000000000000000000000000000000000000000A', + }, + ledger_current_index: 1851200, + validated: true, + }, + status: 'success', + type: 'response', + }, +}; + +/** account_info for a destination that does not exist on the ledger */ +export const destAccountInfoNotFound = { + body: { + result: { + error: 'actNotFound', + error_code: 19, + error_message: 'Account not found.', + request: { + account: 'raBSn6ipeWXYe7rNbNafZSx9dV2fU3zRyP', + command: 'account_info', + ledger_index: 'validated', + strict: true, + }, + }, + status: 'error', + type: 'response', + }, +}; + +// ───────────────────────────────────────────────────────────────────────────── + export const enableTokenFixtures = { txParams: { type: 'enabletoken', diff --git a/modules/sdk-coin-xrp/test/unit/transactionBuilder/accountDeleteBuilder.ts b/modules/sdk-coin-xrp/test/unit/transactionBuilder/accountDeleteBuilder.ts new file mode 100644 index 0000000000..24bd8ea654 --- /dev/null +++ b/modules/sdk-coin-xrp/test/unit/transactionBuilder/accountDeleteBuilder.ts @@ -0,0 +1,118 @@ +import should from 'should'; +import * as rippleBinaryCodec from 'ripple-binary-codec'; +import utils from '../../../src/lib/utils'; +import * as testData from '../../resources/xrp'; +import { getBuilderFactory } from '../getBuilderFactory'; + +describe('XRP AccountDelete Builder', () => { + const factory = getBuilderFactory('txrp'); + + // Use single-sig account as sender, multi-sig account base address as destination + const sender = testData.TEST_SINGLE_SIG_ACCOUNT.address; // rKkq7my4cbS9mEcg8gwdcFW2HHxoYwRzny + const destinationBase = 'raJ4NmhHr2j2SGkmVFeMqKR5MUSWXjNF9a'; + const destinationWithTag = 'raJ4NmhHr2j2SGkmVFeMqKR5MUSWXjNF9a?dt=1'; + + describe('Succeed', () => { + it('should build an AccountDelete transaction without a destination tag', async function () { + const txBuilder = factory.getAccountDeleteBuilder(); + txBuilder.to(destinationBase); + txBuilder.sender(sender); + txBuilder.sequence(1545099); + txBuilder.fee('2000000'); + txBuilder.flags(2147483648); + + const tx = await txBuilder.build(); + const rawTx = tx.toBroadcastFormat(); + + should.equal(utils.isValidRawTransaction(rawTx), true); + + const decoded = rippleBinaryCodec.decode(rawTx); + (decoded as any).TransactionType.should.equal('AccountDelete'); + (decoded as any).Account.should.equal(sender); + (decoded as any).Destination.should.equal(destinationBase); + should.not.exist((decoded as any).DestinationTag); + }); + + it('should build an AccountDelete transaction with a destination tag', async function () { + const txBuilder = factory.getAccountDeleteBuilder(); + txBuilder.to(destinationWithTag); + txBuilder.sender(sender); + txBuilder.sequence(1545099); + txBuilder.fee('2000000'); + txBuilder.flags(2147483648); + + const tx = await txBuilder.build(); + const rawTx = tx.toBroadcastFormat(); + + should.equal(utils.isValidRawTransaction(rawTx), true); + + const decoded = rippleBinaryCodec.decode(rawTx); + (decoded as any).TransactionType.should.equal('AccountDelete'); + (decoded as any).Account.should.equal(sender); + (decoded as any).Destination.should.equal(destinationBase); + ((decoded as any).DestinationTag as number).should.equal(1); + }); + + it('should rebuild an AccountDelete transaction from its raw broadcast format', async function () { + const txBuilder = factory.getAccountDeleteBuilder(); + txBuilder.to(destinationBase); + txBuilder.sender(sender); + txBuilder.sequence(1545099); + txBuilder.fee('2000000'); + txBuilder.flags(2147483648); + + const tx = await txBuilder.build(); + const rawTx = tx.toBroadcastFormat(); + + // factory.from() should route to AccountDeleteBuilder and reproduce identical bytes + const rebuilder = factory.from(rawTx); + const rebuiltTx = await rebuilder.build(); + const rebuiltRawTx = rebuiltTx.toBroadcastFormat(); + + rebuiltRawTx.should.equal(rawTx); + }); + + it('should rebuild an AccountDelete transaction with destination tag from raw', async function () { + const txBuilder = factory.getAccountDeleteBuilder(); + txBuilder.to(destinationWithTag); + txBuilder.sender(sender); + txBuilder.sequence(1545099); + txBuilder.fee('2000000'); + txBuilder.flags(2147483648); + + const tx = await txBuilder.build(); + const rawTx = tx.toBroadcastFormat(); + + const rebuilder = factory.from(rawTx); + const rebuiltTx = await rebuilder.build(); + const rebuiltRawTx = rebuiltTx.toBroadcastFormat(); + + rebuiltRawTx.should.equal(rawTx); + + const decoded = rippleBinaryCodec.decode(rebuiltRawTx); + ((decoded as any).DestinationTag as number).should.equal(1); + }); + }); + + describe('Fail', () => { + it('should fail to build when destination is not set', async function () { + const txBuilder = factory.getAccountDeleteBuilder(); + txBuilder.sender(sender); + txBuilder.sequence(1545099); + txBuilder.fee('2000000'); + txBuilder.flags(2147483648); + + await txBuilder.build().should.be.rejectedWith('Destination must be set before building the transaction'); + }); + + it('should fail to build when sender is not set', async function () { + const txBuilder = factory.getAccountDeleteBuilder(); + txBuilder.to(destinationBase); + txBuilder.sequence(1545099); + txBuilder.fee('2000000'); + txBuilder.flags(2147483648); + + await txBuilder.build().should.be.rejectedWith('Invalid transaction: missing sender'); + }); + }); +}); diff --git a/modules/sdk-coin-xrp/test/unit/xrp.ts b/modules/sdk-coin-xrp/test/unit/xrp.ts index 8acc2ae752..b343cc95a3 100644 --- a/modules/sdk-coin-xrp/test/unit/xrp.ts +++ b/modules/sdk-coin-xrp/test/unit/xrp.ts @@ -442,6 +442,201 @@ describe('XRP:', function () { }); }); + describe('Recover with reserveWithdrawal (AccountDelete)', () => { + const sandBox = sinon.createSandbox(); + const destination = 'raBSn6ipeWXYe7rNbNafZSx9dV2fU3zRyP?dt=12345'; + const destBare = 'raBSn6ipeWXYe7rNbNafZSx9dV2fU3zRyP'; + const passPhrase = '#Bondiola1234'; + let xrplStub; + + afterEach(() => { + sandBox.restore(); + }); + + function setupAccountDeleteStubs( + overrides: { + accountLinesResponse?: any; + accountObjectsResponse?: any; + destInfoResponse?: any; + } = {} + ) { + xrplStub = sandBox.stub(basecoin.bitgo, 'post'); + + const { + accountLinesResponse = testData.accountlinesResponseEmpty, + accountObjectsResponse = testData.accountObjectsResponse, + destInfoResponse = testData.destAccountInfoResponse, + } = overrides; + + const accountInfoParams = { + method: 'account_info', + params: [ + { + account: testData.keys.rootAddress, + strict: true, + ledger_index: 'current', + queue: true, + signer_lists: true, + }, + ], + }; + const accountLinesParams = { + method: 'account_lines', + params: [{ account: testData.keys.rootAddress, ledger_index: 'validated' }], + }; + const accountObjectsParams = { + method: 'account_objects', + params: [{ account: testData.keys.rootAddress, ledger_index: 'validated' }], + }; + const destInfoParams = { + method: 'account_info', + params: [{ account: destBare, ledger_index: 'validated', strict: true }], + }; + + const sendStub = sinon.stub(); + sendStub.withArgs(accountInfoParams).resolves(testData.accountInfoResponse); + sendStub.withArgs({ method: 'fee' }).resolves(testData.feeResponse); + sendStub.withArgs({ method: 'server_info' }).resolves(testData.serverInfoResponse); + sendStub.withArgs(accountLinesParams).resolves(accountLinesResponse); + sendStub.withArgs(accountObjectsParams).resolves(accountObjectsResponse); + sendStub.withArgs(destInfoParams).resolves(destInfoResponse); + + xrplStub.withArgs(basecoin.getRippledUrl()).returns({ send: sendStub }); + } + + it('should build a fully signed AccountDelete transaction (non-KRS recovery)', async function () { + setupAccountDeleteStubs(); + + const res = await basecoin.recover({ + userKey: testData.keys.userKey, + backupKey: testData.keys.backupKey, + rootAddress: testData.keys.rootAddress, + recoveryDestination: destination, + walletPassphrase: passPhrase, + reserveWithdrawal: true, + }); + + res.should.not.be.empty(); + res.should.hasOwnProperty('txHex'); + + const decoded: any = rippleBinaryCodec.decode(res.txHex); + decoded.TransactionType.should.equal('AccountDelete'); + decoded.Account.should.equal(testData.keys.rootAddress); + decoded.Destination.should.equal(destBare); + (decoded.DestinationTag as number).should.equal(12345); + // Fully signed multi-sig: two signers in the Signers array + (decoded.Signers as Array).length.should.equal(2); + }); + + it('should build an unsigned sweep AccountDelete transaction when xpub keys are provided', async function () { + // Use the xpub keys and root address from the existing unsigned-sweep token test, which are + // already matched to accountInfoResponseUnsigned's signer list. + xrplStub = sandBox.stub(basecoin.bitgo, 'post'); + + const unsignedRootAddress = 'raGZWRkRBUWdQJsKYEzwXJNbCZMTqX56aA'; + const accountInfoParamsUnsigned = { + method: 'account_info', + params: [ + { + account: unsignedRootAddress, + strict: true, + ledger_index: 'current', + queue: true, + signer_lists: true, + }, + ], + }; + const accountLinesParamsUnsigned = { + method: 'account_lines', + params: [{ account: unsignedRootAddress, ledger_index: 'validated' }], + }; + const accountObjectsParamsUnsigned = { + method: 'account_objects', + params: [{ account: unsignedRootAddress, ledger_index: 'validated' }], + }; + const destInfoParamsUnsigned = { + method: 'account_info', + params: [{ account: destBare, ledger_index: 'validated', strict: true }], + }; + + const sendStub = sinon.stub(); + sendStub.withArgs(accountInfoParamsUnsigned).resolves(testData.accountInfoResponseUnsigned); + sendStub.withArgs({ method: 'fee' }).resolves(testData.feeResponse); + sendStub.withArgs({ method: 'server_info' }).resolves(testData.serverInfoResponse); + sendStub.withArgs(accountLinesParamsUnsigned).resolves(testData.accountlinesResponseEmpty); + sendStub.withArgs(accountObjectsParamsUnsigned).resolves(testData.accountObjectsResponse); + sendStub.withArgs(destInfoParamsUnsigned).resolves(testData.destAccountInfoResponse); + xrplStub.withArgs(basecoin.getRippledUrl()).returns({ send: sendStub }); + + const res = await basecoin.recover({ + userKey: + 'xpub661MyMwAqRbcF9Ya4zDHGzDtJz3NaaeEGbQ6rnqnNxL9RXDJNHcfzAyPUBXuKXjytvJNzQxqbjBwmPveiYX323Zp8Zx2RYQN9gGM7ntiXxr', + backupKey: + 'xpub661MyMwAqRbcFtWdmWHKZEh9pYiJrAGTu1NNSwxY2S63tU9nGcfCAbNUKQuFqXRTRk8KkuBabxo6YjeBri8Q7dkMsmths6MVxSd6MTaeCmd', + rootAddress: unsignedRootAddress, + recoveryDestination: destination, + walletPassphrase: passPhrase, + reserveWithdrawal: true, + }); + + res.should.not.be.empty(); + res.should.hasOwnProperty('txHex'); + res.should.hasOwnProperty('coin'); + + const decoded: any = xrpl.decode(res.txHex); + decoded.TransactionType.should.equal('AccountDelete'); + decoded.Account.should.equal(unsignedRootAddress); + decoded.Destination.should.equal(destBare); + (decoded.DestinationTag as number).should.equal(12345); + }); + + it('should throw when account has non-zero trustline balances', async function () { + // accountlinesResponse has a non-zero balance line (balance: '4') + setupAccountDeleteStubs({ accountLinesResponse: testData.accountlinesResponse }); + + await basecoin + .recover({ + userKey: testData.keys.userKey, + backupKey: testData.keys.backupKey, + rootAddress: testData.keys.rootAddress, + recoveryDestination: destination, + walletPassphrase: passPhrase, + reserveWithdrawal: true, + }) + .should.be.rejectedWith(/trustline/); + }); + + it('should throw when account owns blocking objects other than SignerList', async function () { + setupAccountDeleteStubs({ accountObjectsResponse: testData.accountObjectsResponseBlocking }); + + await basecoin + .recover({ + userKey: testData.keys.userKey, + backupKey: testData.keys.backupKey, + rootAddress: testData.keys.rootAddress, + recoveryDestination: destination, + walletPassphrase: passPhrase, + reserveWithdrawal: true, + }) + .should.be.rejectedWith(/Offer/); + }); + + it('should throw when the recovery destination does not exist on the ledger', async function () { + setupAccountDeleteStubs({ destInfoResponse: testData.destAccountInfoNotFound }); + + await basecoin + .recover({ + userKey: testData.keys.userKey, + backupKey: testData.keys.backupKey, + rootAddress: testData.keys.rootAddress, + recoveryDestination: destination, + walletPassphrase: passPhrase, + reserveWithdrawal: true, + }) + .should.be.rejectedWith(/does not exist on the ledger/); + }); + }); + describe('Verify Transaction', () => { let newTxPrebuild; diff --git a/modules/sdk-core/src/account-lib/baseCoin/enum.ts b/modules/sdk-core/src/account-lib/baseCoin/enum.ts index eb61e0f3fd..acd453a422 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/enum.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/enum.ts @@ -129,6 +129,9 @@ export enum TransactionType { TonWhalesWithdrawal, TonWhalesVestingDeposit, TonWhalesVestingWithdrawal, + + // xrp — delete an account and recover the full balance including reserve + AccountDelete, } /**