Skip to content

Commit c3ce599

Browse files
Merge pull request #9012 from BitGo/CSHLD-983
fix(sdk-coin-sui): handle gas coin empty with only address balance
2 parents 5e0016d + 4698398 commit c3ce599

3 files changed

Lines changed: 235 additions & 34 deletions

File tree

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

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,17 @@ 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 2a/2b — Self-pay, coin objects only OR address-balance only (sender === gasData.owner):
196+
* Path 2a — Self-pay, coin objects only (sender === gasData.owner, payment non-empty, fundsInAddressBalance==0):
197197
* SplitCoins(GasCoin, [amount]) → TransferObjects
198-
* Handles Case 1 (coins only) and Case 2 (addr-bal only, caller sets ValidDuring).
198+
* Handles Case 1 (coins only).
199+
*
200+
* Path 2b — Self-pay, address-balance only (sender === gasData.owner, payment==[], fundsInAddressBalance>0):
201+
* withdrawal(totalRecipientAmount) → redeem_funds → Coin<SUI>
202+
* SplitCoins(addrCoin, [amount]) → TransferObjects
203+
* MergeCoins(GasCoin, [addrCoin]) ← destroys 0-balance addrCoin; gas paid from accumulator remainder
204+
* Handles Case 2 (addr-bal only, caller sets ValidDuring).
205+
* NOTE: SplitCoins(GasCoin, [amount]) does NOT work here — with payment=[], GasCoin only
206+
* carries up to gas-budget-worth of balance for PTB operations, not the full address balance.
199207
*
200208
* Path 2c — Self-pay, mixed (sender === gasData.owner, gasData.payment non-empty AND fundsInAddressBalance > 0):
201209
* Sui does NOT automatically merge address balance into gas coin when payment is non-empty.
@@ -359,7 +367,53 @@ export class TransferBuilder extends TransactionBuilder<TransferProgrammableTran
359367
};
360368
}
361369

362-
// Path 2a / 2b: self-pay, coin objects only OR address-balance only.
370+
// Path 2b: self-pay, address-balance only (payment=[], fundsInAddressBalance>0).
371+
// With payment=[], GasCoin is backed by the address accumulator only for gas reservation;
372+
// it does NOT expose the full address balance for SplitCoins. Attempting
373+
// SplitCoins(GasCoin, [large_amount]) with payment=[] always fails with
374+
// InsufficientCoinBalance in command 0 because GasCoin only carries up to
375+
// gas-budget-worth of balance for PTB operations.
376+
// Fix: use withdrawal+redeem_funds to materialise the transfer amount as a coin
377+
// object, split from that coin, then merge the 0-balance remainder into GasCoin
378+
// (destroying it). The accumulator retains the gas budget for gas settlement.
379+
if (this._fundsInAddressBalance.gt(0) && this._gasData.payment.length === 0) {
380+
const totalRecipientAmount = this._recipients.reduce(
381+
(acc, r) => acc.plus(new BigNumber(r.amount)),
382+
new BigNumber(0)
383+
);
384+
// Withdraw exactly the total transfer amount. The difference
385+
// (fundsInAddressBalance - totalRecipientAmount ≈ gasBudget) remains in the
386+
// accumulator and is consumed by the empty-payment gas mechanism.
387+
const [addrCoin] = programmableTxBuilder.moveCall({
388+
target: '0x2::coin::redeem_funds',
389+
typeArguments: ['0x2::sui::SUI'],
390+
arguments: [programmableTxBuilder.withdrawal({ amount: BigInt(totalRecipientAmount.toFixed()) })],
391+
});
392+
this._recipients.forEach((recipient) => {
393+
const splitObject = programmableTxBuilder.splitCoins(addrCoin, [
394+
programmableTxBuilder.pure(BigInt(recipient.amount)),
395+
]);
396+
programmableTxBuilder.transferObjects([splitObject], programmableTxBuilder.object(recipient.address));
397+
});
398+
// addrCoin has 0 balance after all splits; merge into GasCoin to delete it.
399+
programmableTxBuilder.mergeCoins(programmableTxBuilder.gas, [addrCoin]);
400+
const txData2b = programmableTxBuilder.blockData;
401+
return {
402+
type: this._type,
403+
sender: this._sender,
404+
tx: {
405+
inputs: [...txData2b.inputs],
406+
transactions: [...txData2b.transactions],
407+
},
408+
gasData: {
409+
...this._gasData,
410+
},
411+
expiration: this._expiration,
412+
fundsInAddressBalance: this._fundsInAddressBalance.toFixed(),
413+
};
414+
}
415+
416+
// Path 2a: self-pay, coin objects only (payment non-empty, fundsInAddressBalance==0).
363417
// number of objects passed as gas payment should be strictly less than `MAX_GAS_OBJECTS`. When the transaction
364418
// requires a larger number of inputs we use the merge command to merge the rest of the objects into the gasCoin
365419
if (this._gasData.payment.length >= MAX_GAS_OBJECTS) {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -394,8 +394,8 @@ export class Sui extends BaseCoin {
394394
inputCoins = inputCoins.slice(0, MAX_OBJECT_LIMIT);
395395
}
396396
// Include funds held in the address balance system (not in coin objects).
397-
// SplitCoins(GasCoin, [amount]) draws from both gasData.payment objects
398-
// and address balance at execution time, so both are spendable.
397+
// When gasData.payment is non-empty, Path 2c merges the address balance into GasCoin.
398+
// When gasData.payment is empty (address-balance only), Path 2b uses withdrawal+redeem_funds.
399399
const coinObjectsBalance = inputCoins.reduce((acc, obj) => acc.plus(obj.balance), new BigNumber(0));
400400
let netAmount = coinObjectsBalance.plus(fundsInAddressBalance).minus(MAX_GAS_BUDGET);
401401

0 commit comments

Comments
 (0)