diff --git a/modules/sdk-coin-starknet/src/lib/constants.ts b/modules/sdk-coin-starknet/src/lib/constants.ts index 014e8c9322..3fc4d016b9 100644 --- a/modules/sdk-coin-starknet/src/lib/constants.ts +++ b/modules/sdk-coin-starknet/src/lib/constants.ts @@ -17,3 +17,14 @@ export const ADDR_BOUND = 2n ** 251n - 256n; export const CONTRACT_ADDRESS_PREFIX = 0x535441524b4e45545f434f4e54524143545f41444452455353n; export const DEFAULT_SEED_SIZE_BYTES = 16; + +// V3 transaction hash prefix: encodeShortString("invoke") +export const INVOKE_TX_PREFIX = 0x696e766f6b65n; + +// V3 transaction version +export const TRANSACTION_VERSION_3 = 3n; + +// Resource bound type names (short-string encoded felts) +export const L1_GAS_NAME = 0x4c315f474153n; // "L1_GAS" +export const L2_GAS_NAME = 0x4c325f474153n; // "L2_GAS" +export const L1_DATA_GAS_NAME = 0x4c315f44415441n; // "L1_DATA" — NOT "L1_DATA_GAS" diff --git a/modules/sdk-coin-starknet/src/lib/iface.ts b/modules/sdk-coin-starknet/src/lib/iface.ts index cf6b39a114..98c8746218 100644 --- a/modules/sdk-coin-starknet/src/lib/iface.ts +++ b/modules/sdk-coin-starknet/src/lib/iface.ts @@ -30,6 +30,24 @@ export interface StarknetTransactionData { transactionType: StarknetTransactionType; signature?: string[]; transactionHash?: string; + tip?: string; + nonceDataAvailabilityMode?: number; + feeDataAvailabilityMode?: number; + compiledCalldata?: string[]; +} + +export interface InvokeTransactionHashParams { + senderAddress: string; + compiledCalldata: string[]; + chainId: string; + nonce: string; + resourceBounds: StarknetResourceBounds; + tip?: string; + nonceDataAvailabilityMode?: number; + feeDataAvailabilityMode?: number; + paymasterData?: string[]; + accountDeploymentData?: string[]; + proofFacts?: string[]; } export interface ParsedTransferData { diff --git a/modules/sdk-coin-starknet/src/lib/transaction.ts b/modules/sdk-coin-starknet/src/lib/transaction.ts index d9cb195c99..49c142f104 100644 --- a/modules/sdk-coin-starknet/src/lib/transaction.ts +++ b/modules/sdk-coin-starknet/src/lib/transaction.ts @@ -23,6 +23,13 @@ export class Transaction extends BaseTransaction { set starknetTransactionData(data: StarknetTransactionData) { this._starknetTransactionData = data; + if (data.transactionHash) { + this._id = data.transactionHash; + } + } + + get signableHex(): string { + return this._starknetTransactionData?.transactionHash || ''; } get signedTransaction(): string | undefined { @@ -47,6 +54,11 @@ export class Transaction extends BaseTransaction { transactionType: parsed.transactionType || StarknetTransactionType.INVOKE, signature: parsed.signature, transactionHash: parsed.transactionHash, + resourceBounds: parsed.resourceBounds, + tip: parsed.tip, + compiledCalldata: parsed.compiledCalldata, + nonceDataAvailabilityMode: parsed.nonceDataAvailabilityMode, + feeDataAvailabilityMode: parsed.feeDataAvailabilityMode, }; if (parsed.signature && parsed.signature.length > 0) { diff --git a/modules/sdk-coin-starknet/src/lib/transactionBuilder.ts b/modules/sdk-coin-starknet/src/lib/transactionBuilder.ts index b72382236c..148ef42b95 100644 --- a/modules/sdk-coin-starknet/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-starknet/src/lib/transactionBuilder.ts @@ -8,10 +8,18 @@ import { } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import BigNumber from 'bignumber.js'; -import { StarknetTransactionData, StarknetTransactionType, StarknetCall } from './iface'; +import { StarknetTransactionData, StarknetTransactionType, StarknetCall, StarknetResourceBounds } from './iface'; import { Transaction } from './transaction'; import utils from './utils'; +function defaultResourceBounds(): StarknetResourceBounds { + return { + l2_gas: { max_amount: '0x1c9c380', max_price_per_unit: '0x174876e800' }, + l1_gas: { max_amount: '0x0', max_price_per_unit: '0x5af3107a4000' }, + l1_data_gas: { max_amount: '0x3e8', max_price_per_unit: '0x2540be400' }, + }; +} + export abstract class TransactionBuilder extends BaseTransactionBuilder { protected _transaction: Transaction; protected _sender?: string; @@ -19,6 +27,8 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { protected _calls: StarknetCall[] = []; protected _nonce?: string; protected _chainId?: string; + protected _resourceBounds: StarknetResourceBounds = defaultResourceBounds(); + protected _tip = '0x0'; constructor(_coinConfig: Readonly) { super(_coinConfig); @@ -55,6 +65,16 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { return this; } + public resourceBounds(rb: StarknetResourceBounds): this { + this._resourceBounds = rb; + return this; + } + + public tip(tip: string): this { + this._tip = tip; + return this; + } + /** @inheritdoc */ get transaction(): Transaction { return this._transaction; @@ -72,6 +92,12 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { this._calls = data.calls || []; this._nonce = data.nonce; this._chainId = data.chainId; + if (data.resourceBounds) { + this._resourceBounds = data.resourceBounds; + } + if (data.tip) { + this._tip = data.tip; + } } /** @inheritdoc */ @@ -128,12 +154,30 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { /** @inheritdoc */ protected async buildImplementation(): Promise { + const sender = this._sender as string; + const chainId = this._chainId as string; + const nonce = this._nonce as string; + const compiledCalldata = utils.compileExecuteCalldata(this._calls); + + const transactionHash = utils.calculateInvokeTransactionHash({ + senderAddress: sender, + compiledCalldata, + chainId, + nonce, + resourceBounds: this._resourceBounds, + tip: this._tip, + }); + const data: StarknetTransactionData = { - senderAddress: this._sender!, + senderAddress: sender, calls: this._calls, - nonce: this._nonce!, - chainId: this._chainId!, + nonce, + chainId, transactionType: this.transactionType, + resourceBounds: this._resourceBounds, + tip: this._tip, + transactionHash, + compiledCalldata, }; this._transaction.starknetTransactionData = data; diff --git a/modules/sdk-coin-starknet/src/lib/utils.ts b/modules/sdk-coin-starknet/src/lib/utils.ts index bcaeb3e89b..6883bfc9d8 100644 --- a/modules/sdk-coin-starknet/src/lib/utils.ts +++ b/modules/sdk-coin-starknet/src/lib/utils.ts @@ -1,6 +1,17 @@ -import { computeHashOnElements } from '@scure/starknet'; -import { FELT_MAX, MASK_128, OZ_ETH_ACCOUNT_CLASS_HASH, ADDR_BOUND, CONTRACT_ADDRESS_PREFIX } from './constants'; -import { StarknetTransactionData, StarknetCall, ParsedTransferData } from './iface'; +import { computeHashOnElements, poseidonHashMany, keccak } from '@scure/starknet'; +import { + FELT_MAX, + MASK_128, + OZ_ETH_ACCOUNT_CLASS_HASH, + ADDR_BOUND, + CONTRACT_ADDRESS_PREFIX, + INVOKE_TX_PREFIX, + TRANSACTION_VERSION_3, + L1_GAS_NAME, + L2_GAS_NAME, + L1_DATA_GAS_NAME, +} from './constants'; +import { StarknetTransactionData, StarknetCall, ParsedTransferData, InvokeTransactionHashParams } from './iface'; import { ecc } from '@bitgo/secp256k1'; /** @@ -198,6 +209,106 @@ export function validateRawTransaction(tx: StarknetTransactionData): void { } } +/** + * Encode an ASCII string (max 31 chars) as a felt252. + */ +export function encodeShortString(str: string): bigint { + if (str.length > 31) { + throw new Error(`Short string too long: ${str.length} > 31`); + } + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code > 127) { + throw new Error(`Non-ASCII character at index ${i}: code ${code}`); + } + } + let result = 0n; + for (let i = 0; i < str.length; i++) { + result = (result << 8n) | BigInt(str.charCodeAt(i)); + } + return result; +} + +/** + * Compute the Starknet function selector: keccak256(name) masked to 250 bits. + * @scure/starknet's keccak() already applies the 250-bit mask. + */ +export function getSelectorFromName(name: string): bigint { + return keccak(Buffer.from(name, 'ascii')); +} + +/** + * Compile calls into the Cairo 1 multicall __execute__ calldata format. + * Format: [num_calls, to_0, selector_0, data_len_0, ...data_0, to_1, ...] + */ +export function compileExecuteCalldata(calls: StarknetCall[]): string[] { + const result: string[] = []; + result.push('0x' + BigInt(calls.length).toString(16)); + for (const call of calls) { + result.push(call.contractAddress); + result.push('0x' + getSelectorFromName(call.entrypoint).toString(16)); + result.push('0x' + BigInt(call.calldata.length).toString(16)); + result.push(...call.calldata); + } + return result; +} + +function encodeResourceBound(typeName: bigint, maxAmount: string, maxPricePerUnit: string): bigint { + return (typeName << 192n) | (BigInt(maxAmount) << 128n) | BigInt(maxPricePerUnit); +} + +/** + * Compute the Poseidon V3 INVOKE transaction hash per SNIP-8. + */ +export function calculateInvokeTransactionHash(params: InvokeTransactionHashParams): string { + const { + senderAddress, + compiledCalldata, + chainId, + nonce, + resourceBounds, + tip = '0x0', + nonceDataAvailabilityMode = 0, + feeDataAvailabilityMode = 0, + paymasterData = [], + accountDeploymentData = [], + proofFacts, + } = params; + + const feeFieldHash = poseidonHashMany([ + BigInt(tip), + encodeResourceBound(L1_GAS_NAME, resourceBounds.l1_gas.max_amount, resourceBounds.l1_gas.max_price_per_unit), + encodeResourceBound(L2_GAS_NAME, resourceBounds.l2_gas.max_amount, resourceBounds.l2_gas.max_price_per_unit), + encodeResourceBound( + L1_DATA_GAS_NAME, + resourceBounds.l1_data_gas.max_amount, + resourceBounds.l1_data_gas.max_price_per_unit + ), + ]); + + const daMode = (BigInt(nonceDataAvailabilityMode) << 32n) | BigInt(feeDataAvailabilityMode); + + const hashFields: bigint[] = [ + INVOKE_TX_PREFIX, + TRANSACTION_VERSION_3, + BigInt(senderAddress), + feeFieldHash, + poseidonHashMany(paymasterData.map(BigInt)), + BigInt(chainId), + BigInt(nonce), + daMode, + poseidonHashMany(accountDeploymentData.map(BigInt)), + poseidonHashMany(compiledCalldata.map(BigInt)), + ]; + + if (proofFacts && proofFacts.length > 0) { + hashFields.push(poseidonHashMany(proofFacts.map(BigInt))); + } + + const hash = poseidonHashMany(hashFields); + return '0x' + hash.toString(16); +} + export default { isValidAddress, isValidPublicKey, @@ -212,4 +323,8 @@ export default { parseTransferCall, generateKeyPair, validateRawTransaction, + encodeShortString, + getSelectorFromName, + compileExecuteCalldata, + calculateInvokeTransactionHash, }; diff --git a/modules/sdk-coin-starknet/test/resources/starknet.ts b/modules/sdk-coin-starknet/test/resources/starknet.ts index 6dd3a99fdc..fd9a8b6819 100644 --- a/modules/sdk-coin-starknet/test/resources/starknet.ts +++ b/modules/sdk-coin-starknet/test/resources/starknet.ts @@ -84,3 +84,34 @@ export const TEST_AMOUNTS = { medium: '10000000000000000000', large: '999999999999999999999999', }; + +export const SandboxTransferData = { + senderAddress: '0x1559292d3f9ea355458f83adf235b400e79786af5dc5e3b50f5505caa2bdc84', + receiverAddress: '0x4a1e86ae265e6e6ecbea5be7f67117c3540f8aaf2ad7f1cfec33c53080f05af', + amount: '1000000000000000000', + chainId: '0x534e5f5345504f4c4941', + tokenContract: '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d', + resourceBounds: { + l2_gas: { max_amount: '0x1c9c380', max_price_per_unit: '0x174876e800' }, + l1_gas: { max_amount: '0x0', max_price_per_unit: '0x5af3107a4000' }, + l1_data_gas: { max_amount: '0x3e8', max_price_per_unit: '0x2540be400' }, + }, +}; + +// Known-good tx from coins-sandbox/strkMPC/transferLocal.ts (block 9537253, Sepolia) +// All inputs from the sandbox script; nonce confirmed via Voyager explorer. +export const KnownGoodInvokeTx = { + senderAddress: '0x1559292d3f9ea355458f83adf235b400e79786af5dc5e3b50f5505caa2bdc84', + receiverAddress: '0x4a1e86ae265e6e6ecbea5be7f67117c3540f8aaf2ad7f1cfec33c53080f05af', + amount: '1000000000000000000', + tokenContract: '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d', + nonce: '0x8', + chainId: '0x534e5f5345504f4c4941', + tip: '0x0', + resourceBounds: { + l2_gas: { max_amount: '0x1c9c380', max_price_per_unit: '0x174876e800' }, + l1_gas: { max_amount: '0x0', max_price_per_unit: '0x5af3107a4000' }, + l1_data_gas: { max_amount: '0x3e8', max_price_per_unit: '0x2540be400' }, + }, + expectedTxHash: '0x739a72831c7f53634a2ffc94b78b61985e3cdffbad09ab20a1480e1bec9bdf2', +}; diff --git a/modules/sdk-coin-starknet/test/unit/transferBuilder.ts b/modules/sdk-coin-starknet/test/unit/transferBuilder.ts new file mode 100644 index 0000000000..ff71e8b2e2 --- /dev/null +++ b/modules/sdk-coin-starknet/test/unit/transferBuilder.ts @@ -0,0 +1,248 @@ +import should from 'should'; +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory } from '../../src/lib/transactionBuilderFactory'; +import { Transaction } from '../../src/lib/transaction'; +import { Accounts, SandboxTransferData } from '../resources/starknet'; + +describe('Starknet TransferBuilder', () => { + const coinConfig = coins.get('starknet'); + + describe('Build transfer transaction', () => { + it('should build a transfer and produce a transactionHash', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getTransferBuilder(); + + builder + .sender(Accounts.account1.address) + .nonce('0x0') + .chainId(SandboxTransferData.chainId) + .receiverId(Accounts.account2.address) + .amount('1000000000000000000'); + + const tx = (await builder.build()) as Transaction; + const data = tx.starknetTransactionData; + + should.exist(data.transactionHash); + (data.transactionHash as string).should.startWith('0x'); + (data.transactionHash as string).length.should.be.greaterThan(2); + }); + + it('should set signableHex from the Poseidon hash', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getTransferBuilder(); + + builder + .sender(Accounts.account1.address) + .nonce('0x0') + .chainId(SandboxTransferData.chainId) + .receiverId(Accounts.account2.address) + .amount('1000000000000000000'); + + const tx = (await builder.build()) as Transaction; + tx.signableHex.should.equal(tx.starknetTransactionData.transactionHash); + tx.id.should.equal(tx.starknetTransactionData.transactionHash); + }); + + it('should produce different hashes for different recipients', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + + const builder1 = factory.getTransferBuilder(); + builder1 + .sender(Accounts.account1.address) + .nonce('0x0') + .chainId(SandboxTransferData.chainId) + .receiverId(Accounts.account2.address) + .amount('1000000000000000000'); + const tx1 = (await builder1.build()) as Transaction; + + const builder2 = factory.getTransferBuilder(); + builder2 + .sender(Accounts.account1.address) + .nonce('0x0') + .chainId(SandboxTransferData.chainId) + .receiverId(Accounts.account3.address) + .amount('1000000000000000000'); + const tx2 = (await builder2.build()) as Transaction; + + tx1.signableHex.should.not.equal(tx2.signableHex); + }); + + it('should produce different hashes for different amounts', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + + const builder1 = factory.getTransferBuilder(); + builder1 + .sender(Accounts.account1.address) + .nonce('0x0') + .chainId(SandboxTransferData.chainId) + .receiverId(Accounts.account2.address) + .amount('1000000000000000000'); + const tx1 = (await builder1.build()) as Transaction; + + const builder2 = factory.getTransferBuilder(); + builder2 + .sender(Accounts.account1.address) + .nonce('0x0') + .chainId(SandboxTransferData.chainId) + .receiverId(Accounts.account2.address) + .amount('2000000000000000000'); + const tx2 = (await builder2.build()) as Transaction; + + tx1.signableHex.should.not.equal(tx2.signableHex); + }); + + it('should include compiledCalldata in transaction data', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getTransferBuilder(); + + builder + .sender(Accounts.account1.address) + .nonce('0x0') + .chainId(SandboxTransferData.chainId) + .receiverId(Accounts.account2.address) + .amount('1000000000000000000'); + + const tx = (await builder.build()) as Transaction; + const data = tx.starknetTransactionData; + + should.exist(data.compiledCalldata); + (data.compiledCalldata as string[]).length.should.be.greaterThan(0); + (data.compiledCalldata as string[])[0].should.equal('0x1'); // 1 call + }); + + it('should include resourceBounds in transaction data', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getTransferBuilder(); + + builder + .sender(Accounts.account1.address) + .nonce('0x0') + .chainId(SandboxTransferData.chainId) + .receiverId(Accounts.account2.address) + .amount('1000000000000000000') + .resourceBounds(SandboxTransferData.resourceBounds); + + const tx = (await builder.build()) as Transaction; + const data = tx.starknetTransactionData; + + should.exist(data.resourceBounds); + (data.resourceBounds as { l2_gas: { max_amount: string } }).l2_gas.max_amount.should.equal( + SandboxTransferData.resourceBounds.l2_gas.max_amount + ); + }); + + it('should accept custom resource bounds and produce different hash', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + + const builder1 = factory.getTransferBuilder(); + builder1 + .sender(Accounts.account1.address) + .nonce('0x0') + .chainId(SandboxTransferData.chainId) + .receiverId(Accounts.account2.address) + .amount('1000000000000000000'); + const tx1 = (await builder1.build()) as Transaction as Transaction; + + const customBounds = { + l2_gas: { max_amount: '0x3938700', max_price_per_unit: '0x174876e800' }, + l1_gas: { max_amount: '0x0', max_price_per_unit: '0x5af3107a4000' }, + l1_data_gas: { max_amount: '0x7d0', max_price_per_unit: '0x2540be400' }, + }; + const builder2 = factory.getTransferBuilder(); + builder2 + .sender(Accounts.account1.address) + .nonce('0x0') + .chainId(SandboxTransferData.chainId) + .receiverId(Accounts.account2.address) + .amount('1000000000000000000') + .resourceBounds(customBounds); + const tx2 = (await builder2.build()) as Transaction as Transaction; + + tx1.signableHex.should.not.equal(tx2.signableHex); + }); + + it('should round-trip through toBroadcastFormat and fromRawTransaction', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getTransferBuilder(); + + builder + .sender(Accounts.account1.address) + .nonce('0x0') + .chainId(SandboxTransferData.chainId) + .receiverId(Accounts.account2.address) + .amount('1000000000000000000'); + + const tx = (await builder.build()) as Transaction; + const broadcastHex = tx.toBroadcastFormat(); + + const factory2 = new TransactionBuilderFactory(coinConfig); + const builder2 = await factory2.from(broadcastHex); + const tx2 = (await builder2.build()) as Transaction as Transaction; + + tx2.signableHex.should.equal(tx.signableHex); + tx2.id.should.equal(tx.id); + }); + }); + + describe('Validation', () => { + it('should reject build without sender', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getTransferBuilder(); + builder + .nonce('0x0') + .chainId(SandboxTransferData.chainId) + .receiverId(Accounts.account2.address) + .amount('1000000000000000000'); + + await builder.build().should.be.rejectedWith(/[Ss]ender/); + }); + + it('should reject build without nonce', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getTransferBuilder(); + builder + .sender(Accounts.account1.address) + .chainId(SandboxTransferData.chainId) + .receiverId(Accounts.account2.address) + .amount('1000000000000000000'); + + await builder.build().should.be.rejectedWith(/[Nn]once/); + }); + + it('should reject build without chainId', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getTransferBuilder(); + builder + .sender(Accounts.account1.address) + .nonce('0x0') + .receiverId(Accounts.account2.address) + .amount('1000000000000000000'); + + await builder.build().should.be.rejectedWith(/[Cc]hain/); + }); + + it('should reject build without receiver', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getTransferBuilder(); + builder + .sender(Accounts.account1.address) + .nonce('0x0') + .chainId(SandboxTransferData.chainId) + .amount('1000000000000000000'); + + await builder.build().should.be.rejectedWith(/[Rr]eceiver/); + }); + + it('should reject invalid sender address', () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getTransferBuilder(); + (() => builder.sender('invalid')).should.throw(/[Ii]nvalid/); + }); + + it('should reject invalid receiver address', () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getTransferBuilder(); + (() => builder.receiverId('invalid')).should.throw(/[Ii]nvalid/); + }); + }); +}); diff --git a/modules/sdk-coin-starknet/test/unit/utils.ts b/modules/sdk-coin-starknet/test/unit/utils.ts index 56d0ad38f8..3b1565764a 100644 --- a/modules/sdk-coin-starknet/test/unit/utils.ts +++ b/modules/sdk-coin-starknet/test/unit/utils.ts @@ -1,5 +1,11 @@ -import utils from '../../src/lib/utils'; -import { Accounts } from '../resources/starknet'; +import utils, { + encodeShortString, + getSelectorFromName, + compileExecuteCalldata, + calculateInvokeTransactionHash, +} from '../../src/lib/utils'; +import { Accounts, SandboxTransferData, KnownGoodInvokeTx } from '../resources/starknet'; +import { MASK_128 } from '../../src/lib/constants'; import 'should'; describe('Starknet Utils', () => { @@ -78,4 +84,183 @@ describe('Starknet Utils', () => { sig1[4].should.equal('0x1'); }); }); + + describe('encodeShortString', () => { + it('should encode "invoke" correctly', () => { + encodeShortString('invoke').should.equal(0x696e766f6b65n); + }); + + it('should encode "L1_GAS" correctly', () => { + encodeShortString('L1_GAS').should.equal(0x4c315f474153n); + }); + + it('should encode "L2_GAS" correctly', () => { + encodeShortString('L2_GAS').should.equal(0x4c325f474153n); + }); + + it('should encode "L1_DATA" correctly', () => { + encodeShortString('L1_DATA').should.equal(0x4c315f44415441n); + }); + + it('should reject strings longer than 31 chars', () => { + (() => encodeShortString('a'.repeat(32))).should.throw(/too long/); + }); + }); + + describe('getSelectorFromName', () => { + it('should compute selector for "transfer"', () => { + const selector = getSelectorFromName('transfer'); + (typeof selector).should.equal('bigint'); + (selector > 0n).should.equal(true); + (selector < 1n << 250n).should.equal(true); + }); + + it('should be deterministic', () => { + getSelectorFromName('transfer').should.equal(getSelectorFromName('transfer')); + }); + }); + + describe('compileExecuteCalldata', () => { + it('should compile a single transfer call', () => { + const calls = [ + { + contractAddress: SandboxTransferData.tokenContract, + entrypoint: 'transfer', + calldata: [ + SandboxTransferData.receiverAddress, + '0x' + (BigInt(SandboxTransferData.amount) & MASK_128).toString(16), + '0x' + (BigInt(SandboxTransferData.amount) >> 128n).toString(16), + ], + }, + ]; + const compiled = compileExecuteCalldata(calls); + compiled[0].should.equal('0x1'); // num_calls = 1 + compiled[1].should.equal(SandboxTransferData.tokenContract); // to + compiled.length.should.equal(1 + 1 + 1 + 1 + 3); // num_calls + to + selector + data_len + 3 calldata + }); + }); + + describe('calculateInvokeTransactionHash', () => { + it('should produce a deterministic hash', () => { + const calls = [ + { + contractAddress: SandboxTransferData.tokenContract, + entrypoint: 'transfer', + calldata: [ + SandboxTransferData.receiverAddress, + '0x' + (BigInt(SandboxTransferData.amount) & MASK_128).toString(16), + '0x' + (BigInt(SandboxTransferData.amount) >> 128n).toString(16), + ], + }, + ]; + const compiledCalldata = compileExecuteCalldata(calls); + + const hash1 = calculateInvokeTransactionHash({ + senderAddress: SandboxTransferData.senderAddress, + compiledCalldata, + chainId: SandboxTransferData.chainId, + nonce: '0x0', + resourceBounds: SandboxTransferData.resourceBounds, + }); + + const hash2 = calculateInvokeTransactionHash({ + senderAddress: SandboxTransferData.senderAddress, + compiledCalldata, + chainId: SandboxTransferData.chainId, + nonce: '0x0', + resourceBounds: SandboxTransferData.resourceBounds, + }); + + hash1.should.equal(hash2); + hash1.should.startWith('0x'); + }); + + it('should produce different hashes for different nonces', () => { + const calls = [ + { + contractAddress: SandboxTransferData.tokenContract, + entrypoint: 'transfer', + calldata: [SandboxTransferData.receiverAddress, '0xde0b6b3a7640000', '0x0'], + }, + ]; + const compiledCalldata = compileExecuteCalldata(calls); + + const hash1 = calculateInvokeTransactionHash({ + senderAddress: SandboxTransferData.senderAddress, + compiledCalldata, + chainId: SandboxTransferData.chainId, + nonce: '0x0', + resourceBounds: SandboxTransferData.resourceBounds, + }); + + const hash2 = calculateInvokeTransactionHash({ + senderAddress: SandboxTransferData.senderAddress, + compiledCalldata, + chainId: SandboxTransferData.chainId, + nonce: '0x1', + resourceBounds: SandboxTransferData.resourceBounds, + }); + + hash1.should.not.equal(hash2); + }); + + it('should not include proof_facts when absent', () => { + const calls = [ + { + contractAddress: SandboxTransferData.tokenContract, + entrypoint: 'transfer', + calldata: [SandboxTransferData.receiverAddress, '0xde0b6b3a7640000', '0x0'], + }, + ]; + const compiledCalldata = compileExecuteCalldata(calls); + + const hashWithout = calculateInvokeTransactionHash({ + senderAddress: SandboxTransferData.senderAddress, + compiledCalldata, + chainId: SandboxTransferData.chainId, + nonce: '0x0', + resourceBounds: SandboxTransferData.resourceBounds, + }); + + const hashWithEmpty = calculateInvokeTransactionHash({ + senderAddress: SandboxTransferData.senderAddress, + compiledCalldata, + chainId: SandboxTransferData.chainId, + nonce: '0x0', + resourceBounds: SandboxTransferData.resourceBounds, + proofFacts: [], + }); + + // Empty proofFacts should be omitted, producing same hash as absent + hashWithout.should.equal(hashWithEmpty); + }); + + it('should match known-good sandbox tx hash (0x739a728...)', () => { + const tv = KnownGoodInvokeTx; + const amountBig = BigInt(tv.amount); + const calls = [ + { + contractAddress: tv.tokenContract, + entrypoint: 'transfer', + calldata: [ + tv.receiverAddress, + '0x' + (amountBig & MASK_128).toString(16), + '0x' + (amountBig >> 128n).toString(16), + ], + }, + ]; + const compiledCalldata = compileExecuteCalldata(calls); + + const hash = calculateInvokeTransactionHash({ + senderAddress: tv.senderAddress, + compiledCalldata, + chainId: tv.chainId, + nonce: tv.nonce, + resourceBounds: tv.resourceBounds, + tip: tv.tip, + }); + + hash.should.equal(tv.expectedTxHash); + }); + }); });