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
3 changes: 3 additions & 0 deletions modules/sdk-coin-starknet/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export const DEFAULT_SEED_SIZE_BYTES = 16;
// V3 transaction hash prefix: encodeShortString("invoke")
export const INVOKE_TX_PREFIX = 0x696e766f6b65n;

// V3 transaction hash prefix: encodeShortString("deploy_account")
export const DEPLOY_ACCOUNT_TX_PREFIX = 0x6465706c6f795f6163636f756e74n;

// V3 transaction version
export const TRANSACTION_VERSION_3 = 3n;

Expand Down
20 changes: 20 additions & 0 deletions modules/sdk-coin-starknet/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export interface StarknetTransactionData {
nonceDataAvailabilityMode?: number;
feeDataAvailabilityMode?: number;
compiledCalldata?: string[];
/** DEPLOY_ACCOUNT: OZ EthAccount class hash. */
classHash?: string;
/** DEPLOY_ACCOUNT: constructor calldata (pubkey limbs). */
constructorCalldata?: string[];
/** DEPLOY_ACCOUNT: address salt derived from pubkey. */
contractAddressSalt?: string;
}

export interface InvokeTransactionHashParams {
Expand All @@ -50,6 +56,20 @@ export interface InvokeTransactionHashParams {
proofFacts?: string[];
}

export interface DeployAccountTransactionHashParams {
contractAddress: string;
classHash: string;
constructorCalldata: string[];
contractAddressSalt: string;
chainId: string;
nonce: string;
resourceBounds: StarknetResourceBounds;
tip?: string;
nonceDataAvailabilityMode?: number;
feeDataAvailabilityMode?: number;
paymasterData?: string[];
}

export interface ParsedTransferData {
recipient: string;
amount: string;
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-coin-starknet/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './iface';
export { KeyPair } from './keyPair';
export { TransactionBuilder } from './transactionBuilder';
export { TransferBuilder } from './transferBuilder';
export { WalletInitializationBuilder } from './walletInitializationBuilder';
export { TransactionBuilderFactory } from './transactionBuilderFactory';
export { Transaction } from './transaction';
export { Utils };
40 changes: 37 additions & 3 deletions modules/sdk-coin-starknet/src/lib/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ export class Transaction extends BaseTransaction {
compiledCalldata: parsed.compiledCalldata,
nonceDataAvailabilityMode: parsed.nonceDataAvailabilityMode,
feeDataAvailabilityMode: parsed.feeDataAvailabilityMode,
classHash: parsed.classHash,
constructorCalldata: parsed.constructorCalldata,
contractAddressSalt: parsed.contractAddressSalt,
};

if (parsed.signature && parsed.signature.length > 0) {
Expand Down Expand Up @@ -146,13 +149,44 @@ export class Transaction extends BaseTransaction {
return Buffer.from(JSON.stringify(data), 'utf-8').toString('hex');
}

/** @inheritdoc — returns Starknet RPC-ready JSON string for starknet_addInvokeTransaction. */
/** @inheritdoc — returns Starknet RPC-ready JSON for addInvoke or addDeployAccount. */
toBroadcastFormat(): string {
const data = this._starknetTransactionData;
if (!data) {
throw new InvalidTransactionError('Empty transaction');
}
return JSON.stringify({

const payload =
data.transactionType === StarknetTransactionType.DEPLOY_ACCOUNT
? this.buildDeployAccountPayload(data)
: this.buildInvokePayload(data);

return JSON.stringify(payload);
}
private buildDeployAccountPayload(data: StarknetTransactionData) {
if (!data.classHash || !data.constructorCalldata || !data.contractAddressSalt) {
throw new InvalidTransactionError('Incomplete deploy account transaction');
}

return {
type: 'DEPLOY_ACCOUNT',
version: '0x3',
signature: data.signature || [],
nonce: data.nonce,
contract_address_salt: data.contractAddressSalt,
constructor_calldata: data.constructorCalldata,
class_hash: data.classHash,
sender_address: data.senderAddress,
resource_bounds: resolveResourceBounds(data),
tip: data.tip || '0x0',
paymaster_data: [],
nonce_data_availability_mode: 'L1',
fee_data_availability_mode: 'L1',
};
}

private buildInvokePayload(data: StarknetTransactionData) {
return {
type: 'INVOKE',
version: '0x3',
sender_address: data.senderAddress,
Expand All @@ -165,7 +199,7 @@ export class Transaction extends BaseTransaction {
account_deployment_data: [],
nonce_data_availability_mode: 'L1',
fee_data_availability_mode: 'L1',
});
};
}

/** @inheritdoc */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { BaseTransactionBuilderFactory, InvalidTransactionError, MethodNotImplementedError } from '@bitgo/sdk-core';
import { BaseTransactionBuilderFactory, InvalidTransactionError } from '@bitgo/sdk-core';
import { BaseCoin as CoinConfig } from '@bitgo/statics';
import { Transaction } from './transaction';
import { TransactionBuilder } from './transactionBuilder';
import { TransferBuilder } from './transferBuilder';
import { WalletInitializationBuilder } from './walletInitializationBuilder';
import { StarknetTransactionType } from './iface';

export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
Expand All @@ -18,6 +19,8 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
switch (transaction.starknetTransactionData.transactionType) {
case StarknetTransactionType.INVOKE:
return this.getTransferBuilder(transaction);
case StarknetTransactionType.DEPLOY_ACCOUNT:
return this.getWalletInitializationBuilder(transaction);
default:
throw new InvalidTransactionError('Invalid transaction type');
}
Expand All @@ -39,7 +42,7 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
}

/** @inheritdoc */
getWalletInitializationBuilder(): void {
throw new MethodNotImplementedError();
getWalletInitializationBuilder(tx?: Transaction): WalletInitializationBuilder {
return TransactionBuilderFactory.initializeBuilder(tx, new WalletInitializationBuilder(this._coinConfig));
}
}
71 changes: 70 additions & 1 deletion modules/sdk-coin-starknet/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,20 @@ import {
ADDR_BOUND,
CONTRACT_ADDRESS_PREFIX,
INVOKE_TX_PREFIX,
DEPLOY_ACCOUNT_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 {
StarknetTransactionData,
StarknetTransactionType,
StarknetCall,
ParsedTransferData,
InvokeTransactionHashParams,
DeployAccountTransactionHashParams,
} from './iface';
import { ecc } from '@bitgo/secp256k1';

/**
Expand Down Expand Up @@ -207,6 +215,17 @@ export function validateRawTransaction(tx: StarknetTransactionData): void {
if (!isValidAddress(tx.senderAddress)) {
throw new Error(`Invalid sender address: ${tx.senderAddress}`);
}
if (tx.transactionType === StarknetTransactionType.DEPLOY_ACCOUNT) {
if (!tx.classHash) {
throw new Error('Missing class hash for deploy account transaction');
}
if (!tx.constructorCalldata || tx.constructorCalldata.length === 0) {
throw new Error('Missing constructor calldata for deploy account transaction');
}
if (!tx.contractAddressSalt) {
throw new Error('Missing contract address salt for deploy account transaction');
}
}
}

/**
Expand Down Expand Up @@ -309,6 +328,55 @@ export function calculateInvokeTransactionHash(params: InvokeTransactionHashPara
return '0x' + hash.toString(16);
}

/**
* Compute the Poseidon V3 DEPLOY_ACCOUNT transaction hash per SNIP-8 (starknet.js v3).
*/
export function calculateDeployAccountTransactionHash(params: DeployAccountTransactionHashParams): string {
const {
contractAddress,
classHash,
constructorCalldata,
contractAddressSalt,
chainId,
nonce,
resourceBounds,
tip = '0x0',
nonceDataAvailabilityMode = 0,
feeDataAvailabilityMode = 0,
paymasterData = [],
} = 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[] = [
DEPLOY_ACCOUNT_TX_PREFIX,
TRANSACTION_VERSION_3,
BigInt(contractAddress),
feeFieldHash,
poseidonHashMany(paymasterData.map(BigInt)),
BigInt(chainId),
BigInt(nonce),
daMode,
poseidonHashMany(constructorCalldata.map(BigInt)),
BigInt(classHash),
BigInt(contractAddressSalt),
];

const hash = poseidonHashMany(hashFields);
return '0x' + hash.toString(16);
}

export default {
isValidAddress,
isValidPublicKey,
Expand All @@ -327,4 +395,5 @@ export default {
getSelectorFromName,
compileExecuteCalldata,
calculateInvokeTransactionHash,
calculateDeployAccountTransactionHash,
};
130 changes: 130 additions & 0 deletions modules/sdk-coin-starknet/src/lib/walletInitializationBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { BuildTransactionError } from '@bitgo/sdk-core';
import { BaseCoin as CoinConfig } from '@bitgo/statics';
import { TransactionBuilder } from './transactionBuilder';
import { Transaction } from './transaction';
import { StarknetTransactionData, StarknetTransactionType } from './iface';
import { OZ_ETH_ACCOUNT_CLASS_HASH } from './constants';
import utils from './utils';

/**
* Builds DEPLOY_ACCOUNT v3 transactions for counterfactual EthAccount activation.
*/
export class WalletInitializationBuilder extends TransactionBuilder {
protected _classHash: string = OZ_ETH_ACCOUNT_CLASS_HASH;
protected _constructorCalldata?: string[];
protected _contractAddressSalt?: string;

constructor(_coinConfig: Readonly<CoinConfig>) {
super(_coinConfig);
}

protected get transactionType(): StarknetTransactionType {
return StarknetTransactionType.DEPLOY_ACCOUNT;
}

/**
* Set deploy parameters from a secp256k1 public key (compressed or uncompressed).
* Derives counterfactual address, constructor calldata, and salt.
*/
public fromPublicKey(pubKey: string): this {
if (!utils.isValidPublicKey(pubKey)) {
throw new BuildTransactionError('Invalid pubKey, got: ' + pubKey);
}
const fullPublicKey = utils.getUncompressedPublicKey(pubKey);
const { address, constructorCalldata, salt } = utils.computeStarknetAddress(fullPublicKey);
this._constructorCalldata = constructorCalldata;
this._contractAddressSalt = salt;
return this.sender(address, pubKey);
}

public classHash(classHash: string): this {
if (!classHash || !utils.isValidAddress(classHash)) {
throw new BuildTransactionError('Invalid class hash, got: ' + classHash);
}
this._classHash = classHash;
return this;
}

/** @inheritdoc */
initBuilder(tx: Transaction): void {
super.initBuilder(tx);
const data = tx.starknetTransactionData;
if (data.classHash) {
this._classHash = data.classHash;
}
if (data.constructorCalldata) {
this._constructorCalldata = data.constructorCalldata;
}
if (data.contractAddressSalt) {
this._contractAddressSalt = data.contractAddressSalt;
}
}

/** @inheritdoc */
protected async buildImplementation(): Promise<Transaction> {
this.ensureDeployFields();

const contractAddress = this._sender as string;
const chainId = this._chainId as string;
const nonce = this._nonce as string;
const constructorCalldata = this._constructorCalldata as string[];
const contractAddressSalt = this._contractAddressSalt as string;

const transactionHash = utils.calculateDeployAccountTransactionHash({
contractAddress,
classHash: this._classHash,
constructorCalldata,
contractAddressSalt,
chainId,
nonce,
resourceBounds: this._resourceBounds,
tip: this._tip,
});

const data: StarknetTransactionData = {
senderAddress: contractAddress,
calls: [],
nonce,
chainId,
transactionType: StarknetTransactionType.DEPLOY_ACCOUNT,
resourceBounds: this._resourceBounds,
tip: this._tip,
transactionHash,
classHash: this._classHash,
constructorCalldata,
contractAddressSalt,
};

this._transaction.starknetTransactionData = data;
return this._transaction;
}

private ensureDeployFields(): void {
if (!this._sender) {
throw new BuildTransactionError('Sender (counterfactual address) is required');
}
if (this._publicKey && (!this._constructorCalldata || !this._contractAddressSalt)) {
this.fromPublicKey(this._publicKey);
}
if (!this._constructorCalldata || !this._contractAddressSalt) {
throw new BuildTransactionError(
'Deploy account requires public key (fromPublicKey) or explicit constructor calldata and salt'
);
}
const fullPublicKey = this._publicKey ? utils.getUncompressedPublicKey(this._publicKey) : undefined;
if (fullPublicKey) {
const derived = utils.computeStarknetAddress(fullPublicKey);
if (utils.normalizeAddress(derived.address) !== utils.normalizeAddress(this._sender)) {
throw new BuildTransactionError(
`Address does not match public key. Expected ${derived.address}, got ${this._sender}`
);
}
if (
derived.constructorCalldata.join(',') !== this._constructorCalldata.join(',') ||
derived.salt !== this._contractAddressSalt
) {
throw new BuildTransactionError('Constructor calldata or salt does not match public key');
}
}
}
}
Loading
Loading