Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}),
}),
])
)
);
Expand Down
22 changes: 15 additions & 7 deletions modules/sdk-coin-sui/src/lib/mystenlab/types/sui-bcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64> 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;
};
Expand Down Expand Up @@ -193,9 +199,11 @@ const BCS_SPEC: TypeSchema = {
type_: 'TypeTag',
},
ValidDuringExpiration: {
minEpoch: BCS.U64,
maxEpoch: BCS.U64,
chain: BCS.STRING,
minEpoch: 'Option<u64>',
maxEpoch: 'Option<u64>',
minTimestamp: 'Option<u64>',
maxTimestamp: 'Option<u64>',
chain: 'ObjectDigest',
nonce: BCS.U32,
},
SuiObjectRef: {
Expand Down
6 changes: 3 additions & 3 deletions modules/sdk-coin-sui/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SuiBalanceInfo> {
Expand Down
16 changes: 12 additions & 4 deletions modules/sdk-coin-sui/src/sui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>, minTimestamp/maxTimestamp as Option<u64> (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);
});
});
});
Loading