From 95e1e8408001179275f6e9a0592fae496f306fa0 Mon Sep 17 00:00:00 2001 From: Shubham Damkondwar Date: Wed, 10 Jun 2026 08:58:44 +0530 Subject: [PATCH] feat(sdk-coin-starknet): implement deploy account transaction support CECHO-927 TICKET: CECHO-927 --- .../sdk-coin-starknet/src/lib/constants.ts | 3 + modules/sdk-coin-starknet/src/lib/iface.ts | 20 +++ modules/sdk-coin-starknet/src/lib/index.ts | 1 + .../sdk-coin-starknet/src/lib/transaction.ts | 40 ++++- .../src/lib/transactionBuilderFactory.ts | 9 +- modules/sdk-coin-starknet/src/lib/utils.ts | 71 ++++++++- .../src/lib/walletInitializationBuilder.ts | 130 ++++++++++++++++ modules/sdk-coin-starknet/test/unit/utils.ts | 43 ++++++ .../test/unit/walletInitializationBuilder.ts | 146 ++++++++++++++++++ 9 files changed, 456 insertions(+), 7 deletions(-) create mode 100644 modules/sdk-coin-starknet/src/lib/walletInitializationBuilder.ts create mode 100644 modules/sdk-coin-starknet/test/unit/walletInitializationBuilder.ts diff --git a/modules/sdk-coin-starknet/src/lib/constants.ts b/modules/sdk-coin-starknet/src/lib/constants.ts index 877b83c45a..398cdd249f 100644 --- a/modules/sdk-coin-starknet/src/lib/constants.ts +++ b/modules/sdk-coin-starknet/src/lib/constants.ts @@ -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; diff --git a/modules/sdk-coin-starknet/src/lib/iface.ts b/modules/sdk-coin-starknet/src/lib/iface.ts index 98c8746218..3614a410ae 100644 --- a/modules/sdk-coin-starknet/src/lib/iface.ts +++ b/modules/sdk-coin-starknet/src/lib/iface.ts @@ -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 { @@ -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; diff --git a/modules/sdk-coin-starknet/src/lib/index.ts b/modules/sdk-coin-starknet/src/lib/index.ts index 839446d4bd..047e8281a1 100644 --- a/modules/sdk-coin-starknet/src/lib/index.ts +++ b/modules/sdk-coin-starknet/src/lib/index.ts @@ -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 }; diff --git a/modules/sdk-coin-starknet/src/lib/transaction.ts b/modules/sdk-coin-starknet/src/lib/transaction.ts index d18ee28a55..95466f8c45 100644 --- a/modules/sdk-coin-starknet/src/lib/transaction.ts +++ b/modules/sdk-coin-starknet/src/lib/transaction.ts @@ -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) { @@ -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, @@ -165,7 +199,7 @@ export class Transaction extends BaseTransaction { account_deployment_data: [], nonce_data_availability_mode: 'L1', fee_data_availability_mode: 'L1', - }); + }; } /** @inheritdoc */ diff --git a/modules/sdk-coin-starknet/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-starknet/src/lib/transactionBuilderFactory.ts index de691811d8..e1916cff4f 100644 --- a/modules/sdk-coin-starknet/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-starknet/src/lib/transactionBuilderFactory.ts @@ -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 { @@ -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'); } @@ -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)); } } diff --git a/modules/sdk-coin-starknet/src/lib/utils.ts b/modules/sdk-coin-starknet/src/lib/utils.ts index 6883bfc9d8..9c78750abd 100644 --- a/modules/sdk-coin-starknet/src/lib/utils.ts +++ b/modules/sdk-coin-starknet/src/lib/utils.ts @@ -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'; /** @@ -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'); + } + } } /** @@ -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, @@ -327,4 +395,5 @@ export default { getSelectorFromName, compileExecuteCalldata, calculateInvokeTransactionHash, + calculateDeployAccountTransactionHash, }; diff --git a/modules/sdk-coin-starknet/src/lib/walletInitializationBuilder.ts b/modules/sdk-coin-starknet/src/lib/walletInitializationBuilder.ts new file mode 100644 index 0000000000..85555aa0b9 --- /dev/null +++ b/modules/sdk-coin-starknet/src/lib/walletInitializationBuilder.ts @@ -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) { + 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 { + 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'); + } + } + } +} diff --git a/modules/sdk-coin-starknet/test/unit/utils.ts b/modules/sdk-coin-starknet/test/unit/utils.ts index 3b1565764a..d6db407750 100644 --- a/modules/sdk-coin-starknet/test/unit/utils.ts +++ b/modules/sdk-coin-starknet/test/unit/utils.ts @@ -3,7 +3,10 @@ import utils, { getSelectorFromName, compileExecuteCalldata, calculateInvokeTransactionHash, + calculateDeployAccountTransactionHash, } from '../../src/lib/utils'; +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory } from '../../src/lib/transactionBuilderFactory'; import { Accounts, SandboxTransferData, KnownGoodInvokeTx } from '../resources/starknet'; import { MASK_128 } from '../../src/lib/constants'; import 'should'; @@ -263,4 +266,44 @@ describe('Starknet Utils', () => { hash.should.equal(tv.expectedTxHash); }); }); + + describe('calculateDeployAccountTransactionHash', () => { + it('should be deterministic for the same deploy inputs', () => { + const fullPub = utils.getUncompressedPublicKey(Accounts.account1.publicKey); + const { address, constructorCalldata, salt } = utils.computeStarknetAddress(fullPub); + const params = { + contractAddress: address, + classHash: '0x3940bc18abf1df6bc540cabadb1cad9486c6803b95801e57b6153ae21abfe06', + constructorCalldata, + contractAddressSalt: salt, + chainId: SandboxTransferData.chainId, + nonce: '0x0', + resourceBounds: SandboxTransferData.resourceBounds, + tip: '0x0', + }; + const hash1 = calculateDeployAccountTransactionHash(params); + const hash2 = calculateDeployAccountTransactionHash(params); + hash1.should.equal(hash2); + hash1.should.startWith('0x'); + }); + + it('should match hash from WalletInitializationBuilder build', async () => { + const factory = new TransactionBuilderFactory(coins.get('starknet')); + const builder = factory.getWalletInitializationBuilder(); + builder.fromPublicKey(Accounts.account1.publicKey).nonce('0x0').chainId(SandboxTransferData.chainId); + const tx = (await builder.build()) as import('../../src/lib/transaction').Transaction; + const fullPub = utils.getUncompressedPublicKey(Accounts.account1.publicKey); + const { address, constructorCalldata, salt } = utils.computeStarknetAddress(fullPub); + const hash = calculateDeployAccountTransactionHash({ + contractAddress: address, + classHash: '0x3940bc18abf1df6bc540cabadb1cad9486c6803b95801e57b6153ae21abfe06', + constructorCalldata, + contractAddressSalt: salt, + chainId: SandboxTransferData.chainId, + nonce: '0x0', + resourceBounds: SandboxTransferData.resourceBounds, + }); + hash.should.equal(tx.starknetTransactionData.transactionHash); + }); + }); }); diff --git a/modules/sdk-coin-starknet/test/unit/walletInitializationBuilder.ts b/modules/sdk-coin-starknet/test/unit/walletInitializationBuilder.ts new file mode 100644 index 0000000000..0998a67c11 --- /dev/null +++ b/modules/sdk-coin-starknet/test/unit/walletInitializationBuilder.ts @@ -0,0 +1,146 @@ +import should from 'should'; +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory } from '../../src/lib/transactionBuilderFactory'; +import { Transaction } from '../../src/lib/transaction'; +import { StarknetTransactionType } from '../../src/lib/iface'; +import { Accounts, SandboxTransferData } from '../resources/starknet'; +import { OZ_ETH_ACCOUNT_CLASS_HASH } from '../../src/lib/constants'; + +describe('Starknet WalletInitializationBuilder', () => { + const coinConfig = coins.get('starknet'); + const chainId = SandboxTransferData.chainId; + + describe('Build deploy account transaction', () => { + it('should build DEPLOY_ACCOUNT and produce a transactionHash', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getWalletInitializationBuilder(); + + builder.fromPublicKey(Accounts.account1.publicKey).nonce('0x0').chainId(chainId); + + const tx = (await builder.build()) as Transaction; + const data = tx.starknetTransactionData; + + data.transactionType.should.equal(StarknetTransactionType.DEPLOY_ACCOUNT); + should.exist(data.transactionHash); + (data.transactionHash as string).should.startWith('0x'); + data.senderAddress.should.equal(Accounts.account1.address); + (data.classHash as string).should.equal(OZ_ETH_ACCOUNT_CLASS_HASH); + should.exist(data.constructorCalldata); + should.exist(data.contractAddressSalt); + data.calls.should.have.length(0); + }); + + it('should set signableHex from the Poseidon deploy hash', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getWalletInitializationBuilder(); + + builder.fromPublicKey(Accounts.account1.publicKey).nonce('0x0').chainId(chainId); + + 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 accounts', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + + const builder1 = factory.getWalletInitializationBuilder(); + builder1.fromPublicKey(Accounts.account1.publicKey).nonce('0x0').chainId(chainId); + const tx1 = (await builder1.build()) as Transaction; + + const builder2 = factory.getWalletInitializationBuilder(); + builder2.fromPublicKey(Accounts.account2.publicKey).nonce('0x0').chainId(chainId); + const tx2 = (await builder2.build()) as Transaction; + + tx1.signableHex.should.not.equal(tx2.signableHex); + }); + + it('should round-trip through toInternalHex and factory.from', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getWalletInitializationBuilder(); + + builder.fromPublicKey(Accounts.account1.publicKey).nonce('0x0').chainId(chainId); + + const tx = (await builder.build()) as Transaction; + const internalHex = tx.toInternalHex(); + + const factory2 = new TransactionBuilderFactory(coinConfig); + const builder2 = await factory2.from(internalHex); + const tx2 = (await builder2.build()) as Transaction; + + tx2.signableHex.should.equal(tx.signableHex); + tx2.starknetTransactionData.transactionType.should.equal(StarknetTransactionType.DEPLOY_ACCOUNT); + }); + + it('toBroadcastFormat should return DEPLOY_ACCOUNT RPC JSON', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getWalletInitializationBuilder(); + + builder.fromPublicKey(Accounts.account1.publicKey).nonce('0x0').chainId(chainId); + + const tx = (await builder.build()) as Transaction; + const broadcast = tx.toBroadcastFormat(); + const parsed = JSON.parse(broadcast); + + parsed.type.should.equal('DEPLOY_ACCOUNT'); + parsed.version.should.equal('0x3'); + parsed.sender_address.should.equal(Accounts.account1.address); + parsed.class_hash.should.equal(OZ_ETH_ACCOUNT_CLASS_HASH); + parsed.constructor_calldata.should.be.Array().and.not.empty(); + parsed.contract_address_salt.should.startWith('0x'); + parsed.nonce.should.equal('0x0'); + parsed.resource_bounds.should.have.property('l2_gas'); + parsed.nonce_data_availability_mode.should.equal('L1'); + parsed.fee_data_availability_mode.should.equal('L1'); + parsed.should.not.have.property('calldata'); + parsed.should.not.have.property('account_deployment_data'); + }); + }); + + describe('Validation', () => { + it('should reject build without public key or deploy fields', async () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getWalletInitializationBuilder(); + builder.sender(Accounts.account1.address).nonce('0x0').chainId(chainId); + + await builder.build().should.be.rejectedWith(/public key|constructor calldata/i); + }); + + it('should reject mismatched address and public key', () => { + const factory = new TransactionBuilderFactory(coinConfig); + const builder = factory.getWalletInitializationBuilder(); + (() => + builder + .fromPublicKey(Accounts.account1.publicKey) + .sender(Accounts.account2.address, Accounts.account1.publicKey)).should.throw(/[Aa]ddress/); + }); + }); +}); + +describe('Starknet deploy account RPC wire format (live Sepolia)', () => { + it('should reach node validation (not param parse error) for unsigned deploy', async function (this: Mocha.Context) { + this.timeout(15000); + const factory = new TransactionBuilderFactory(coins.get('starknet')); + const builder = factory.getWalletInitializationBuilder(); + builder.fromPublicKey(Accounts.account1.publicKey).nonce('0x0').chainId(SandboxTransferData.chainId); + const tx = (await builder.build()) as Transaction; + const body = { + jsonrpc: '2.0', + method: 'starknet_addDeployAccountTransaction', + params: [JSON.parse(tx.toBroadcastFormat())], + id: 1, + }; + + const response = await fetch('https://api.cartridge.gg/x/starknet/sepolia', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const json = (await response.json()) as { error?: { message?: string } }; + + should.exist(json.error); + const msg = json.error?.message || ''; + msg.should.not.match(/parsing params|EOF/i); + msg.should.match(/signature|Validate|nonce|fee|resource|Invalid params/i); + }); +});