Skip to content

Commit 5df03bd

Browse files
Ticket: COINS-312
1 parent 8bc40d5 commit 5df03bd

7 files changed

Lines changed: 115 additions & 14 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
221221

222222
this._recipients.forEach((recipient) => {
223223
const splitObject = programmableTxBuilder.splitCoins(mergedObject, [
224-
programmableTxBuilder.pure(Number(recipient.amount)),
224+
programmableTxBuilder.pure(BigInt(recipient.amount)),
225225
]);
226226
programmableTxBuilder.transferObjects([splitObject], programmableTxBuilder.object(recipient.address));
227227
});

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
TransactionType as SuiTransactionBlockType,
2222
} from './mystenlab/builder';
2323
import { BCS } from '@mysten/bcs';
24+
import BigNumber from 'bignumber.js';
2425

2526
export class TokenTransferTransaction extends Transaction<TokenTransferProgrammableTransaction> {
2627
constructor(_coinConfig: Readonly<CoinConfig>) {
@@ -123,7 +124,9 @@ export class TokenTransferTransaction extends Transaction<TokenTransferProgramma
123124
}
124125

125126
const recipients = utils.getRecipients(this._suiTransaction);
126-
const totalAmount = recipients.reduce((accumulator, current) => accumulator + Number(current.amount), 0);
127+
// Use BigNumber: token consolidation amounts can exceed 2^53 and a Number-based sum
128+
// would lose precision.
129+
const totalAmount = recipients.reduce((accumulator, current) => accumulator.plus(current.amount), new BigNumber(0));
127130
this._outputs = recipients.map((recipient) => ({
128131
address: recipient.address,
129132
value: recipient.amount,
@@ -132,7 +135,7 @@ export class TokenTransferTransaction extends Transaction<TokenTransferProgramma
132135
this._inputs = [
133136
{
134137
address: this.suiTransaction.sender,
135-
value: totalAmount.toString(),
138+
value: totalAmount.toFixed(),
136139
coin: this._coinConfig.name,
137140
},
138141
];
@@ -219,7 +222,9 @@ export class TokenTransferTransaction extends Transaction<TokenTransferProgramma
219222
explainTokenTransferTransaction(json: TxData, explanationResult: TransactionExplanation): TransactionExplanation {
220223
const recipients = utils.getRecipients(this.suiTransaction);
221224
const outputs: TransactionRecipient[] = recipients.map((recipient) => recipient);
222-
const outputAmount = recipients.reduce((accumulator, current) => accumulator + Number(current.amount), 0);
225+
const outputAmount = recipients
226+
.reduce((accumulator, current) => accumulator.plus(current.amount), new BigNumber(0))
227+
.toFixed();
223228

224229
return {
225230
...explanationResult,

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ export class TransferBuilder extends TransactionBuilder<TransferProgrammableTran
230230
}
231231
this._recipients.forEach((recipient) => {
232232
const splitObject = programmableTxBuilder.splitCoins(mergedObject, [
233-
programmableTxBuilder.pure(Number(recipient.amount)),
233+
programmableTxBuilder.pure(BigInt(recipient.amount)),
234234
]);
235235
programmableTxBuilder.transferObjects([splitObject], programmableTxBuilder.object(recipient.address));
236236
});
@@ -269,7 +269,7 @@ export class TransferBuilder extends TransactionBuilder<TransferProgrammableTran
269269

270270
this._recipients.forEach((recipient) => {
271271
const splitObject = programmableTxBuilder.splitCoins(addrCoin, [
272-
programmableTxBuilder.pure(Number(recipient.amount)),
272+
programmableTxBuilder.pure(BigInt(recipient.amount)),
273273
]);
274274
programmableTxBuilder.transferObjects([splitObject], programmableTxBuilder.object(recipient.address));
275275
});
@@ -319,7 +319,7 @@ export class TransferBuilder extends TransactionBuilder<TransferProgrammableTran
319319
this._recipients.forEach((recipient) => {
320320
const coin = programmableTxBuilder.add(
321321
TransactionsConstructor.SplitCoins(programmableTxBuilder.gas, [
322-
programmableTxBuilder.pure(Number(recipient.amount)),
322+
programmableTxBuilder.pure(BigInt(recipient.amount)),
323323
])
324324
);
325325
programmableTxBuilder.add(

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,9 @@ export class TransferTransaction extends Transaction<TransferProgrammableTransac
142142
}
143143

144144
const recipients = utils.getRecipients(this._suiTransaction);
145-
const totalAmount = recipients.reduce((accumulator, current) => accumulator + Number(current.amount), 0);
145+
// Use BigNumber for the total: consolidation amounts can exceed 2^53, so a Number-based
146+
// sum would lose precision.
147+
const totalAmount = recipients.reduce((accumulator, current) => accumulator.plus(current.amount), new BigNumber(0));
146148
this._outputs = recipients.map((recipient, index) => ({
147149
address: recipient.address,
148150
value: recipient.amount,
@@ -151,7 +153,7 @@ export class TransferTransaction extends Transaction<TransferProgrammableTransac
151153
this._inputs = [
152154
{
153155
address: this.suiTransaction.sender,
154-
value: totalAmount.toString(),
156+
value: totalAmount.toFixed(),
155157
coin: this._coinConfig.name,
156158
},
157159
];

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,10 @@ export class Utils implements BaseUtils {
171171
* @returns {boolean} - the validation result
172172
*/
173173
isValidAmount(amount: string | number): boolean {
174-
const bigNumberAmount = new BigNumber(Number(amount));
174+
// Build the BigNumber directly from the raw value. Wrapping in Number() first would
175+
// truncate integers above 2^53 (e.g. large consolidation amounts in MIST), causing
176+
// valid amounts to be mis-validated.
177+
const bigNumberAmount = new BigNumber(amount);
175178
if (!bigNumberAmount.isInteger() || bigNumberAmount.isLessThanOrEqualTo(0)) {
176179
return false;
177180
}

modules/sdk-coin-sui/test/resources/sui.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export const coinsWithoutGasPayment = [
8585
export const txInputs = [
8686
{
8787
kind: 'Input',
88-
value: 100,
88+
value: '100',
8989
index: 0,
9090
type: 'pure',
9191
},
@@ -97,7 +97,7 @@ export const txInputs = [
9797
},
9898
{
9999
kind: 'Input',
100-
value: 100,
100+
value: '100',
101101
index: 2,
102102
type: 'pure',
103103
},
@@ -117,7 +117,7 @@ export const txTransactions = [
117117
amounts: [
118118
{
119119
kind: 'Input',
120-
value: 100,
120+
value: '100',
121121
index: 0,
122122
type: 'pure',
123123
},
@@ -146,7 +146,7 @@ export const txTransactions = [
146146
amounts: [
147147
{
148148
kind: 'Input',
149-
value: 100,
149+
value: '100',
150150
index: 2,
151151
type: 'pure',
152152
},

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,97 @@ describe('Sui Transfer Builder', () => {
672672
});
673673
});
674674

675+
describe('large amounts exceeding Number.MAX_SAFE_INTEGER', () => {
676+
// Number.MAX_SAFE_INTEGER = 9007199254740991.
677+
// Using Number() for amounts above this silently rounds to the nearest representable float.
678+
// 9007199254740993 (MAX_SAFE_INTEGER + 2) is NOT exactly representable as a JS Number — it
679+
// rounds down to 9007199254740992 — so BCS would encode the wrong value.
680+
// BigInt preserves the exact value and is the correct type for BCS u64 encoding.
681+
//
682+
// The core check in each test is tx.outputs[0].value === LARGE_AMOUNT: this reads from the
683+
// in-memory builder state (a BigInt, not BCS-decoded), so it accurately distinguishes
684+
// BigInt() vs Number() encoding at build time.
685+
const LARGE_AMOUNT = '9007199254740993'; // MAX_SAFE_INTEGER + 2
686+
687+
it('should build a self-pay transfer (Path 2) with amount > Number.MAX_SAFE_INTEGER and encode it precisely', async function () {
688+
const txBuilder = factory.getTransferBuilder();
689+
txBuilder.type(SuiTransactionType.Transfer);
690+
txBuilder.sender(testData.sender.address);
691+
txBuilder.send([{ address: testData.recipients[0].address, amount: LARGE_AMOUNT }]);
692+
txBuilder.gasData(testData.gasData);
693+
694+
const tx = await txBuilder.build();
695+
should.equal(tx.type, TransactionType.Send);
696+
697+
// With Number() the amount would be silently rounded to 9007199254740992 — BigInt preserves the exact value
698+
tx.outputs.length.should.equal(1);
699+
tx.outputs[0].value.should.equal(LARGE_AMOUNT);
700+
701+
const rawTx = tx.toBroadcastFormat();
702+
should.equal(utils.isValidRawTransaction(rawTx), true);
703+
});
704+
705+
it('should build a sponsored transfer with coin objects (Path 1a) with amount > Number.MAX_SAFE_INTEGER and encode it precisely', async function () {
706+
const inputObjects = testData.generateObjects(2);
707+
const sponsoredGasData = {
708+
...testData.gasData,
709+
owner: testData.feePayer.address,
710+
};
711+
712+
const txBuilder = factory.getTransferBuilder();
713+
txBuilder.type(SuiTransactionType.Transfer);
714+
txBuilder.sender(testData.sender.address);
715+
txBuilder.send([{ address: testData.recipients[0].address, amount: LARGE_AMOUNT }]);
716+
txBuilder.gasData(sponsoredGasData);
717+
txBuilder.inputObjects(inputObjects);
718+
719+
const tx = await txBuilder.build();
720+
should.equal(tx.type, TransactionType.Send);
721+
722+
// With Number() the amount would be silently rounded to 9007199254740992 — BigInt preserves the exact value
723+
tx.outputs.length.should.equal(1);
724+
tx.outputs[0].value.should.equal(LARGE_AMOUNT);
725+
726+
const rawTx = tx.toBroadcastFormat();
727+
should.equal(utils.isValidRawTransaction(rawTx), true);
728+
});
729+
730+
it('should build a sponsored addr-balance-only transfer (Path 1b) with amount > Number.MAX_SAFE_INTEGER and round-trip correctly', async function () {
731+
const sponsoredGasData = {
732+
...testData.gasData,
733+
owner: testData.feePayer.address,
734+
};
735+
736+
const txBuilder = factory.getTransferBuilder();
737+
txBuilder.type(SuiTransactionType.Transfer);
738+
txBuilder.sender(testData.sender.address);
739+
txBuilder.send([{ address: testData.recipients[0].address, amount: LARGE_AMOUNT }]);
740+
txBuilder.gasData(sponsoredGasData);
741+
txBuilder.fundsInAddressBalance(LARGE_AMOUNT);
742+
743+
const tx = await txBuilder.build();
744+
should.equal(tx.type, TransactionType.Send);
745+
746+
// With Number() the amount would be silently rounded to 9007199254740992 — BigInt preserves the exact value
747+
tx.outputs.length.should.equal(1);
748+
tx.outputs[0].value.should.equal(LARGE_AMOUNT);
749+
750+
const rawTx = tx.toBroadcastFormat();
751+
should.equal(utils.isValidRawTransaction(rawTx), true);
752+
753+
// Full round-trip: rebuilding from BCS bytes must produce identical bytes
754+
const rebuilder = factory.from(rawTx);
755+
rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex));
756+
const rebuiltTx = await rebuilder.build();
757+
rebuiltTx.toBroadcastFormat().should.equal(rawTx);
758+
759+
const recipients = utils.getRecipients(
760+
(rebuiltTx as SuiTransaction<TransferProgrammableTransaction>).suiTransaction
761+
);
762+
recipients[0].amount.should.equal(LARGE_AMOUNT);
763+
});
764+
});
765+
675766
describe('BalanceWithdrawal BCS encoding (FundsWithdrawal format)', () => {
676767
const AMOUNT = '100000000'; // 0.1 SUI in MIST
677768
const sponsoredGasData = {

0 commit comments

Comments
 (0)