@@ -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 ) {
0 commit comments