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 4cf7fd3099..ef3595c54c 100644 --- a/modules/sdk-coin-sui/src/lib/mystenlab/builder/TransactionDataBlock.ts +++ b/modules/sdk-coin-sui/src/lib/mystenlab/builder/TransactionDataBlock.ts @@ -38,7 +38,16 @@ export const TransactionExpiration = optional( union([ object({ Epoch: StringEncodedBigint }), object({ None: union([literal(true), literal(null)]) }), - object({ ValidDuring: object({ minEpoch: integer(), maxEpoch: integer(), chain: string(), nonce: integer() }) }), + object({ + ValidDuring: object({ + minEpoch: union([object({ Some: StringEncodedBigint }), object({ None: union([literal(null), literal(true)]) })]), + maxEpoch: union([object({ Some: StringEncodedBigint }), object({ None: union([literal(null), literal(true)]) })]), + minTimestamp: union([object({ Some: StringEncodedBigint }), object({ None: union([literal(null), literal(true)]) })]), + maxTimestamp: union([object({ Some: StringEncodedBigint }), object({ None: union([literal(null), literal(true)]) })]), + chain: string(), + nonce: integer(), + }), + }), ]) ) ); 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 891c512a3a..c415844615 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 @@ -106,14 +106,20 @@ export type GasData = { budget: number; }; +type OptionU64 = { Some: number } | { None: null }; + /** * ValidDuring expiration — used when gasData.payment is empty (address-balance-funded gas). - * Both minEpoch and maxEpoch must be set; maxEpoch must equal minEpoch or minEpoch + 1. - * The nonce (u32) prevents duplicate transaction digests across same-epoch builds. + * Both minEpoch and maxEpoch are Option matching the Sui protocol BCS layout. + * minTimestamp/maxTimestamp are not yet used by the protocol and must be None. + * chain is the Base58-encoded genesis checkpoint digest (32 bytes). + * nonce (u32) prevents duplicate transaction digests across same-epoch builds. */ export type ValidDuringExpiration = { - minEpoch: number; - maxEpoch: number; + minEpoch: OptionU64; + maxEpoch: OptionU64; + minTimestamp: OptionU64; + maxTimestamp: OptionU64; chain: string; nonce: number; }; @@ -193,9 +199,11 @@ const BCS_SPEC: TypeSchema = { type_: 'TypeTag', }, ValidDuringExpiration: { - minEpoch: BCS.U64, - maxEpoch: BCS.U64, - chain: BCS.STRING, + minEpoch: 'Option', + maxEpoch: 'Option', + minTimestamp: 'Option', + maxTimestamp: 'Option', + chain: 'ObjectDigest', nonce: BCS.U32, }, SuiObjectRef: { diff --git a/modules/sdk-coin-sui/src/lib/utils.ts b/modules/sdk-coin-sui/src/lib/utils.ts index 0f292caf34..8acd4e8f1e 100644 --- a/modules/sdk-coin-sui/src/lib/utils.ts +++ b/modules/sdk-coin-sui/src/lib/utils.ts @@ -504,11 +504,11 @@ export class Utils implements BaseUtils { * @returns {Promise<{ epoch: number; chainId: string }>} - The current epoch and chain identifier. */ async getChainContext(url: string): Promise<{ epoch: number; chainId: string }> { - const [systemState, chainId] = await Promise.all([ + const [systemState, genesisCheckpoint] = await Promise.all([ makeRPC(url, 'suix_getLatestSuiSystemState', []), - makeRPC(url, 'sui_getChainIdentifier', []), + makeRPC(url, 'sui_getCheckpoint', ['0']), ]); - return { epoch: Number(systemState.epoch), chainId: String(chainId) }; + return { epoch: Number(systemState.epoch), chainId: String(genesisCheckpoint.digest) }; } async getBalance(url: string, owner: string, coinType?: string): Promise { diff --git a/modules/sdk-coin-sui/src/sui.ts b/modules/sdk-coin-sui/src/sui.ts index e2f4282938..11c68e28c2 100644 --- a/modules/sdk-coin-sui/src/sui.ts +++ b/modules/sdk-coin-sui/src/sui.ts @@ -44,6 +44,7 @@ import { import utils from './lib/utils'; import * as _ from 'lodash'; import { SuiBalanceInfo, SuiObjectInfo, SuiTransactionType } from './lib/iface'; +import { ValidDuringExpiration } from './lib/mystenlab/types/sui-bcs'; import { DEFAULT_GAS_OVERHEAD, DEFAULT_GAS_PRICE, @@ -408,13 +409,20 @@ export class Sui extends BaseCoin { // Case 2 self-funded: all balance is in address balance, no coin objects. // gasData.payment must be [] and a ValidDuring expiration is required to // prevent replay attacks when there are no gas coin objects to anchor uniqueness. - let validDuringExpiration: - | { ValidDuring: { minEpoch: number; maxEpoch: number; chain: string; nonce: number } } - | undefined; + let validDuringExpiration: { ValidDuring: ValidDuringExpiration } | undefined; if (fundsInAddressBalance.gt(0) && coinObjectsBalance.eq(0)) { const { epoch, chainId } = await utils.getChainContext(this.getPublicNodeUrl()); const nonce = crypto.randomBytes(4).readUInt32BE(0); - validDuringExpiration = { ValidDuring: { minEpoch: epoch, maxEpoch: epoch + 1, chain: chainId, nonce } }; + validDuringExpiration = { + ValidDuring: { + minEpoch: { Some: epoch }, + maxEpoch: { Some: epoch + 1 }, + minTimestamp: { None: null }, + maxTimestamp: { None: null }, + chain: chainId, + nonce, + }, + }; } // first build the unsigned txn diff --git a/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts b/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts index 583ae32ad7..34f4129e0d 100644 --- a/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts +++ b/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts @@ -511,5 +511,55 @@ describe('Sui Transfer Builder', () => { should.exist(epochVal); Number(epochVal).should.equal(324); }); + + it('should round-trip a self-pay transfer with ValidDuring expiration via fromBytes', async function () { + // Verifies the full 6-field ValidDuringExpiration BCS schema: + // minEpoch/maxEpoch as Option, minTimestamp/maxTimestamp as Option (None), + // chain as 32-byte Base58 ObjectDigest, nonce as u32. + // Uses a real mainnet genesis checkpoint digest (32 bytes, Base58). + const GENESIS_CHAIN_ID = 'GAFpCCcRCxTdFfUEMbQbkLBaZy2RNiGAfvFBhMNpq2kT'; + const FUNDS_IN_ADDRESS_BALANCE = '5000000000'; + 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({ + ValidDuring: { + minEpoch: { Some: 500 }, + maxEpoch: { Some: 501 }, + minTimestamp: { None: null }, + maxTimestamp: { None: null }, + chain: GENESIS_CHAIN_ID, + nonce: 0xdeadbeef, + }, + }); + + const tx = await txBuilder.build(); + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + + // fromBytes must not throw — ValidDuring fields must survive deserialization + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + + // BCS round-trip: serialized bytes must be identical + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + + // All ValidDuring fields must survive the round-trip + const expiration = (rebuiltTx.toJson().expiration as any)?.ValidDuring; + should.exist(expiration); + Number(expiration.minEpoch?.Some ?? expiration.minEpoch).should.equal(500); + Number(expiration.maxEpoch?.Some ?? expiration.maxEpoch).should.equal(501); + expiration.chain.should.equal(GENESIS_CHAIN_ID); + Number(expiration.nonce).should.equal(0xdeadbeef); + }); }); });