From 4247d7e5c97ce064d08a9170aa43be3cf5f35ae7 Mon Sep 17 00:00:00 2001 From: Abhishek Agrawal Date: Tue, 14 Apr 2026 03:44:39 +0530 Subject: [PATCH] fix(sdk-coin-sui): handle epoch round trip TICKET: CSHLD-601 --- .../mystenlab/builder/TransactionDataBlock.ts | 22 +++++----- .../src/lib/mystenlab/types/sui-bcs.ts | 3 +- .../transactionBuilder/transferBuilder.ts | 41 +++++++++++++++++++ 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/modules/sdk-coin-sui/src/lib/mystenlab/builder/TransactionDataBlock.ts b/modules/sdk-coin-sui/src/lib/mystenlab/builder/TransactionDataBlock.ts index 1bb90d77ba..4cf7fd3099 100644 --- a/modules/sdk-coin-sui/src/lib/mystenlab/builder/TransactionDataBlock.ts +++ b/modules/sdk-coin-sui/src/lib/mystenlab/builder/TransactionDataBlock.ts @@ -20,17 +20,6 @@ import { TransactionType, TransactionBlockInput } from './Transactions'; import { BuilderCallArg, PureCallArg } from './Inputs'; import { create } from './utils'; -export const TransactionExpiration = optional( - nullable( - union([ - object({ Epoch: integer() }), - object({ None: union([literal(true), literal(null)]) }), - object({ ValidDuring: object({ minEpoch: integer(), maxEpoch: integer(), chain: string(), nonce: integer() }) }), - ]) - ) -); -export type TransactionExpiration = Infer; - const SuiAddress = string(); const StringEncodedBigint = define('StringEncodedBigint', (val) => { @@ -44,6 +33,17 @@ const StringEncodedBigint = define('StringEncodedBigint', (val) => { } }); +export const TransactionExpiration = optional( + nullable( + union([ + object({ Epoch: StringEncodedBigint }), + object({ None: union([literal(true), literal(null)]) }), + object({ ValidDuring: object({ minEpoch: integer(), maxEpoch: integer(), chain: string(), nonce: integer() }) }), + ]) + ) +); +export type TransactionExpiration = Infer; + const GasConfig = object({ budget: optional(StringEncodedBigint), price: optional(StringEncodedBigint), diff --git a/modules/sdk-coin-sui/src/lib/mystenlab/types/sui-bcs.ts b/modules/sdk-coin-sui/src/lib/mystenlab/types/sui-bcs.ts index 9a58c65642..891c512a3a 100644 --- a/modules/sdk-coin-sui/src/lib/mystenlab/types/sui-bcs.ts +++ b/modules/sdk-coin-sui/src/lib/mystenlab/types/sui-bcs.ts @@ -123,7 +123,7 @@ export type ValidDuringExpiration = { * * Indications the expiration time for a transaction. */ -export type TransactionExpiration = { None: null } | { Epoch: number } | { ValidDuring: ValidDuringExpiration }; +export type TransactionExpiration = { None: null } | { Epoch: number | bigint | string } | { ValidDuring: ValidDuringExpiration }; // Move name of the Vector type. const VECTOR = 'vector'; @@ -155,7 +155,6 @@ const BCS_SPEC: TypeSchema = { CallArg: { Pure: [VECTOR, BCS.U8], Object: 'ObjectArg', - ObjVec: [VECTOR, 'ObjectArg'], BalanceWithdrawal: 'BalanceWithdrawal', }, TypeTag: { diff --git a/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts b/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts index 8fc943cc7c..583ae32ad7 100644 --- a/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts +++ b/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts @@ -470,5 +470,46 @@ describe('Sui Transfer Builder', () => { const rawTx = tx.toBroadcastFormat(); should.equal(utils.isValidRawTransaction(rawTx), true); }); + + it('should round-trip a self-pay transfer with Epoch expiration via fromBytes', async function () { + // Regression test for the BigInt round-trip bug: + // BCS.U64 deserializes u64 as BigInt, but the previous superstruct schema used integer() + // which rejected BigInt, causing fromBytes() to throw a StructError at "expiration". + // StringEncodedBigint now accepts string | number | bigint, fixing the round-trip. + const gasDataNoPayment = { + ...testData.gasDataWithoutGasPayment, + payment: [], + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send(testData.recipients); + txBuilder.gasData(gasDataNoPayment); + txBuilder.fundsInAddressBalance(FUNDS_IN_ADDRESS_BALANCE); + txBuilder.expiration({ Epoch: 324 }); // number input + + const tx = await txBuilder.build(); + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + + // fromBytes must not throw StructError — this was the failing case before the fix + should.doesNotThrow(() => { + const rebuilder = factory.from(rawTx); + should.exist(rebuilder); + }); + + // Full round-trip: rebuilt tx must serialize identically + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + + // Epoch value must survive the round-trip regardless of BigInt/number representation + const rebuiltJson = rebuiltTx.toJson(); + const epochVal = (rebuiltJson.expiration as any)?.Epoch; + should.exist(epochVal); + Number(epochVal).should.equal(324); + }); }); });