Skip to content

Commit 7a20d3d

Browse files
feat(sdk-coin-starknet): add Poseidon V3 INVOKE transaction hash computation
The TransferBuilder now computes the signableHex (Poseidon hash) locally using @scure/starknet primitives, enabling wallet-platform to build Starknet transactions without delegating to IMS. New utils: encodeShortString, getSelectorFromName, compileExecuteCalldata, calculateInvokeTransactionHash. Hash validated against known Sepolia tx 0x739a72831c7f53634a2ffc94b78b61985e3cdffbad09ab20a1480e1bec9bdf2. CECHO-1165 TICKET: CECHO-1165
1 parent 02cd6e3 commit 7a20d3d

8 files changed

Lines changed: 673 additions & 9 deletions

File tree

modules/sdk-coin-starknet/src/lib/constants.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,14 @@ export const ADDR_BOUND = 2n ** 251n - 256n;
1717
export const CONTRACT_ADDRESS_PREFIX = 0x535441524b4e45545f434f4e54524143545f41444452455353n;
1818

1919
export const DEFAULT_SEED_SIZE_BYTES = 16;
20+
21+
// V3 transaction hash prefix: encodeShortString("invoke")
22+
export const INVOKE_TX_PREFIX = 0x696e766f6b65n;
23+
24+
// V3 transaction version
25+
export const TRANSACTION_VERSION_3 = 3n;
26+
27+
// Resource bound type names (short-string encoded felts)
28+
export const L1_GAS_NAME = 0x4c315f474153n; // "L1_GAS"
29+
export const L2_GAS_NAME = 0x4c325f474153n; // "L2_GAS"
30+
export const L1_DATA_GAS_NAME = 0x4c315f44415441n; // "L1_DATA" — NOT "L1_DATA_GAS"

modules/sdk-coin-starknet/src/lib/iface.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,24 @@ export interface StarknetTransactionData {
3030
transactionType: StarknetTransactionType;
3131
signature?: string[];
3232
transactionHash?: string;
33+
tip?: string;
34+
nonceDataAvailabilityMode?: number;
35+
feeDataAvailabilityMode?: number;
36+
compiledCalldata?: string[];
37+
}
38+
39+
export interface InvokeTransactionHashParams {
40+
senderAddress: string;
41+
compiledCalldata: string[];
42+
chainId: string;
43+
nonce: string;
44+
resourceBounds: StarknetResourceBounds;
45+
tip?: string;
46+
nonceDataAvailabilityMode?: number;
47+
feeDataAvailabilityMode?: number;
48+
paymasterData?: string[];
49+
accountDeploymentData?: string[];
50+
proofFacts?: string[];
3351
}
3452

3553
export interface ParsedTransferData {

modules/sdk-coin-starknet/src/lib/transaction.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ export class Transaction extends BaseTransaction {
2323

2424
set starknetTransactionData(data: StarknetTransactionData) {
2525
this._starknetTransactionData = data;
26+
if (data.transactionHash) {
27+
this._id = data.transactionHash;
28+
}
29+
}
30+
31+
get signableHex(): string {
32+
return this._starknetTransactionData?.transactionHash || '';
2633
}
2734

2835
get signedTransaction(): string | undefined {
@@ -47,6 +54,11 @@ export class Transaction extends BaseTransaction {
4754
transactionType: parsed.transactionType || StarknetTransactionType.INVOKE,
4855
signature: parsed.signature,
4956
transactionHash: parsed.transactionHash,
57+
resourceBounds: parsed.resourceBounds,
58+
tip: parsed.tip,
59+
compiledCalldata: parsed.compiledCalldata,
60+
nonceDataAvailabilityMode: parsed.nonceDataAvailabilityMode,
61+
feeDataAvailabilityMode: parsed.feeDataAvailabilityMode,
5062
};
5163

5264
if (parsed.signature && parsed.signature.length > 0) {

modules/sdk-coin-starknet/src/lib/transactionBuilder.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,27 @@ import {
88
} from '@bitgo/sdk-core';
99
import { BaseCoin as CoinConfig } from '@bitgo/statics';
1010
import BigNumber from 'bignumber.js';
11-
import { StarknetTransactionData, StarknetTransactionType, StarknetCall } from './iface';
11+
import { StarknetTransactionData, StarknetTransactionType, StarknetCall, StarknetResourceBounds } from './iface';
1212
import { Transaction } from './transaction';
1313
import utils from './utils';
1414

15+
function defaultResourceBounds(): StarknetResourceBounds {
16+
return {
17+
l2_gas: { max_amount: '0x1c9c380', max_price_per_unit: '0x174876e800' },
18+
l1_gas: { max_amount: '0x0', max_price_per_unit: '0x5af3107a4000' },
19+
l1_data_gas: { max_amount: '0x3e8', max_price_per_unit: '0x2540be400' },
20+
};
21+
}
22+
1523
export abstract class TransactionBuilder extends BaseTransactionBuilder {
1624
protected _transaction: Transaction;
1725
protected _sender?: string;
1826
protected _publicKey?: string;
1927
protected _calls: StarknetCall[] = [];
2028
protected _nonce?: string;
2129
protected _chainId?: string;
30+
protected _resourceBounds: StarknetResourceBounds = defaultResourceBounds();
31+
protected _tip = '0x0';
2232

2333
constructor(_coinConfig: Readonly<CoinConfig>) {
2434
super(_coinConfig);
@@ -55,6 +65,16 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
5565
return this;
5666
}
5767

68+
public resourceBounds(rb: StarknetResourceBounds): this {
69+
this._resourceBounds = rb;
70+
return this;
71+
}
72+
73+
public tip(tip: string): this {
74+
this._tip = tip;
75+
return this;
76+
}
77+
5878
/** @inheritdoc */
5979
get transaction(): Transaction {
6080
return this._transaction;
@@ -72,6 +92,12 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
7292
this._calls = data.calls || [];
7393
this._nonce = data.nonce;
7494
this._chainId = data.chainId;
95+
if (data.resourceBounds) {
96+
this._resourceBounds = data.resourceBounds;
97+
}
98+
if (data.tip) {
99+
this._tip = data.tip;
100+
}
75101
}
76102

77103
/** @inheritdoc */
@@ -128,12 +154,30 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
128154

129155
/** @inheritdoc */
130156
protected async buildImplementation(): Promise<Transaction> {
157+
const sender = this._sender as string;
158+
const chainId = this._chainId as string;
159+
const nonce = this._nonce as string;
160+
const compiledCalldata = utils.compileExecuteCalldata(this._calls);
161+
162+
const transactionHash = utils.calculateInvokeTransactionHash({
163+
senderAddress: sender,
164+
compiledCalldata,
165+
chainId,
166+
nonce,
167+
resourceBounds: this._resourceBounds,
168+
tip: this._tip,
169+
});
170+
131171
const data: StarknetTransactionData = {
132-
senderAddress: this._sender!,
172+
senderAddress: sender,
133173
calls: this._calls,
134-
nonce: this._nonce!,
135-
chainId: this._chainId!,
174+
nonce,
175+
chainId,
136176
transactionType: this.transactionType,
177+
resourceBounds: this._resourceBounds,
178+
tip: this._tip,
179+
transactionHash,
180+
compiledCalldata,
137181
};
138182

139183
this._transaction.starknetTransactionData = data;

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

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
1-
import { computeHashOnElements } from '@scure/starknet';
2-
import { FELT_MAX, MASK_128, OZ_ETH_ACCOUNT_CLASS_HASH, ADDR_BOUND, CONTRACT_ADDRESS_PREFIX } from './constants';
3-
import { StarknetTransactionData, StarknetCall, ParsedTransferData } from './iface';
1+
import { computeHashOnElements, poseidonHashMany, keccak } from '@scure/starknet';
2+
import {
3+
FELT_MAX,
4+
MASK_128,
5+
OZ_ETH_ACCOUNT_CLASS_HASH,
6+
ADDR_BOUND,
7+
CONTRACT_ADDRESS_PREFIX,
8+
INVOKE_TX_PREFIX,
9+
TRANSACTION_VERSION_3,
10+
L1_GAS_NAME,
11+
L2_GAS_NAME,
12+
L1_DATA_GAS_NAME,
13+
} from './constants';
14+
import { StarknetTransactionData, StarknetCall, ParsedTransferData, InvokeTransactionHashParams } from './iface';
415
import { ecc } from '@bitgo/secp256k1';
516

617
/**
@@ -198,6 +209,106 @@ export function validateRawTransaction(tx: StarknetTransactionData): void {
198209
}
199210
}
200211

212+
/**
213+
* Encode an ASCII string (max 31 chars) as a felt252.
214+
*/
215+
export function encodeShortString(str: string): bigint {
216+
if (str.length > 31) {
217+
throw new Error(`Short string too long: ${str.length} > 31`);
218+
}
219+
for (let i = 0; i < str.length; i++) {
220+
const code = str.charCodeAt(i);
221+
if (code > 127) {
222+
throw new Error(`Non-ASCII character at index ${i}: code ${code}`);
223+
}
224+
}
225+
let result = 0n;
226+
for (let i = 0; i < str.length; i++) {
227+
result = (result << 8n) | BigInt(str.charCodeAt(i));
228+
}
229+
return result;
230+
}
231+
232+
/**
233+
* Compute the Starknet function selector: keccak256(name) masked to 250 bits.
234+
* @scure/starknet's keccak() already applies the 250-bit mask.
235+
*/
236+
export function getSelectorFromName(name: string): bigint {
237+
return keccak(Buffer.from(name, 'ascii'));
238+
}
239+
240+
/**
241+
* Compile calls into the Cairo 1 multicall __execute__ calldata format.
242+
* Format: [num_calls, to_0, selector_0, data_len_0, ...data_0, to_1, ...]
243+
*/
244+
export function compileExecuteCalldata(calls: StarknetCall[]): string[] {
245+
const result: string[] = [];
246+
result.push('0x' + BigInt(calls.length).toString(16));
247+
for (const call of calls) {
248+
result.push(call.contractAddress);
249+
result.push('0x' + getSelectorFromName(call.entrypoint).toString(16));
250+
result.push('0x' + BigInt(call.calldata.length).toString(16));
251+
result.push(...call.calldata);
252+
}
253+
return result;
254+
}
255+
256+
function encodeResourceBound(typeName: bigint, maxAmount: string, maxPricePerUnit: string): bigint {
257+
return (typeName << 192n) | (BigInt(maxAmount) << 128n) | BigInt(maxPricePerUnit);
258+
}
259+
260+
/**
261+
* Compute the Poseidon V3 INVOKE transaction hash per SNIP-8.
262+
*/
263+
export function calculateInvokeTransactionHash(params: InvokeTransactionHashParams): string {
264+
const {
265+
senderAddress,
266+
compiledCalldata,
267+
chainId,
268+
nonce,
269+
resourceBounds,
270+
tip = '0x0',
271+
nonceDataAvailabilityMode = 0,
272+
feeDataAvailabilityMode = 0,
273+
paymasterData = [],
274+
accountDeploymentData = [],
275+
proofFacts,
276+
} = params;
277+
278+
const feeFieldHash = poseidonHashMany([
279+
BigInt(tip),
280+
encodeResourceBound(L1_GAS_NAME, resourceBounds.l1_gas.max_amount, resourceBounds.l1_gas.max_price_per_unit),
281+
encodeResourceBound(L2_GAS_NAME, resourceBounds.l2_gas.max_amount, resourceBounds.l2_gas.max_price_per_unit),
282+
encodeResourceBound(
283+
L1_DATA_GAS_NAME,
284+
resourceBounds.l1_data_gas.max_amount,
285+
resourceBounds.l1_data_gas.max_price_per_unit
286+
),
287+
]);
288+
289+
const daMode = (BigInt(nonceDataAvailabilityMode) << 32n) | BigInt(feeDataAvailabilityMode);
290+
291+
const hashFields: bigint[] = [
292+
INVOKE_TX_PREFIX,
293+
TRANSACTION_VERSION_3,
294+
BigInt(senderAddress),
295+
feeFieldHash,
296+
poseidonHashMany(paymasterData.map(BigInt)),
297+
BigInt(chainId),
298+
BigInt(nonce),
299+
daMode,
300+
poseidonHashMany(accountDeploymentData.map(BigInt)),
301+
poseidonHashMany(compiledCalldata.map(BigInt)),
302+
];
303+
304+
if (proofFacts && proofFacts.length > 0) {
305+
hashFields.push(poseidonHashMany(proofFacts.map(BigInt)));
306+
}
307+
308+
const hash = poseidonHashMany(hashFields);
309+
return '0x' + hash.toString(16);
310+
}
311+
201312
export default {
202313
isValidAddress,
203314
isValidPublicKey,
@@ -212,4 +323,8 @@ export default {
212323
parseTransferCall,
213324
generateKeyPair,
214325
validateRawTransaction,
326+
encodeShortString,
327+
getSelectorFromName,
328+
compileExecuteCalldata,
329+
calculateInvokeTransactionHash,
215330
};

modules/sdk-coin-starknet/test/resources/starknet.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,34 @@ export const TEST_AMOUNTS = {
8484
medium: '10000000000000000000',
8585
large: '999999999999999999999999',
8686
};
87+
88+
export const SandboxTransferData = {
89+
senderAddress: '0x1559292d3f9ea355458f83adf235b400e79786af5dc5e3b50f5505caa2bdc84',
90+
receiverAddress: '0x4a1e86ae265e6e6ecbea5be7f67117c3540f8aaf2ad7f1cfec33c53080f05af',
91+
amount: '1000000000000000000',
92+
chainId: '0x534e5f5345504f4c4941',
93+
tokenContract: '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d',
94+
resourceBounds: {
95+
l2_gas: { max_amount: '0x1c9c380', max_price_per_unit: '0x174876e800' },
96+
l1_gas: { max_amount: '0x0', max_price_per_unit: '0x5af3107a4000' },
97+
l1_data_gas: { max_amount: '0x3e8', max_price_per_unit: '0x2540be400' },
98+
},
99+
};
100+
101+
// Known-good tx from coins-sandbox/strkMPC/transferLocal.ts (block 9537253, Sepolia)
102+
// All inputs from the sandbox script; nonce confirmed via Voyager explorer.
103+
export const KnownGoodInvokeTx = {
104+
senderAddress: '0x1559292d3f9ea355458f83adf235b400e79786af5dc5e3b50f5505caa2bdc84',
105+
receiverAddress: '0x4a1e86ae265e6e6ecbea5be7f67117c3540f8aaf2ad7f1cfec33c53080f05af',
106+
amount: '1000000000000000000',
107+
tokenContract: '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d',
108+
nonce: '0x8',
109+
chainId: '0x534e5f5345504f4c4941',
110+
tip: '0x0',
111+
resourceBounds: {
112+
l2_gas: { max_amount: '0x1c9c380', max_price_per_unit: '0x174876e800' },
113+
l1_gas: { max_amount: '0x0', max_price_per_unit: '0x5af3107a4000' },
114+
l1_data_gas: { max_amount: '0x3e8', max_price_per_unit: '0x2540be400' },
115+
},
116+
expectedTxHash: '0x739a72831c7f53634a2ffc94b78b61985e3cdffbad09ab20a1480e1bec9bdf2',
117+
};

0 commit comments

Comments
 (0)