Skip to content

Commit 2f106f0

Browse files
Merge pull request #8518 from BitGo/CSHLD-612
fix(sdk-coin-sui): address balance transaction expiration handling
2 parents a87272c + 821b7a7 commit 2f106f0

5 files changed

Lines changed: 90 additions & 15 deletions

File tree

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,16 @@ export const TransactionExpiration = optional(
3838
union([
3939
object({ Epoch: StringEncodedBigint }),
4040
object({ None: union([literal(true), literal(null)]) }),
41-
object({ ValidDuring: object({ minEpoch: integer(), maxEpoch: integer(), chain: string(), nonce: integer() }) }),
41+
object({
42+
ValidDuring: object({
43+
minEpoch: union([object({ Some: StringEncodedBigint }), object({ None: union([literal(null), literal(true)]) })]),
44+
maxEpoch: union([object({ Some: StringEncodedBigint }), object({ None: union([literal(null), literal(true)]) })]),
45+
minTimestamp: union([object({ Some: StringEncodedBigint }), object({ None: union([literal(null), literal(true)]) })]),
46+
maxTimestamp: union([object({ Some: StringEncodedBigint }), object({ None: union([literal(null), literal(true)]) })]),
47+
chain: string(),
48+
nonce: integer(),
49+
}),
50+
}),
4251
])
4352
)
4453
);

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,20 @@ export type GasData = {
106106
budget: number;
107107
};
108108

109+
type OptionU64 = { Some: number } | { None: null };
110+
109111
/**
110112
* ValidDuring expiration — used when gasData.payment is empty (address-balance-funded gas).
111-
* Both minEpoch and maxEpoch must be set; maxEpoch must equal minEpoch or minEpoch + 1.
112-
* The nonce (u32) prevents duplicate transaction digests across same-epoch builds.
113+
* Both minEpoch and maxEpoch are Option<u64> matching the Sui protocol BCS layout.
114+
* minTimestamp/maxTimestamp are not yet used by the protocol and must be None.
115+
* chain is the Base58-encoded genesis checkpoint digest (32 bytes).
116+
* nonce (u32) prevents duplicate transaction digests across same-epoch builds.
113117
*/
114118
export type ValidDuringExpiration = {
115-
minEpoch: number;
116-
maxEpoch: number;
119+
minEpoch: OptionU64;
120+
maxEpoch: OptionU64;
121+
minTimestamp: OptionU64;
122+
maxTimestamp: OptionU64;
117123
chain: string;
118124
nonce: number;
119125
};
@@ -193,9 +199,11 @@ const BCS_SPEC: TypeSchema = {
193199
type_: 'TypeTag',
194200
},
195201
ValidDuringExpiration: {
196-
minEpoch: BCS.U64,
197-
maxEpoch: BCS.U64,
198-
chain: BCS.STRING,
202+
minEpoch: 'Option<u64>',
203+
maxEpoch: 'Option<u64>',
204+
minTimestamp: 'Option<u64>',
205+
maxTimestamp: 'Option<u64>',
206+
chain: 'ObjectDigest',
199207
nonce: BCS.U32,
200208
},
201209
SuiObjectRef: {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -504,11 +504,11 @@ export class Utils implements BaseUtils {
504504
* @returns {Promise<{ epoch: number; chainId: string }>} - The current epoch and chain identifier.
505505
*/
506506
async getChainContext(url: string): Promise<{ epoch: number; chainId: string }> {
507-
const [systemState, chainId] = await Promise.all([
507+
const [systemState, genesisCheckpoint] = await Promise.all([
508508
makeRPC(url, 'suix_getLatestSuiSystemState', []),
509-
makeRPC(url, 'sui_getChainIdentifier', []),
509+
makeRPC(url, 'sui_getCheckpoint', ['0']),
510510
]);
511-
return { epoch: Number(systemState.epoch), chainId: String(chainId) };
511+
return { epoch: Number(systemState.epoch), chainId: String(genesisCheckpoint.digest) };
512512
}
513513

514514
async getBalance(url: string, owner: string, coinType?: string): Promise<SuiBalanceInfo> {

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
import utils from './lib/utils';
4545
import * as _ from 'lodash';
4646
import { SuiBalanceInfo, SuiObjectInfo, SuiTransactionType } from './lib/iface';
47+
import { ValidDuringExpiration } from './lib/mystenlab/types/sui-bcs';
4748
import {
4849
DEFAULT_GAS_OVERHEAD,
4950
DEFAULT_GAS_PRICE,
@@ -408,13 +409,20 @@ export class Sui extends BaseCoin {
408409
// Case 2 self-funded: all balance is in address balance, no coin objects.
409410
// gasData.payment must be [] and a ValidDuring expiration is required to
410411
// prevent replay attacks when there are no gas coin objects to anchor uniqueness.
411-
let validDuringExpiration:
412-
| { ValidDuring: { minEpoch: number; maxEpoch: number; chain: string; nonce: number } }
413-
| undefined;
412+
let validDuringExpiration: { ValidDuring: ValidDuringExpiration } | undefined;
414413
if (fundsInAddressBalance.gt(0) && coinObjectsBalance.eq(0)) {
415414
const { epoch, chainId } = await utils.getChainContext(this.getPublicNodeUrl());
416415
const nonce = crypto.randomBytes(4).readUInt32BE(0);
417-
validDuringExpiration = { ValidDuring: { minEpoch: epoch, maxEpoch: epoch + 1, chain: chainId, nonce } };
416+
validDuringExpiration = {
417+
ValidDuring: {
418+
minEpoch: { Some: epoch },
419+
maxEpoch: { Some: epoch + 1 },
420+
minTimestamp: { None: null },
421+
maxTimestamp: { None: null },
422+
chain: chainId,
423+
nonce,
424+
},
425+
};
418426
}
419427

420428
// first build the unsigned txn

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,5 +511,55 @@ describe('Sui Transfer Builder', () => {
511511
should.exist(epochVal);
512512
Number(epochVal).should.equal(324);
513513
});
514+
515+
it('should round-trip a self-pay transfer with ValidDuring expiration via fromBytes', async function () {
516+
// Verifies the full 6-field ValidDuringExpiration BCS schema:
517+
// minEpoch/maxEpoch as Option<u64>, minTimestamp/maxTimestamp as Option<u64> (None),
518+
// chain as 32-byte Base58 ObjectDigest, nonce as u32.
519+
// Uses a real mainnet genesis checkpoint digest (32 bytes, Base58).
520+
const GENESIS_CHAIN_ID = 'GAFpCCcRCxTdFfUEMbQbkLBaZy2RNiGAfvFBhMNpq2kT';
521+
const FUNDS_IN_ADDRESS_BALANCE = '5000000000';
522+
const gasDataNoPayment = {
523+
...testData.gasDataWithoutGasPayment,
524+
payment: [],
525+
};
526+
527+
const txBuilder = factory.getTransferBuilder();
528+
txBuilder.type(SuiTransactionType.Transfer);
529+
txBuilder.sender(testData.sender.address);
530+
txBuilder.send(testData.recipients);
531+
txBuilder.gasData(gasDataNoPayment);
532+
txBuilder.fundsInAddressBalance(FUNDS_IN_ADDRESS_BALANCE);
533+
txBuilder.expiration({
534+
ValidDuring: {
535+
minEpoch: { Some: 500 },
536+
maxEpoch: { Some: 501 },
537+
minTimestamp: { None: null },
538+
maxTimestamp: { None: null },
539+
chain: GENESIS_CHAIN_ID,
540+
nonce: 0xdeadbeef,
541+
},
542+
});
543+
544+
const tx = await txBuilder.build();
545+
const rawTx = tx.toBroadcastFormat();
546+
should.equal(utils.isValidRawTransaction(rawTx), true);
547+
548+
// fromBytes must not throw — ValidDuring fields must survive deserialization
549+
const rebuilder = factory.from(rawTx);
550+
rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex));
551+
const rebuiltTx = await rebuilder.build();
552+
553+
// BCS round-trip: serialized bytes must be identical
554+
rebuiltTx.toBroadcastFormat().should.equal(rawTx);
555+
556+
// All ValidDuring fields must survive the round-trip
557+
const expiration = (rebuiltTx.toJson().expiration as any)?.ValidDuring;
558+
should.exist(expiration);
559+
Number(expiration.minEpoch?.Some ?? expiration.minEpoch).should.equal(500);
560+
Number(expiration.maxEpoch?.Some ?? expiration.maxEpoch).should.equal(501);
561+
expiration.chain.should.equal(GENESIS_CHAIN_ID);
562+
Number(expiration.nonce).should.equal(0xdeadbeef);
563+
});
514564
});
515565
});

0 commit comments

Comments
 (0)