@@ -562,4 +562,248 @@ describe('Sui Transfer Builder', () => {
562562 Number ( expiration . nonce ) . should . equal ( 0xdeadbeef ) ;
563563 } ) ;
564564 } ) ;
565+
566+ describe ( 'BalanceWithdrawal BCS encoding (FundsWithdrawal format)' , ( ) => {
567+ const AMOUNT = '100000000' ; // 0.1 SUI in MIST
568+ const sponsoredGasData = {
569+ ...testData . gasData ,
570+ owner : testData . feePayer . address ,
571+ } ;
572+
573+ async function buildSponsoredTxWithAddressBalance ( ) {
574+ const txBuilder = factory . getTransferBuilder ( ) ;
575+ txBuilder . type ( SuiTransactionType . Transfer ) ;
576+ txBuilder . sender ( testData . sender . address ) ;
577+ txBuilder . send ( [ { address : testData . recipients [ 0 ] . address , amount : AMOUNT } ] ) ;
578+ txBuilder . gasData ( sponsoredGasData ) ;
579+ txBuilder . fundsInAddressBalance ( AMOUNT ) ;
580+ return txBuilder . build ( ) ;
581+ }
582+
583+ it ( 'should encode BalanceWithdrawal input with FundsWithdrawal structure (reservation/typeArg/withdrawFrom)' , async function ( ) {
584+ const tx = await buildSponsoredTxWithAddressBalance ( ) ;
585+ const suiTx = tx as SuiTransaction < TransferProgrammableTransaction > ;
586+ const inputs = suiTx . suiTransaction . tx . inputs as any [ ] ;
587+
588+ const bwInput = inputs . find (
589+ ( inp ) => inp ?. BalanceWithdrawal !== undefined || inp ?. value ?. BalanceWithdrawal !== undefined
590+ ) ;
591+ should . exist ( bwInput , 'BalanceWithdrawal input must be present' ) ;
592+
593+ const bw = bwInput . BalanceWithdrawal ?? bwInput . value ?. BalanceWithdrawal ;
594+
595+ should . exist ( bw . reservation , 'reservation field must exist' ) ;
596+ should . exist ( bw . reservation . MaxAmountU64 , 'reservation.MaxAmountU64 must exist' ) ;
597+ bw . reservation . MaxAmountU64 . toString ( ) . should . equal ( AMOUNT ) ;
598+
599+ should . exist ( bw . typeArg , 'typeArg field must exist' ) ;
600+ should . exist ( bw . typeArg . Balance , 'typeArg.Balance must exist' ) ;
601+
602+ should . exist ( bw . withdrawFrom , 'withdrawFrom field must exist' ) ;
603+ should . exist (
604+ bw . withdrawFrom . Sender !== undefined || bw . withdrawFrom . Sponsor !== undefined ,
605+ 'withdrawFrom must be Sender or Sponsor'
606+ ) ;
607+
608+ // Old format fields must NOT exist directly on bw
609+ should . not . exist ( bw . amount , 'old "amount" field must not exist on BalanceWithdrawal' ) ;
610+ should . not . exist ( bw . type_ , 'old "type_" field must not exist on BalanceWithdrawal' ) ;
611+ } ) ;
612+
613+ it ( 'should produce BCS bytes that decode back to FundsWithdrawal with correct amount' , async function ( ) {
614+ const tx = await buildSponsoredTxWithAddressBalance ( ) ;
615+ const rawTx = tx . toBroadcastFormat ( ) ;
616+ should . equal ( utils . isValidRawTransaction ( rawTx ) , true ) ;
617+
618+ const deserialized = SuiTransaction . deserializeSuiTransaction ( rawTx ) ;
619+ const inputs = deserialized . tx . inputs as any [ ] ;
620+
621+ const bwInput = inputs . find (
622+ ( inp ) => inp ?. BalanceWithdrawal !== undefined || inp ?. value ?. BalanceWithdrawal !== undefined
623+ ) ;
624+ should . exist ( bwInput , 'BalanceWithdrawal must be present in deserialized inputs' ) ;
625+
626+ const bw = bwInput . BalanceWithdrawal ?? bwInput . value ?. BalanceWithdrawal ;
627+
628+ // FundsWithdrawal structure (not old {amount, type_})
629+ should . exist ( bw . reservation , 'reservation must survive BCS round-trip' ) ;
630+ should . exist ( bw . reservation . MaxAmountU64 , 'MaxAmountU64 must survive BCS round-trip' ) ;
631+ bw . reservation . MaxAmountU64 . toString ( ) . should . equal ( AMOUNT ) ;
632+
633+ should . exist ( bw . typeArg , 'typeArg must survive BCS round-trip' ) ;
634+ should . exist ( bw . typeArg . Balance , 'typeArg.Balance must survive BCS round-trip' ) ;
635+
636+ should . exist ( bw . withdrawFrom , 'withdrawFrom must survive BCS round-trip' ) ;
637+ } ) ;
638+
639+ it ( 'should serialize toJson() without a BigInt replacer (no TypeError)' , async function ( ) {
640+ const tx = await buildSponsoredTxWithAddressBalance ( ) ;
641+ should . doesNotThrow (
642+ ( ) => JSON . stringify ( tx . toJson ( ) ) ,
643+ 'JSON.stringify(tx.toJson()) must not throw — BalanceWithdrawal amount must not be stored as BigInt'
644+ ) ;
645+ } ) ;
646+
647+ it ( 'should preserve fundsInAddressBalance amount through full round-trip' , async function ( ) {
648+ const tx = await buildSponsoredTxWithAddressBalance ( ) ;
649+ const rawTx = tx . toBroadcastFormat ( ) ;
650+
651+ const rebuilder = factory . from ( rawTx ) ;
652+ rebuilder . addSignature ( { pub : testData . sender . publicKey } , Buffer . from ( testData . sender . signatureHex ) ) ;
653+ const rebuiltTx = await rebuilder . build ( ) ;
654+
655+ rebuiltTx . toBroadcastFormat ( ) . should . equal ( rawTx ) ;
656+
657+ const suiTx = rebuiltTx as SuiTransaction < TransferProgrammableTransaction > ;
658+ const inputs = suiTx . suiTransaction . tx . inputs as any [ ] ;
659+ const bwInput = inputs . find (
660+ ( inp ) => inp ?. BalanceWithdrawal !== undefined || inp ?. value ?. BalanceWithdrawal !== undefined
661+ ) ;
662+ should . exist ( bwInput ) ;
663+ const bw = bwInput . BalanceWithdrawal ?? bwInput . value ?. BalanceWithdrawal ;
664+ bw . reservation . MaxAmountU64 . toString ( ) . should . equal ( AMOUNT ) ;
665+ } ) ;
666+
667+ it ( 'should encode gas-from-address-balance (self-pay, empty payment) with no FundsWithdrawal input' , async function ( ) {
668+ // When gasData.payment=[] and sender===gasData.owner, the Sui runtime automatically
669+ // uses address balance to fund both gas and the transfer via GasCoin.
670+ // No BalanceWithdrawal CallArg is needed — the runtime handles it implicitly.
671+ const selfPayNoPayment = { ...testData . gasDataWithoutGasPayment , payment : [ ] } ;
672+
673+ const txBuilder = factory . getTransferBuilder ( ) ;
674+ txBuilder . type ( SuiTransactionType . Transfer ) ;
675+ txBuilder . sender ( testData . sender . address ) ;
676+ txBuilder . send ( [ { address : testData . recipients [ 0 ] . address , amount : AMOUNT } ] ) ;
677+ txBuilder . gasData ( selfPayNoPayment ) ;
678+ txBuilder . fundsInAddressBalance ( AMOUNT ) ;
679+
680+ const tx = await txBuilder . build ( ) ;
681+ should . equal ( tx . type , TransactionType . Send ) ;
682+
683+ const suiTx = tx as SuiTransaction < TransferProgrammableTransaction > ;
684+
685+ // Gas owner must equal sender (self-pay)
686+ suiTx . suiTransaction . gasData . owner . should . equal ( testData . sender . address ) ;
687+ // Payment must be empty — gas funded from address balance by the runtime
688+ suiTx . suiTransaction . gasData . payment . length . should . equal ( 0 ) ;
689+
690+ // No BalanceWithdrawal input — runtime handles address balance automatically
691+ const inputs = suiTx . suiTransaction . tx . inputs as any [ ] ;
692+ const bwInput = inputs . find (
693+ ( inp ) => inp ?. BalanceWithdrawal !== undefined || inp ?. value ?. BalanceWithdrawal !== undefined
694+ ) ;
695+ should . not . exist ( bwInput , 'self-pay must NOT have a BalanceWithdrawal input — runtime handles it' ) ;
696+
697+ // First command must be SplitCoins(GasCoin) — no redeem_funds needed
698+ const commands = suiTx . suiTransaction . tx . transactions as any [ ] ;
699+ commands [ 0 ] . kind . should . equal ( 'SplitCoins' ) ;
700+
701+ const rawTx = tx . toBroadcastFormat ( ) ;
702+ should . equal ( utils . isValidRawTransaction ( rawTx ) , true ) ;
703+
704+ const deserialized = SuiTransaction . deserializeSuiTransaction ( rawTx ) ;
705+ deserialized . sender . should . equal ( testData . sender . address ) ;
706+ deserialized . gasData . owner . should . equal ( testData . sender . address ) ;
707+ deserialized . gasData . payment . length . should . equal ( 0 ) ;
708+
709+ // No FundsWithdrawal in decoded inputs — only Pure args
710+ const decodedInputs = deserialized . tx . inputs as any [ ] ;
711+ decodedInputs
712+ . every ( ( inp : any ) => inp ?. BalanceWithdrawal === undefined && inp ?. value ?. BalanceWithdrawal === undefined )
713+ . should . be . true ( 'decoded inputs must contain no FundsWithdrawal for self-pay path' ) ;
714+
715+ const rebuilder = factory . from ( rawTx ) ;
716+ rebuilder . addSignature ( { pub : testData . sender . publicKey } , Buffer . from ( testData . sender . signatureHex ) ) ;
717+ const rebuiltTx = await rebuilder . build ( ) ;
718+ rebuiltTx . toBroadcastFormat ( ) . should . equal ( rawTx ) ;
719+ } ) ;
720+
721+ it ( 'should encode gas-from-address-balance with ValidDuring expiration (self-pay, empty payment)' , async function ( ) {
722+ // When gasData.payment=[] the Sui node requires a ValidDuring expiration to prevent
723+ // replay attacks. Gas and transfer are both funded from address balance via GasCoin.
724+ const GENESIS_CHAIN_ID = 'GAFpCCcRCxTdFfUEMbQbkLBaZy2RNiGAfvFBhMNpq2kT' ;
725+ const selfPayNoPayment = { ...testData . gasDataWithoutGasPayment , payment : [ ] } ;
726+
727+ const txBuilder = factory . getTransferBuilder ( ) ;
728+ txBuilder . type ( SuiTransactionType . Transfer ) ;
729+ txBuilder . sender ( testData . sender . address ) ;
730+ txBuilder . send ( [ { address : testData . recipients [ 0 ] . address , amount : AMOUNT } ] ) ;
731+ txBuilder . gasData ( selfPayNoPayment ) ;
732+ txBuilder . fundsInAddressBalance ( AMOUNT ) ;
733+ txBuilder . expiration ( {
734+ ValidDuring : {
735+ minEpoch : { Some : 500 } ,
736+ maxEpoch : { Some : 501 } ,
737+ minTimestamp : { None : null } ,
738+ maxTimestamp : { None : null } ,
739+ chain : GENESIS_CHAIN_ID ,
740+ nonce : 0xdeadbeef ,
741+ } ,
742+ } ) ;
743+
744+ const tx = await txBuilder . build ( ) ;
745+ should . equal ( tx . type , TransactionType . Send ) ;
746+
747+ const suiTx = tx as SuiTransaction < TransferProgrammableTransaction > ;
748+
749+ // Self-pay: owner === sender, payment empty
750+ suiTx . suiTransaction . gasData . owner . should . equal ( testData . sender . address ) ;
751+ suiTx . suiTransaction . gasData . payment . length . should . equal ( 0 ) ;
752+
753+ // No BalanceWithdrawal input — runtime handles GasCoin from address balance
754+ const inputs = suiTx . suiTransaction . tx . inputs as any [ ] ;
755+ const bwInput = inputs . find (
756+ ( inp ) => inp ?. BalanceWithdrawal !== undefined || inp ?. value ?. BalanceWithdrawal !== undefined
757+ ) ;
758+ should . not . exist ( bwInput , 'self-pay must NOT have a BalanceWithdrawal input' ) ;
759+
760+ // First command: SplitCoins(GasCoin) — no redeem_funds for self-pay
761+ const commands = suiTx . suiTransaction . tx . transactions as any [ ] ;
762+ commands [ 0 ] . kind . should . equal ( 'SplitCoins' ) ;
763+
764+ const rawTx = tx . toBroadcastFormat ( ) ;
765+ should . equal ( utils . isValidRawTransaction ( rawTx ) , true ) ;
766+
767+ const deserialized = SuiTransaction . deserializeSuiTransaction ( rawTx ) ;
768+ deserialized . sender . should . equal ( testData . sender . address ) ;
769+ deserialized . gasData . payment . length . should . equal ( 0 ) ;
770+
771+ const expiration = ( deserialized . expiration as any ) ?. ValidDuring ;
772+ should . exist ( expiration , 'ValidDuring expiration must survive BCS round-trip' ) ;
773+ Number ( expiration . minEpoch ?. Some ?? expiration . minEpoch ) . should . equal ( 500 ) ;
774+ Number ( expiration . maxEpoch ?. Some ?? expiration . maxEpoch ) . should . equal ( 501 ) ;
775+ expiration . chain . should . equal ( GENESIS_CHAIN_ID ) ;
776+ Number ( expiration . nonce ) . should . equal ( 0xdeadbeef ) ;
777+
778+ // Inputs must contain no FundsWithdrawal — only Pure args
779+ const decodedInputs = deserialized . tx . inputs as any [ ] ;
780+ decodedInputs
781+ . every ( ( inp : any ) => inp ?. BalanceWithdrawal === undefined && inp ?. value ?. BalanceWithdrawal === undefined )
782+ . should . be . true ( 'decoded inputs must contain no FundsWithdrawal for self-pay path' ) ;
783+
784+ const rebuilder = factory . from ( rawTx ) ;
785+ rebuilder . addSignature ( { pub : testData . sender . publicKey } , Buffer . from ( testData . sender . signatureHex ) ) ;
786+ const rebuiltTx = await rebuilder . build ( ) ;
787+ rebuiltTx . toBroadcastFormat ( ) . should . equal ( rawTx ) ;
788+ } ) ;
789+
790+ it ( 'should build and correctly decode a MoveCall to 0x2::coin::redeem_funds with BalanceWithdrawal arg' , async function ( ) {
791+ const tx = await buildSponsoredTxWithAddressBalance ( ) ;
792+ const suiTx = tx as SuiTransaction < TransferProgrammableTransaction > ;
793+ const commands = suiTx . suiTransaction . tx . transactions as any [ ] ;
794+
795+ commands [ 0 ] . kind . should . equal ( 'MoveCall' ) ;
796+ commands [ 0 ] . target . should . equal ( '0x2::coin::redeem_funds' ) ;
797+ commands [ 0 ] . typeArguments [ 0 ] . should . equal ( '0x2::sui::SUI' ) ;
798+
799+ // The argument to redeem_funds must reference the BalanceWithdrawal input (index 0)
800+ const arg = commands [ 0 ] . arguments [ 0 ] ;
801+ arg . kind . should . equal ( 'Input' ) ;
802+ arg . index . should . equal ( 0 ) ;
803+ arg . type . should . equal ( 'object' ) ;
804+
805+ const rawTx = tx . toBroadcastFormat ( ) ;
806+ should . equal ( utils . isValidRawTransaction ( rawTx ) , true ) ;
807+ } ) ;
808+ } ) ;
565809} ) ;
0 commit comments