Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 52 additions & 6 deletions modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,25 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
* MoveCall(0x2::coin::redeem_funds, [withdrawal(amount, coinType)]) → Coin<T>
* 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<T>
* 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<T>
* unused, which causes UnusedValueWithoutDrop since Coin<T> has no drop ability.)
*
* Exception B — addr-bal only, partial amount (fundsInAddressBalance > sum(recipient amounts)):
* MoveCall(0x2::coin::redeem_funds, [withdrawal(amount, coinType)]) → Coin<T>
* 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<SUI> and addrCoin is Coin<T> — incompatible types.)
*
* @return {SuiTransaction<TokenTransferProgrammableTransaction>}
* @protected
*/
Expand Down Expand Up @@ -219,12 +238,39 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
programmableTxBuilder.mergeCoins(mergedObject, inputObjects);
}

this._recipients.forEach((recipient) => {
const splitObject = programmableTxBuilder.splitCoins(mergedObject, [
programmableTxBuilder.pure(BigInt(recipient.amount)),
]);
programmableTxBuilder.transferObjects([splitObject], programmableTxBuilder.object(recipient.address));
});
// addrCoin (the Coin<T> returned by redeem_funds) is a command result and must be explicitly
// consumed — Coin<T> 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<SUI> but addrCoin is Coin<T>, incompatible types).
programmableTxBuilder.transferObjects([mergedObject], programmableTxBuilder.object(this._sender));
}
}

const txData = programmableTxBuilder.blockData;
return {
Expand Down
15 changes: 15 additions & 0 deletions modules/sdk-coin-sui/src/lib/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,21 @@ export abstract class Transaction<T> 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;
}
Expand Down
42 changes: 42 additions & 0 deletions modules/sdk-coin-sui/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> 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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TokenTransferProgrammableTransaction>;
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<T>)', 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<TokenTransferProgrammableTransaction>;
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<TokenTransferProgrammableTransaction>;
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<TokenTransferProgrammableTransaction>;
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);

Expand Down
Loading
Loading