diff --git a/modules/sdk-coin-starknet/src/lib/constants.ts b/modules/sdk-coin-starknet/src/lib/constants.ts index 3fc4d016b9..877b83c45a 100644 --- a/modules/sdk-coin-starknet/src/lib/constants.ts +++ b/modules/sdk-coin-starknet/src/lib/constants.ts @@ -1,3 +1,5 @@ +import { StarknetResourceBounds } from './iface'; + // OZ EthAccountUpgradeable class hash (v0.17.0) — secp256k1 signature verification export const OZ_ETH_ACCOUNT_CLASS_HASH = '0x3940bc18abf1df6bc540cabadb1cad9486c6803b95801e57b6153ae21abfe06'; @@ -28,3 +30,12 @@ export const TRANSACTION_VERSION_3 = 3n; 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" + +/** Default v3 resource bounds (matches TransactionBuilder defaults). */ +export 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' }, + }; +} diff --git a/modules/sdk-coin-starknet/src/lib/transaction.ts b/modules/sdk-coin-starknet/src/lib/transaction.ts index 49c142f104..d18ee28a55 100644 --- a/modules/sdk-coin-starknet/src/lib/transaction.ts +++ b/modules/sdk-coin-starknet/src/lib/transaction.ts @@ -6,8 +6,29 @@ import { InvalidTransactionError, } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; -import { StarknetTransactionData, StarknetTransactionType, StarknetTransactionExplanation, TxData } from './iface'; -import utils, { parseTransferCall } from './utils'; +import { defaultResourceBounds } from './constants'; +import { + StarknetResourceBounds, + StarknetTransactionData, + StarknetTransactionType, + StarknetTransactionExplanation, + TxData, +} from './iface'; +import utils, { compileExecuteCalldata, parseTransferCall } from './utils'; + +function resolveCompiledCalldata(data: StarknetTransactionData): string[] { + if (data.compiledCalldata && data.compiledCalldata.length > 0) { + return data.compiledCalldata; + } + if (data.calls.length > 0) { + return compileExecuteCalldata(data.calls); + } + throw new InvalidTransactionError('Missing calldata: no compiledCalldata or calls'); +} + +function resolveResourceBounds(data: StarknetTransactionData): StarknetResourceBounds { + return data.resourceBounds ?? defaultResourceBounds(); +} export class Transaction extends BaseTransaction { protected _starknetTransactionData!: StarknetTransactionData; @@ -116,14 +137,35 @@ export class Transaction extends BaseTransaction { }; } - /** @inheritdoc */ + /** Hex-encoded internal JSON — used by WP for round-trip via fromRawTransaction. */ + toInternalHex(): string { + const data = this._starknetTransactionData; + if (!data) { + throw new InvalidTransactionError('Empty transaction'); + } + return Buffer.from(JSON.stringify(data), 'utf-8').toString('hex'); + } + + /** @inheritdoc — returns Starknet RPC-ready JSON string for starknet_addInvokeTransaction. */ toBroadcastFormat(): string { const data = this._starknetTransactionData; if (!data) { throw new InvalidTransactionError('Empty transaction'); } - const json = JSON.stringify(data); - return Buffer.from(json, 'utf-8').toString('hex'); + return JSON.stringify({ + type: 'INVOKE', + version: '0x3', + sender_address: data.senderAddress, + calldata: resolveCompiledCalldata(data), + signature: data.signature || [], + nonce: data.nonce, + resource_bounds: resolveResourceBounds(data), + tip: data.tip || '0x0', + paymaster_data: [], + account_deployment_data: [], + nonce_data_availability_mode: 'L1', + fee_data_availability_mode: 'L1', + }); } /** @inheritdoc */ diff --git a/modules/sdk-coin-starknet/src/lib/transactionBuilder.ts b/modules/sdk-coin-starknet/src/lib/transactionBuilder.ts index 148ef42b95..ad985d2683 100644 --- a/modules/sdk-coin-starknet/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-starknet/src/lib/transactionBuilder.ts @@ -9,17 +9,10 @@ import { import { BaseCoin as CoinConfig } from '@bitgo/statics'; import BigNumber from 'bignumber.js'; import { StarknetTransactionData, StarknetTransactionType, StarknetCall, StarknetResourceBounds } from './iface'; +import { defaultResourceBounds } from './constants'; 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; diff --git a/modules/sdk-coin-starknet/test/unit/transaction.ts b/modules/sdk-coin-starknet/test/unit/transaction.ts index df0be2f3fc..350423b25b 100644 --- a/modules/sdk-coin-starknet/test/unit/transaction.ts +++ b/modules/sdk-coin-starknet/test/unit/transaction.ts @@ -1,7 +1,7 @@ import should from 'should'; import { coins } from '@bitgo/statics'; import { Transaction } from '../../src/lib/transaction'; -import { rawTx, Accounts } from '../resources/starknet'; +import { Accounts, rawTx } from '../resources/starknet'; describe('Starknet Transaction', () => { describe('Parse unsigned transaction', () => { @@ -42,14 +42,41 @@ describe('Starknet Transaction', () => { }); describe('toBroadcastFormat', () => { - it('should produce non-empty broadcast format for unsigned tx', async () => { + it('should return Starknet RPC-ready JSON string', async () => { const coinConfig = coins.get('starknet'); const tx = new Transaction(coinConfig); await tx.fromRawTransaction(rawTx.transfer.unsigned); const payload = tx.toBroadcastFormat(); should.exist(payload); - payload.length.should.be.greaterThan(0); + const parsed = JSON.parse(payload); + parsed.type.should.equal('INVOKE'); + parsed.version.should.equal('0x3'); + parsed.should.have.property('sender_address'); + parsed.sender_address.should.equal(Accounts.account1.address); + parsed.calldata.should.be.Array().and.not.empty(); + parsed.calldata[0].should.equal('0x1'); + parsed.should.have.property('nonce'); + parsed.resource_bounds.should.have.properties(['l2_gas', 'l1_gas', 'l1_data_gas']); + parsed.resource_bounds.l2_gas.should.have.properties(['max_amount', 'max_price_per_unit']); + parsed.nonce_data_availability_mode.should.equal('L1'); + parsed.fee_data_availability_mode.should.equal('L1'); + }); + }); + + describe('toInternalHex', () => { + it('should produce hex-encoded internal JSON', async () => { + const coinConfig = coins.get('starknet'); + const tx = new Transaction(coinConfig); + await tx.fromRawTransaction(rawTx.transfer.unsigned); + + const hex = tx.toInternalHex(); + should.exist(hex); + const json = JSON.parse(Buffer.from(hex, 'hex').toString('utf-8')); + json.should.have.property('senderAddress'); + json.should.have.property('calls'); + json.should.have.property('chainId'); + json.should.have.property('transactionType'); }); }); }); diff --git a/modules/sdk-coin-starknet/test/unit/transferBuilder.ts b/modules/sdk-coin-starknet/test/unit/transferBuilder.ts index ff71e8b2e2..f7278228ad 100644 --- a/modules/sdk-coin-starknet/test/unit/transferBuilder.ts +++ b/modules/sdk-coin-starknet/test/unit/transferBuilder.ts @@ -161,7 +161,7 @@ describe('Starknet TransferBuilder', () => { tx1.signableHex.should.not.equal(tx2.signableHex); }); - it('should round-trip through toBroadcastFormat and fromRawTransaction', async () => { + it('should round-trip through toInternalHex and fromRawTransaction', async () => { const factory = new TransactionBuilderFactory(coinConfig); const builder = factory.getTransferBuilder(); @@ -173,15 +173,40 @@ describe('Starknet TransferBuilder', () => { .amount('1000000000000000000'); const tx = (await builder.build()) as Transaction; - const broadcastHex = tx.toBroadcastFormat(); + const internalHex = tx.toInternalHex(); const factory2 = new TransactionBuilderFactory(coinConfig); - const builder2 = await factory2.from(broadcastHex); - const tx2 = (await builder2.build()) as Transaction as Transaction; + const builder2 = await factory2.from(internalHex); + const tx2 = (await builder2.build()) as Transaction; tx2.signableHex.should.equal(tx.signableHex); tx2.id.should.equal(tx.id); }); + + it('toBroadcastFormat should return Starknet RPC-ready JSON', 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 broadcast = tx.toBroadcastFormat(); + const parsed = JSON.parse(broadcast); + + parsed.type.should.equal('INVOKE'); + parsed.version.should.equal('0x3'); + parsed.sender_address.should.equal(Accounts.account1.address); + parsed.calldata.should.be.Array(); + 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'); + }); }); describe('Validation', () => {