From 112ddb8f86aab5e3b996848b775f7d5e2a90a632 Mon Sep 17 00:00:00 2001 From: Abhishek Agrawal Date: Fri, 3 Jul 2026 13:39:20 +0530 Subject: [PATCH] fix(sdk-coin-sui): skip splitCoins entirely when the full amount withdrawn TICKET: CSHLD-1146 --- .../src/lib/tokenTransferBuilder.ts | 58 +++++- modules/sdk-coin-sui/src/lib/transaction.ts | 15 ++ modules/sdk-coin-sui/src/lib/utils.ts | 42 ++++ .../tokenTransferBuilder.ts | 188 ++++++++++++++++++ .../transactionBuilder/transferBuilder.ts | 150 ++++++++++++++ 5 files changed, 447 insertions(+), 6 deletions(-) diff --git a/modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts b/modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts index 1177da1866..ee45697964 100644 --- a/modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts +++ b/modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts @@ -186,6 +186,25 @@ export class TokenTransferBuilder extends TransactionBuilder * MergeCoins(inputObject[0] | addrCoin, [rest...]) → SplitCoins → TransferObjects * + * Exception A — addr-bal only, full amount (fundsInAddressBalance === sum(recipient amounts)): + * MoveCall(0x2::coin::redeem_funds, [withdrawal(amount, coinType)]) → Coin + * SplitCoins(addrCoin, [amount0]) → TransferObjects(split0, addr0) # recipients 0..N-2 + * ... + * TransferObjects([addrCoin], addrN-1) # last recipient directly + * (After N-1 splits addrCoin.balance === recipients[N-1].amount exactly, so it can be + * transferred directly. SplitCoins on the last recipient would leave a zero-balance Coin + * unused, which causes UnusedValueWithoutDrop since Coin has no drop ability.) + * + * Exception B — addr-bal only, partial amount (fundsInAddressBalance > sum(recipient amounts)): + * MoveCall(0x2::coin::redeem_funds, [withdrawal(amount, coinType)]) → Coin + * SplitCoins(addrCoin, [amount0]) → TransferObjects(split0, addr0) # all recipients + * ... + * TransferObjects([addrCoin], sender) # return change to sender + * (addrCoin still holds the unspent remainder; transferring it back to the sender consumes + * the command result, avoiding UnusedValueWithoutDrop. Mirrors the native SUI TransferBuilder + * change-return path. Unlike native SUI, MergeCoins(gas, [addrCoin]) is not possible here + * because gas is Coin and addrCoin is Coin — incompatible types.) + * * @return {SuiTransaction} * @protected */ @@ -219,12 +238,39 @@ export class TokenTransferBuilder extends TransactionBuilder { - const splitObject = programmableTxBuilder.splitCoins(mergedObject, [ - programmableTxBuilder.pure(BigInt(recipient.amount)), - ]); - programmableTxBuilder.transferObjects([splitObject], programmableTxBuilder.object(recipient.address)); - }); + // addrCoin (the Coin returned by redeem_funds) is a command result and must be explicitly + // consumed — Coin has no `drop` ability, so leaving it unused causes UnusedValueWithoutDrop. + // This only applies when there are no coin objects (addr-bal only); in the hybrid case addrCoin + // is consumed upfront by the MergeCoins above. + const recipientTotal = this._recipients.reduce((sum, r) => sum.plus(r.amount), new BigNumber(0)); + const isAddrBalOnly = (this._inputObjects ?? []).length === 0 && this._fundsInAddressBalance.gt(0); + + if (isAddrBalOnly && this._fundsInAddressBalance.eq(recipientTotal)) { + // Exception A — full amount: split for first N-1 recipients, transfer addrCoin directly + // to the last. After N-1 splits addrCoin.balance === recipients[N-1].amount exactly. + this._recipients.slice(0, -1).forEach((recipient) => { + const splitObject = programmableTxBuilder.splitCoins(mergedObject, [ + programmableTxBuilder.pure(BigInt(recipient.amount)), + ]); + programmableTxBuilder.transferObjects([splitObject], programmableTxBuilder.object(recipient.address)); + }); + const lastRecipient = this._recipients[this._recipients.length - 1]; + programmableTxBuilder.transferObjects([mergedObject], programmableTxBuilder.object(lastRecipient.address)); + } else { + // Standard path: split and transfer for every recipient. + this._recipients.forEach((recipient) => { + const splitObject = programmableTxBuilder.splitCoins(mergedObject, [ + programmableTxBuilder.pure(BigInt(recipient.amount)), + ]); + programmableTxBuilder.transferObjects([splitObject], programmableTxBuilder.object(recipient.address)); + }); + if (isAddrBalOnly && this._fundsInAddressBalance.gt(recipientTotal)) { + // Exception B — partial amount: addrCoin still holds the unspent remainder. + // Return it to the sender to consume the command result (MergeCoins into gas is not + // possible here — gas is Coin but addrCoin is Coin, incompatible types). + programmableTxBuilder.transferObjects([mergedObject], programmableTxBuilder.object(this._sender)); + } + } const txData = programmableTxBuilder.blockData; return { diff --git a/modules/sdk-coin-sui/src/lib/transaction.ts b/modules/sdk-coin-sui/src/lib/transaction.ts index 8fc66b3e10..44e3d52665 100644 --- a/modules/sdk-coin-sui/src/lib/transaction.ts +++ b/modules/sdk-coin-sui/src/lib/transaction.ts @@ -274,6 +274,21 @@ export abstract class Transaction extends BaseTransaction { ) { return SuiTransactionType.Transfer; } + // Full-amount token addr-bal send: MoveCall(redeem_funds, TOKEN_TYPE) + TransferObjects + // with no SplitCoins. Both commands return Transfer from getSuiTransactionType, so the + // check below wouldn't fire. Detect by finding redeem_funds with a non-native type arg. + if ( + transactions.some((tx) => { + if (tx.kind !== 'MoveCall') return false; + const moveCall = tx as any; + if (!moveCall.target?.endsWith(MethodNames.RedeemFunds)) return false; + const typeArg: string = moveCall.typeArguments?.[0] ?? ''; + const [addr = '', mod = '', name = ''] = typeArg.split('::'); + return !(normalizeSuiAddress(addr) === suiNativeAddress && mod === 'sui' && name === 'SUI'); + }) + ) { + return SuiTransactionType.TokenTransfer; + } if (transactions.some((tx) => utils.getSuiTransactionType(tx) === SuiTransactionType.TokenTransfer)) { return SuiTransactionType.TokenTransfer; } diff --git a/modules/sdk-coin-sui/src/lib/utils.ts b/modules/sdk-coin-sui/src/lib/utils.ts index 93f9846f67..fe7150db25 100644 --- a/modules/sdk-coin-sui/src/lib/utils.ts +++ b/modules/sdk-coin-sui/src/lib/utils.ts @@ -302,6 +302,48 @@ export class Utils implements BaseUtils { }); }); + // Full-amount addr-bal transfer: the last recipient receives mergedObject (addrCoin) + // directly via TransferObjects rather than via SplitCoins, to avoid leaving a zero-balance + // Coin unused (UnusedValueWithoutDrop). Recover amounts from BalanceWithdrawal. + // + // Single-recipient (0 SplitCoins, 1 TransferObjects): receipts is empty — recover the + // full withdrawal amount and push one receipt. + // + // Multi-recipient (N-1 SplitCoins, N TransferObjects): receipts has N-1 entries — recover + // the last recipient's amount as BalanceWithdrawal.amount - sum(splitResults). + if (receipts.length === 0 && destinations.length > 0) { + const withdrawalInput = (tx.tx.inputs as any[]).find( + (input: any) => input?.BalanceWithdrawal != null || input?.value?.BalanceWithdrawal != null + ) as any; + if (withdrawalInput) { + const bw = withdrawalInput.BalanceWithdrawal ?? withdrawalInput.value?.BalanceWithdrawal; + const amount = String(bw.reservation?.MaxAmountU64 ?? bw.amount ?? 0); + destinations.forEach((address) => { + receipts.push({ address, amount }); + }); + } + } else if (receipts.length > 0 && destinations.length === receipts.length + 1) { + const lastDest = destinations[destinations.length - 1]; + // Path 1b with change returns the surplus to the sender via a trailing TransferObjects — + // this destination is intentionally excluded by the zip-limit above and must not be + // re-added as a recipient. Only proceed when the last destination is NOT the sender + // (i.e. it is the final recipient in a multi-recipient full-amount addr-bal send). + if (lastDest !== tx.sender) { + const withdrawalInput = (tx.tx.inputs as any[]).find( + (input: any) => input?.BalanceWithdrawal != null || input?.value?.BalanceWithdrawal != null + ) as any; + if (withdrawalInput) { + const bw = withdrawalInput.BalanceWithdrawal ?? withdrawalInput.value?.BalanceWithdrawal; + const totalWithdrawn = Number(bw.reservation?.MaxAmountU64 ?? bw.amount ?? 0); + const splitTotal = splitResults.reduce((sum, v) => sum + v, 0); + const lastAmount = totalWithdrawn - splitTotal; + if (lastAmount > 0) { + receipts.push({ address: lastDest, amount: String(lastAmount) }); + } + } + } + } + tx.tx.transactions.forEach((transaction) => { if (transaction.kind === 'MoveCall' && transaction.target.endsWith(MethodNames.PublicTransfer)) { const destinationArg = transaction.arguments[1]; diff --git a/modules/sdk-coin-sui/test/unit/transactionBuilder/tokenTransferBuilder.ts b/modules/sdk-coin-sui/test/unit/transactionBuilder/tokenTransferBuilder.ts index a496782ff1..d79e0702a4 100644 --- a/modules/sdk-coin-sui/test/unit/transactionBuilder/tokenTransferBuilder.ts +++ b/modules/sdk-coin-sui/test/unit/transactionBuilder/tokenTransferBuilder.ts @@ -217,6 +217,194 @@ describe('Sui Token Transfer Builder', () => { rebuiltTx.toJson().inputObjects.length.should.equal(numberOfInputObjects); }); + it('should build a token transfer using only address balance when sending the full amount (no SplitCoins)', async function () { + const fullAmount = '82402000000'; + + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.type(SuiTransactionType.TokenTransfer); + txBuilder.sender(testData.sender.address); + txBuilder.send([{ address: testData.recipients[0].address, amount: fullAmount }]); + txBuilder.gasData(testData.gasData); + txBuilder.fundsInAddressBalance(fullAmount); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + const programmableTx = suiTx.suiTransaction.tx; + + // Cmd 0: MoveCall(redeem_funds) + (programmableTx.transactions[0] as any).kind.should.equal('MoveCall'); + (programmableTx.transactions[0] as any).target.should.equal('0x2::coin::redeem_funds'); + + // Cmd 1: TransferObjects directly — no SplitCoins + (programmableTx.transactions[1] as any).kind.should.equal('TransferObjects'); + programmableTx.transactions.some((t: any) => t.kind === 'SplitCoins').should.be.false(); + + // getRecipients must recover the correct amount and address + const recipients = utils.getRecipients(suiTx.suiTransaction); + recipients.length.should.equal(1); + recipients[0].address.should.equal(testData.recipients[0].address); + recipients[0].amount.should.equal(fullAmount); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + + // Round-trip: rebuilt tx must produce the same serialized output + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + }); + + it('should build a token transfer using only address balance when sending the full amount to multiple recipients (no unused Coin)', async function () { + const amounts = ['30000000000', '52402000000']; + const total = (BigInt(amounts[0]) + BigInt(amounts[1])).toString(); + + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.type(SuiTransactionType.TokenTransfer); + txBuilder.sender(testData.sender.address); + txBuilder.send([ + { address: testData.recipients[0].address, amount: amounts[0] }, + { address: testData.recipients[1].address, amount: amounts[1] }, + ]); + txBuilder.gasData(testData.gasData); + txBuilder.fundsInAddressBalance(total); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + const cmds = suiTx.suiTransaction.tx.transactions as any[]; + + // Cmd 0: MoveCall(redeem_funds) + cmds[0].kind.should.equal('MoveCall'); + cmds[0].target.should.equal('0x2::coin::redeem_funds'); + + // Cmd 1+2: SplitCoins + TransferObjects for first recipient + cmds[1].kind.should.equal('SplitCoins'); + cmds[2].kind.should.equal('TransferObjects'); + + // Cmd 3: TransferObjects directly for last recipient — no SplitCoins for it + cmds[3].kind.should.equal('TransferObjects'); + cmds.length.should.equal(4); + + // getRecipients must recover both amounts and addresses + const recipients = utils.getRecipients(suiTx.suiTransaction); + recipients.length.should.equal(2); + recipients[0].address.should.equal(testData.recipients[0].address); + recipients[0].amount.should.equal(amounts[0]); + recipients[1].address.should.equal(testData.recipients[1].address); + recipients[1].amount.should.equal(amounts[1]); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + }); + + it('should build a token transfer using partial address balance (single recipient) and return change to sender', async function () { + const addrBal = '100000000000'; + const sendAmount = '82402000000'; // less than addrBal → change = 17598000000 + + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.type(SuiTransactionType.TokenTransfer); + txBuilder.sender(testData.sender.address); + txBuilder.send([{ address: testData.recipients[0].address, amount: sendAmount }]); + txBuilder.gasData(testData.gasData); + txBuilder.fundsInAddressBalance(addrBal); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + const cmds = suiTx.suiTransaction.tx.transactions as any[]; + + // Cmd 0: MoveCall(redeem_funds) + cmds[0].kind.should.equal('MoveCall'); + cmds[0].target.should.equal('0x2::coin::redeem_funds'); + // Cmd 1: SplitCoins for the recipient + cmds[1].kind.should.equal('SplitCoins'); + // Cmd 2: TransferObjects to recipient + cmds[2].kind.should.equal('TransferObjects'); + // Cmd 3: TransferObjects([addrCoin], sender) — returns change, consumes addrCoin + cmds[3].kind.should.equal('TransferObjects'); + cmds.length.should.equal(4); + + // Verify the last TransferObjects goes to the sender (not the recipient) + const lastCmd = cmds[3] as any; + const changeAddrInput = suiTx.suiTransaction.tx.inputs[lastCmd.address.index] as any; + utils.getAddress(changeAddrInput).should.equal(testData.sender.address); + + // getRecipients must return only the actual recipient, not the change transfer + const recipients = utils.getRecipients(suiTx.suiTransaction); + recipients.length.should.equal(1); + recipients[0].address.should.equal(testData.recipients[0].address); + recipients[0].amount.should.equal(sendAmount); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + }); + + it('should build a token transfer using partial address balance (multiple recipients) and return change to sender', async function () { + const amounts = ['30000000000', '40000000000']; + const addrBal = '100000000000'; // more than total → change = 30000000000 + + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.type(SuiTransactionType.TokenTransfer); + txBuilder.sender(testData.sender.address); + txBuilder.send([ + { address: testData.recipients[0].address, amount: amounts[0] }, + { address: testData.recipients[1].address, amount: amounts[1] }, + ]); + txBuilder.gasData(testData.gasData); + txBuilder.fundsInAddressBalance(addrBal); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + const cmds = suiTx.suiTransaction.tx.transactions as any[]; + + // MoveCall + 2×(SplitCoins+TransferObjects) + TransferObjects(change to sender) + cmds[0].kind.should.equal('MoveCall'); + cmds[1].kind.should.equal('SplitCoins'); + cmds[2].kind.should.equal('TransferObjects'); + cmds[3].kind.should.equal('SplitCoins'); + cmds[4].kind.should.equal('TransferObjects'); + cmds[5].kind.should.equal('TransferObjects'); // change + cmds.length.should.equal(6); + + // Last command returns change to sender + const lastCmd = cmds[5] as any; + const changeAddrInput = suiTx.suiTransaction.tx.inputs[lastCmd.address.index] as any; + utils.getAddress(changeAddrInput).should.equal(testData.sender.address); + + // getRecipients must return only the 2 actual recipients + const recipients = utils.getRecipients(suiTx.suiTransaction); + recipients.length.should.equal(2); + recipients[0].address.should.equal(testData.recipients[0].address); + recipients[0].amount.should.equal(amounts[0]); + recipients[1].address.should.equal(testData.recipients[1].address); + recipients[1].amount.should.equal(amounts[1]); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + }); + it('should correctly reconstruct fundsInAddressBalance from raw transaction', async function () { const inputObjects = testData.generateObjects(2); diff --git a/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts b/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts index 301285cbf0..7ca7c7b219 100644 --- a/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts +++ b/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts @@ -1258,4 +1258,154 @@ describe('Sui Transfer Builder', () => { should.equal(utils.isValidRawTransaction(rawTx), true); }); }); + + describe('full-amount addr-bal send (UnusedValueWithoutDrop parity with token fix)', () => { + // For Coin (TokenTransferBuilder), when fundsInAddressBalance === sendAmount + // and there are no coin objects, SplitCoins leaves a zero-balance Coin command result + // unused, causing UnusedValueWithoutDrop at the SUI VM level. The token builder fixes + // this by using TransferObjects(addrCoin, recipient) directly — skipping SplitCoins. + // + // For native SUI (TransferBuilder), the same "zero-balance command result" issue does NOT + // require a special path because: + // - Path 1b (sponsored): MergeCoins(GasCoin, [addrCoin]) destroys the 0-balance Coin + // (gas IS SUI, so the merge is type-compatible). Tested in fundsInAddressBalance suite. + // - Path 2b (self-pay): withdrawal(totalRecipientAmount) drains addrCoin exactly, then + // MergeCoins(GasCoin, [addrCoin]) destroys it. Tested in BalanceWithdrawal BCS suite. + // + // These tests verify that TransferBuilder always uses the native SUI type arg (not a token + // type) and that the full-amount send correctly destroys the zero-balance addrCoin. + + it('Path 1b full-amount: sponsored addr-bal-only send uses 0x2::sui::SUI type arg and destroys zero-balance addrCoin', async function () { + const SEND_AMOUNT = '500000000'; // 0.5 SUI + const sponsoredGasData = { + ...testData.gasData, + owner: testData.feePayer.address, + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send([{ address: testData.recipients[0].address, amount: SEND_AMOUNT }]); + txBuilder.gasData(sponsoredGasData); + txBuilder.fundsInAddressBalance(SEND_AMOUNT); // exact match: send-all + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + const cmds = suiTx.suiTransaction.tx.transactions as any[]; + + // MoveCall must use native SUI type, never a token type + cmds[0].kind.should.equal('MoveCall'); + cmds[0].target.should.equal('0x2::coin::redeem_funds'); + cmds[0].typeArguments[0].should.equal('0x2::sui::SUI'); + + // Path 1b send-all: SplitCoins → TransferObjects → MergeCoins(gas, [addrCoin]) + // The MergeCoins at the end destroys the zero-balance addrCoin (type-compatible since gas IS SUI) + cmds[1].kind.should.equal('SplitCoins'); + cmds[2].kind.should.equal('TransferObjects'); + cmds[3].kind.should.equal('MergeCoins', 'zero-balance addrCoin must be destroyed via MergeCoins(gas)'); + cmds.length.should.equal(4); + + const parsedRecipients = utils.getRecipients(suiTx.suiTransaction); + parsedRecipients.length.should.equal(1); + parsedRecipients[0].address.should.equal(testData.recipients[0].address); + parsedRecipients[0].amount.should.equal(SEND_AMOUNT); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + }); + + it('Path 1b full-amount: multiple recipients, all drained from addr-bal, last MergeCoins destroys addrCoin', async function () { + const SEND_AMOUNT_EACH = '100000000'; // 0.1 SUI per recipient + const TOTAL = (Number(SEND_AMOUNT_EACH) * testData.recipients.length).toString(); + const sponsoredGasData = { + ...testData.gasData, + owner: testData.feePayer.address, + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send(testData.recipients.map((r) => ({ ...r, amount: SEND_AMOUNT_EACH }))); + txBuilder.gasData(sponsoredGasData); + txBuilder.fundsInAddressBalance(TOTAL); // send-all across multiple recipients + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + const cmds = suiTx.suiTransaction.tx.transactions as any[]; + + // For N recipients: MoveCall + N×(SplitCoins+TransferObjects) + MergeCoins + cmds[0].kind.should.equal('MoveCall'); + cmds[0].typeArguments[0].should.equal('0x2::sui::SUI'); + cmds[cmds.length - 1].kind.should.equal('MergeCoins', 'last command must destroy zero-balance addrCoin'); + + const parsedRecipients = utils.getRecipients(suiTx.suiTransaction); + parsedRecipients.length.should.equal(testData.recipients.length); + parsedRecipients.forEach((r, i) => { + r.address.should.equal(testData.recipients[i].address); + r.amount.should.equal(SEND_AMOUNT_EACH); + }); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + }); + + it('Path 2b full-amount: self-pay addr-bal-only uses native SUI type arg and destroys zero-balance addrCoin', async function () { + // Path 2b withdraws totalRecipientAmount (not fundsInAddressBalance), so addrCoin is + // exactly 0 after all splits. MergeCoins(GasCoin, [addrCoin]) destroys it. + // The remaining fundsInAddressBalance in the accumulator covers gas settlement. + const SEND_AMOUNT = '500000000'; // 0.5 SUI to send + const ADDR_BAL = '510000000'; // 0.51 SUI total — surplus stays in accumulator for gas + const gasDataNoPayment = { + ...testData.gasDataWithoutGasPayment, + payment: [], + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send([{ address: testData.recipients[0].address, amount: SEND_AMOUNT }]); + txBuilder.gasData(gasDataNoPayment); + txBuilder.fundsInAddressBalance(ADDR_BAL); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + const cmds = suiTx.suiTransaction.tx.transactions as any[]; + + // Path 2b: MoveCall(redeem_funds, SUI) → SplitCoins → TransferObjects → MergeCoins(gas, addrCoin) + cmds[0].kind.should.equal('MoveCall'); + cmds[0].target.should.equal('0x2::coin::redeem_funds'); + cmds[0].typeArguments[0].should.equal('0x2::sui::SUI'); // always native SUI, never a token type + cmds[1].kind.should.equal('SplitCoins'); + cmds[2].kind.should.equal('TransferObjects'); + cmds[3].kind.should.equal('MergeCoins', 'zero-balance addrCoin must be destroyed via MergeCoins(gas)'); + cmds.length.should.equal(4); + + const parsedRecipients = utils.getRecipients(suiTx.suiTransaction); + parsedRecipients.length.should.equal(1); + parsedRecipients[0].address.should.equal(testData.recipients[0].address); + parsedRecipients[0].amount.should.equal(SEND_AMOUNT); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + }); + }); });