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