Skip to content

Commit 49703c2

Browse files
Merge pull request #8920 from BitGo/CSHLD-948
feat(sdk-coin-sui): handle fab balance instruction
2 parents 9694608 + 051eda5 commit 49703c2

2 files changed

Lines changed: 100 additions & 11 deletions

File tree

modules/sdk-coin-sui/src/lib/transferBuilder.ts

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ export class TransferBuilder extends TransactionBuilder<TransferProgrammableTran
2020
/**
2121
* Balance held in the Sui address balance system (not in coin objects).
2222
* When set, this amount is included in the total available balance for transfer.
23-
* At execution time, Sui's GasCoin automatically draws from both coin objects
24-
* (gasData.payment) and address balance, so SplitCoins(GasCoin, [amount])
25-
* can spend funds from either source.
23+
* Note: Sui does NOT automatically merge address balance into the gas coin when
24+
* gasData.payment is non-empty. Path 2c explicitly redeems it via redeem_funds
25+
* and merges it into the gas coin before splitting.
2626
*/
2727
protected _fundsInAddressBalance: BigNumber = new BigNumber(0);
2828

@@ -179,7 +179,7 @@ export class TransferBuilder extends TransactionBuilder<TransferProgrammableTran
179179
/**
180180
* Build transfer programmable transaction.
181181
*
182-
* Three build paths:
182+
* Four build paths:
183183
*
184184
* Path 1a — Sponsored with coin objects (sender ≠ gasData.owner, inputObjects provided):
185185
* [optional withdrawal(fundsInAddressBalance) → redeem_funds → Coin<SUI>]
@@ -193,10 +193,16 @@ export class TransferBuilder extends TransactionBuilder<TransferProgrammableTran
193193
* Handles Case 5 (sponsor coin-object gas) and Case 7/Phase-4b (sponsor addr-bal gas).
194194
* Caller must set ValidDuring expiration when gasData.payment = [] (Cases 7, 9).
195195
*
196-
* Path 2 — Self-pay (sender === gasData.owner):
196+
* Path 2a/2b — Self-pay, coin objects only OR address-balance only (sender === gasData.owner):
197197
* SplitCoins(GasCoin, [amount]) → TransferObjects
198-
* GasCoin at Sui execution time = gasData.payment objects merged + fundsInAddressBalance.
199-
* Handles Case 1 (coins), Case 2 (addr-bal only, caller sets ValidDuring), Case 3 (mixed).
198+
* Handles Case 1 (coins only) and Case 2 (addr-bal only, caller sets ValidDuring).
199+
*
200+
* Path 2c — Self-pay, mixed (sender === gasData.owner, gasData.payment non-empty AND fundsInAddressBalance > 0):
201+
* Sui does NOT automatically merge address balance into gas coin when payment is non-empty.
202+
* withdrawal(fundsInAddressBalance) → redeem_funds → Coin<SUI>
203+
* MergeCoins(GasCoin, [addrCoin])
204+
* SplitCoins(GasCoin, [amount]) → TransferObjects
205+
* Handles Case 3 (mixed funds — coin objects + address balance, self-pay).
200206
*
201207
* @protected
202208
*/
@@ -297,6 +303,63 @@ export class TransferBuilder extends TransactionBuilder<TransferProgrammableTran
297303
fundsInAddressBalance: this._fundsInAddressBalance.toFixed(),
298304
};
299305
} else {
306+
// Path 2c: self-pay, mixed — coin objects (gasData.payment) AND address balance both present.
307+
// Sui does NOT automatically merge fundsInAddressBalance into the gas coin when
308+
// gasData.payment is non-empty. We must explicitly redeem the address balance as a
309+
// Coin<SUI> and merge it into the gas coin before splitting for recipients.
310+
if (this._fundsInAddressBalance.gt(0) && this._gasData.payment.length > 0) {
311+
// Merge excess gas payment objects first (same overflow logic as Path 2a/2b below).
312+
if (this._gasData.payment.length >= MAX_GAS_OBJECTS) {
313+
const gasPaymentObjects = this._gasData.payment
314+
.slice(MAX_GAS_OBJECTS - 1)
315+
.map((object) => Inputs.ObjectRef(object));
316+
317+
while (gasPaymentObjects.length > 0) {
318+
programmableTxBuilder.mergeCoins(
319+
programmableTxBuilder.gas,
320+
gasPaymentObjects.splice(0, MAX_COMMAND_ARGS - 1).map((object) => programmableTxBuilder.object(object))
321+
);
322+
}
323+
}
324+
325+
// Redeem address balance as Coin<SUI> and merge into the gas coin so that
326+
// SplitCoins(GasCoin, [amount]) can draw from the full available balance:
327+
// gas coin = sum(coinObjects) + fundsInAddressBalance
328+
const [addrCoin] = programmableTxBuilder.moveCall({
329+
target: '0x2::coin::redeem_funds',
330+
typeArguments: ['0x2::sui::SUI'],
331+
arguments: [programmableTxBuilder.withdrawal({ amount: BigInt(this._fundsInAddressBalance.toFixed()) })],
332+
});
333+
programmableTxBuilder.mergeCoins(programmableTxBuilder.gas, [addrCoin]);
334+
335+
this._recipients.forEach((recipient) => {
336+
const coin = programmableTxBuilder.add(
337+
TransactionsConstructor.SplitCoins(programmableTxBuilder.gas, [
338+
programmableTxBuilder.pure(BigInt(recipient.amount)),
339+
])
340+
);
341+
programmableTxBuilder.add(
342+
TransactionsConstructor.TransferObjects([coin], programmableTxBuilder.object(recipient.address))
343+
);
344+
});
345+
const txData2c = programmableTxBuilder.blockData;
346+
return {
347+
type: this._type,
348+
sender: this._sender,
349+
tx: {
350+
inputs: [...txData2c.inputs],
351+
transactions: [...txData2c.transactions],
352+
},
353+
gasData: {
354+
...this._gasData,
355+
payment: this._gasData.payment.slice(0, MAX_GAS_OBJECTS - 1),
356+
},
357+
expiration: this._expiration,
358+
fundsInAddressBalance: this._fundsInAddressBalance.toFixed(),
359+
};
360+
}
361+
362+
// Path 2a / 2b: self-pay, coin objects only OR address-balance only.
300363
// number of objects passed as gas payment should be strictly less than `MAX_GAS_OBJECTS`. When the transaction
301364
// requires a larger number of inputs we use the merge command to merge the rest of the objects into the gasCoin
302365
if (this._gasData.payment.length >= MAX_GAS_OBJECTS) {

modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -346,25 +346,51 @@ describe('Sui Transfer Builder', () => {
346346
rebuiltTx.toBroadcastFormat().should.equal(rawTx);
347347
});
348348

349-
it('should build a self-pay transfer with coin objects + address balance', async function () {
349+
it('should build a self-pay transfer with coin objects + address balance (Path 2c)', async function () {
350+
// Regression test for COINS-331: when both gasData.payment (coin objects) and
351+
// fundsInAddressBalance are set, Sui does NOT automatically merge address balance into
352+
// the gas coin. Path 2c must explicitly call redeem_funds + MergeCoins(gas, [addrCoin])
353+
// before SplitCoins so that the gas coin holds the full spendable amount.
350354
const txBuilder = factory.getTransferBuilder();
351355
txBuilder.type(SuiTransactionType.Transfer);
352356
txBuilder.sender(testData.sender.address);
353357
txBuilder.send(testData.recipients);
354-
txBuilder.gasData(testData.gasData);
358+
txBuilder.gasData(testData.gasData); // non-empty payment — triggers Path 2c
355359
txBuilder.fundsInAddressBalance(FUNDS_IN_ADDRESS_BALANCE);
356360

357361
const tx = await txBuilder.build();
358362
should.equal(tx.type, TransactionType.Send);
359363

360364
const suiTx = tx as SuiTransaction<TransferProgrammableTransaction>;
361365

362-
// Self-pay path: SplitCoins(GasCoin) — protocol merges coin objects + address balance automatically
366+
// Path 2c PTB command sequence (for 2 recipients):
367+
// 0: MoveCall(redeem_funds) — materialize addrCoin from address balance
368+
// 1: MergeCoins(gas, [addrCoin])— gas coin = coinObjects + addrBal (full spendable amount)
369+
// 2: SplitCoins(gas) — split for recipient 0
370+
// 3: TransferObjects — send to recipient 0
371+
// 4: SplitCoins(gas) — split for recipient 1
372+
// 5: TransferObjects — send to recipient 1
363373
const programmableTx = suiTx.suiTransaction.tx;
364-
(programmableTx.transactions[0] as any).kind.should.equal('SplitCoins');
374+
const cmds = programmableTx.transactions as any[];
375+
cmds[0].kind.should.equal('MoveCall', 'command 0 must be MoveCall(redeem_funds)');
376+
cmds[0].target.should.equal('0x2::coin::redeem_funds');
377+
cmds[1].kind.should.equal('MergeCoins', 'command 1 must be MergeCoins(gas, [addrCoin])');
378+
cmds[2].kind.should.equal('SplitCoins', 'command 2 must be SplitCoins(gas, [amount0])');
379+
cmds[3].kind.should.equal('TransferObjects');
380+
cmds[4].kind.should.equal('SplitCoins', 'command 4 must be SplitCoins(gas, [amount1])');
381+
cmds[5].kind.should.equal('TransferObjects');
382+
383+
// fundsInAddressBalance must be persisted in the serialized transaction
384+
suiTx.suiTransaction.fundsInAddressBalance!.should.equal(FUNDS_IN_ADDRESS_BALANCE);
365385

366386
const rawTx = tx.toBroadcastFormat();
367387
should.equal(utils.isValidRawTransaction(rawTx), true);
388+
389+
// Round-trip: rebuilt transaction must be bit-for-bit identical
390+
const rebuilder = factory.from(rawTx);
391+
rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex));
392+
const rebuiltTx = await rebuilder.build();
393+
rebuiltTx.toBroadcastFormat().should.equal(rawTx);
368394
});
369395

370396
it('should build a sponsored transfer with coin objects + address balance', async function () {

0 commit comments

Comments
 (0)