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
11 changes: 11 additions & 0 deletions modules/sdk-coin-starknet/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
18 changes: 18 additions & 0 deletions modules/sdk-coin-starknet/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions modules/sdk-coin-starknet/src/lib/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
52 changes: 48 additions & 4 deletions modules/sdk-coin-starknet/src/lib/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,27 @@ 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;
protected _publicKey?: string;
protected _calls: StarknetCall[] = [];
protected _nonce?: string;
protected _chainId?: string;
protected _resourceBounds: StarknetResourceBounds = defaultResourceBounds();
protected _tip = '0x0';

constructor(_coinConfig: Readonly<CoinConfig>) {
super(_coinConfig);
Expand Down Expand Up @@ -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;
Expand All @@ -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 */
Expand Down Expand Up @@ -128,12 +154,30 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {

/** @inheritdoc */
protected async buildImplementation(): Promise<Transaction> {
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;
Expand Down
121 changes: 118 additions & 3 deletions modules/sdk-coin-starknet/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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));
Comment thread
shubham-damkondwar marked this conversation as resolved.
}
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);
Comment thread
shubham-damkondwar marked this conversation as resolved.
}

export default {
isValidAddress,
isValidPublicKey,
Expand All @@ -212,4 +323,8 @@ export default {
parseTransferCall,
generateKeyPair,
validateRawTransaction,
encodeShortString,
getSelectorFromName,
compileExecuteCalldata,
calculateInvokeTransactionHash,
};
31 changes: 31 additions & 0 deletions modules/sdk-coin-starknet/test/resources/starknet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Loading
Loading