Skip to content

Commit e3b0da9

Browse files
fix(sdk-coin-sui): balance withdrawal bcs encoding for Sui funds withdrawal
TICKET: CSHLD-635
1 parent 9584aa0 commit e3b0da9

5 files changed

Lines changed: 286 additions & 32 deletions

File tree

modules/sdk-coin-sui/src/lib/mystenlab/builder/Inputs.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,9 @@ export const PureCallArg = object({ Pure: array(integer()) });
3232
export const ObjectCallArg = object({ Object: ObjectArg });
3333
export const BalanceWithdrawalCallArg = object({
3434
BalanceWithdrawal: object({
35-
amount: bigintOrInteger,
36-
// TypeTag is a recursive union; object() ensures the value is a non-null object
37-
// matching all TypeTag variants ({ bool: null }, { struct: StructTag }, etc.)
38-
type_: object(),
35+
reservation: object({ MaxAmountU64: bigintOrInteger }),
36+
typeArg: object({ Balance: object() }),
37+
withdrawFrom: object(),
3938
}),
4039
});
4140
export type PureCallArg = Infer<typeof PureCallArg>;
@@ -66,7 +65,13 @@ export const Inputs = {
6665
* @param type_ - the TypeTag of the coin (defaults to SUI)
6766
*/
6867
BalanceWithdrawal(amount: bigint | number, type_: TypeTag): BalanceWithdrawalCallArg {
69-
return { BalanceWithdrawal: { amount, type_ } };
68+
return {
69+
BalanceWithdrawal: {
70+
reservation: { MaxAmountU64: amount.toString() },
71+
typeArg: { Balance: type_ },
72+
withdrawFrom: { Sender: null },
73+
},
74+
};
7075
},
7176
};
7277

modules/sdk-coin-sui/src/lib/mystenlab/types/sui-bcs.ts

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -43,30 +43,24 @@ export function isPureArg(arg: any): arg is PureArg {
4343
return (arg as PureArg).Pure !== undefined;
4444
}
4545

46+
/** Inner types for the FundsWithdrawal (BalanceWithdrawal) CallArg variant. */
47+
export type BalanceWithdrawalReservation = { MaxAmountU64: string };
48+
export type BalanceWithdrawalTypeArg = { Balance: TypeTag };
49+
export type BalanceWithdrawalFrom = { Sender: null } | { Sponsor: null };
50+
4651
/**
47-
* An argument for the transaction. It is a 'meant' enum which expects to have
48-
* one of the optional properties. If not, the BCS error will be thrown while
49-
* attempting to form a transaction.
50-
*
51-
* Example:
52-
* ```js
53-
* let arg1: CallArg = { Object: { Shared: {
54-
* objectId: '5460cf92b5e3e7067aaace60d88324095fd22944',
55-
* initialSharedVersion: 1,
56-
* mutable: true,
57-
* } } };
58-
* let arg2: CallArg = { Pure: bcs.ser(BCS.STRING, 100000).toBytes() };
59-
* let arg3: CallArg = { Object: { ImmOrOwned: {
60-
* objectId: '4047d2e25211d87922b6650233bd0503a6734279',
61-
* version: 1,
62-
* digest: 'bCiANCht4O9MEUhuYjdRCqRPZjr2rJ8MfqNiwyhmRgA='
63-
* } } };
64-
* ```
52+
* An argument for a programmable transaction. Exactly one property must be set.
6553
*
66-
* For `Pure` arguments BCS is required. You must encode the values with BCS according
67-
* to the type required by the called function. Pure accepts only serialized values
54+
* - `Pure` — BCS-serialized bytes for a primitive value
55+
* - `Object` — a reference to an owned or shared object
56+
* - `BalanceWithdrawal` — a FundsWithdrawal (SIP-58) that draws from the sender's
57+
* address balance at execution time; use with `0x2::coin::redeem_funds` to obtain
58+
* a `Coin<T>` object
6859
*/
69-
export type CallArg = PureArg | { Object: ObjectArg } | { BalanceWithdrawal: { amount: bigint | number; type_: TypeTag } };
60+
export type CallArg =
61+
| PureArg
62+
| { Object: ObjectArg }
63+
| { BalanceWithdrawal: { reservation: BalanceWithdrawalReservation; typeArg: BalanceWithdrawalTypeArg; withdrawFrom: BalanceWithdrawalFrom } };
7064

7165
/**
7266
* Kind of a TypeTag which is represented by a Move type identifier.
@@ -161,7 +155,17 @@ const BCS_SPEC: TypeSchema = {
161155
CallArg: {
162156
Pure: [VECTOR, BCS.U8],
163157
Object: 'ObjectArg',
164-
BalanceWithdrawal: 'BalanceWithdrawal',
158+
BalanceWithdrawal: 'FundsWithdrawal',
159+
},
160+
Reservation: {
161+
MaxAmountU64: BCS.U64,
162+
},
163+
WithdrawalType: {
164+
Balance: 'TypeTag',
165+
},
166+
WithdrawFrom: {
167+
Sender: null,
168+
Sponsor: null,
165169
},
166170
TypeTag: {
167171
bool: null,
@@ -194,9 +198,10 @@ const BCS_SPEC: TypeSchema = {
194198
},
195199
},
196200
structs: {
197-
BalanceWithdrawal: {
198-
amount: BCS.U64,
199-
type_: 'TypeTag',
201+
FundsWithdrawal: {
202+
reservation: 'Reservation',
203+
typeArg: 'WithdrawalType',
204+
withdrawFrom: 'WithdrawFrom',
200205
},
201206
ValidDuringExpiration: {
202207
minEpoch: 'Option<u64>',

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
112112
);
113113
if (withdrawalInput) {
114114
const bw = withdrawalInput.BalanceWithdrawal ?? withdrawalInput.value?.BalanceWithdrawal;
115-
this._fundsInAddressBalance = new BigNumber(String(bw.amount));
115+
this._fundsInAddressBalance = new BigNumber(String(bw.reservation?.MaxAmountU64 ?? bw.amount));
116116
}
117117

118118
if (txData.inputObjects && txData.inputObjects.length > 0) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export class TransferBuilder extends TransactionBuilder<TransferProgrammableTran
138138
);
139139
if (withdrawalInput) {
140140
const bw = withdrawalInput.BalanceWithdrawal ?? withdrawalInput.value?.BalanceWithdrawal;
141-
this._fundsInAddressBalance = new BigNumber(String(bw.amount));
141+
this._fundsInAddressBalance = new BigNumber(String(bw.reservation?.MaxAmountU64 ?? bw.amount));
142142
}
143143

144144
const recipients = utils.getRecipients(tx.suiTransaction);

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

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)