diff --git a/modules/sdk-coin-starknet/src/lib/constants.ts b/modules/sdk-coin-starknet/src/lib/constants.ts index 398cdd249f..dc982980ad 100644 --- a/modules/sdk-coin-starknet/src/lib/constants.ts +++ b/modules/sdk-coin-starknet/src/lib/constants.ts @@ -6,6 +6,10 @@ export const OZ_ETH_ACCOUNT_CLASS_HASH = '0x3940bc18abf1df6bc540cabadb1cad9486c6 // STRK token contract (same on both mainnet and sepolia) export const STRK_TOKEN_CONTRACT = '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d'; +// UDC — same address on mainnet and Sepolia +export const UDC_ADDRESS = '0x041a78e741e5af2fec34b695679bc6891742439f7afb8484ecd7766661ad02bf'; +export const UDC_DEPLOY_ENTRYPOINT = 'deployContract'; + // felt252 max value (2^251 + 17 * 2^192 + 1) export const FELT_MAX = (1n << 251n) + 17n * (1n << 192n) + 1n; diff --git a/modules/sdk-coin-starknet/src/lib/iface.ts b/modules/sdk-coin-starknet/src/lib/iface.ts index ff859b524a..b91a8e78f3 100644 --- a/modules/sdk-coin-starknet/src/lib/iface.ts +++ b/modules/sdk-coin-starknet/src/lib/iface.ts @@ -78,6 +78,21 @@ export interface ParsedTransferData { tokenContract: string; } +export interface ParsedUdcDeployData { + classHash: string; + salt: string; + /** Must be false for BitGo address match (deployer=0). */ + unique: boolean; + constructorCalldata: string[]; + udcAddress: string; +} + +export interface UdcDeployParams { + classHash: string; + salt: string; + constructorCalldata: string[]; +} + export interface TxData { id?: string; sender: string; diff --git a/modules/sdk-coin-starknet/src/lib/index.ts b/modules/sdk-coin-starknet/src/lib/index.ts index 047e8281a1..91dec45e04 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 { UdcDeployBuilder } from './udcDeployBuilder'; export { WalletInitializationBuilder } from './walletInitializationBuilder'; export { TransactionBuilderFactory } from './transactionBuilderFactory'; export { Transaction } from './transaction'; diff --git a/modules/sdk-coin-starknet/src/lib/transaction.ts b/modules/sdk-coin-starknet/src/lib/transaction.ts index 24dd2b92e5..b7e166d688 100644 --- a/modules/sdk-coin-starknet/src/lib/transaction.ts +++ b/modules/sdk-coin-starknet/src/lib/transaction.ts @@ -14,7 +14,7 @@ import { StarknetTransactionExplanation, TxData, } from './iface'; -import utils, { compileExecuteCalldata, parseTransferCall } from './utils'; +import utils, { compileExecuteCalldata, parseTransferCall, parseUdcDeployCall } from './utils'; function resolveCompiledCalldata(data: StarknetTransactionData): string[] { if (data.compiledCalldata && data.compiledCalldata.length > 0) { @@ -104,18 +104,40 @@ export class Transaction extends BaseTransaction { if (!this._starknetTransactionData) { throw new InvalidTransactionError('Empty transaction'); } - const transfer = - this._starknetTransactionData.calls.length > 0 - ? parseTransferCall(this._starknetTransactionData.calls[0]) - : undefined; - return { + + const data = this._starknetTransactionData; + const result: TxData = { id: this._id, - sender: this._starknetTransactionData.senderAddress, - recipient: transfer?.recipient, - amount: transfer?.amount, - nonce: this._starknetTransactionData.nonce, + sender: data.senderAddress, + nonce: data.nonce, type: TransactionType.Send, }; + + const firstCall = data.calls[0]; + if (!firstCall) { + return result; + } + + const transfer = parseTransferCall(firstCall); + if (transfer) { + result.recipient = transfer.recipient; + result.amount = transfer.amount; + return result; + } + + const udcDeploy = parseUdcDeployCall(firstCall); + if (udcDeploy && !udcDeploy.unique) { + result.recipient = utils.calculateContractAddressFromHash( + udcDeploy.salt, + udcDeploy.classHash, + udcDeploy.constructorCalldata, + 0 + ); + result.amount = '0'; + result.type = TransactionType.ContractCall; + } + + return result; } /** @inheritDoc */ diff --git a/modules/sdk-coin-starknet/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-starknet/src/lib/transactionBuilderFactory.ts index e1916cff4f..c21452b35d 100644 --- a/modules/sdk-coin-starknet/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-starknet/src/lib/transactionBuilderFactory.ts @@ -3,8 +3,10 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { Transaction } from './transaction'; import { TransactionBuilder } from './transactionBuilder'; import { TransferBuilder } from './transferBuilder'; +import { UdcDeployBuilder } from './udcDeployBuilder'; import { WalletInitializationBuilder } from './walletInitializationBuilder'; import { StarknetTransactionType } from './iface'; +import utils from './utils'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -17,8 +19,13 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { await transaction.fromRawTransaction(rawTransaction); try { switch (transaction.starknetTransactionData.transactionType) { - case StarknetTransactionType.INVOKE: + case StarknetTransactionType.INVOKE: { + const calls = transaction.starknetTransactionData.calls || []; + if (calls.length === 1 && utils.isUdcDeployCall(calls[0])) { + return this.getUdcDeployBuilder(transaction); + } return this.getTransferBuilder(transaction); + } case StarknetTransactionType.DEPLOY_ACCOUNT: return this.getWalletInitializationBuilder(transaction); default: @@ -41,6 +48,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return TransactionBuilderFactory.initializeBuilder(tx, new TransferBuilder(this._coinConfig)); } + getUdcDeployBuilder(tx?: Transaction): UdcDeployBuilder { + return TransactionBuilderFactory.initializeBuilder(tx, new UdcDeployBuilder(this._coinConfig)); + } + /** @inheritdoc */ getWalletInitializationBuilder(tx?: Transaction): WalletInitializationBuilder { return TransactionBuilderFactory.initializeBuilder(tx, new WalletInitializationBuilder(this._coinConfig)); diff --git a/modules/sdk-coin-starknet/src/lib/udcDeployBuilder.ts b/modules/sdk-coin-starknet/src/lib/udcDeployBuilder.ts new file mode 100644 index 0000000000..90855d3938 --- /dev/null +++ b/modules/sdk-coin-starknet/src/lib/udcDeployBuilder.ts @@ -0,0 +1,139 @@ +import { BuildTransactionError } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from './transaction'; +import { StarknetCall, StarknetTransactionType, UdcDeployParams } from './iface'; +import { OZ_ETH_ACCOUNT_CLASS_HASH, UDC_ADDRESS, UDC_DEPLOY_ENTRYPOINT } from './constants'; +import utils from './utils'; + +/** + * INVOKE to UDC `deployContract` from a master wallet. + * Always uses unique=0 so the address matches `computeStarknetAddress` (deployer=0). + * `fromPublicKey` sets the *target* only — call `sender` with the master address separately. + */ +export class UdcDeployBuilder extends TransactionBuilder { + protected _classHash: string = OZ_ETH_ACCOUNT_CLASS_HASH; + protected _salt?: string; + protected _constructorCalldata?: string[]; + protected _targetAddress?: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + protected get transactionType(): StarknetTransactionType { + return StarknetTransactionType.INVOKE; + } + + /** Sets deploy params and derives the counterfactual target address. */ + public deployParams(params: UdcDeployParams): this { + if (!params.classHash || !utils.isValidAddress(params.classHash)) { + throw new BuildTransactionError('Invalid class hash, got: ' + params.classHash); + } + if (!params.salt || !utils.isValidAddress(params.salt)) { + throw new BuildTransactionError('Invalid salt, got: ' + params.salt); + } + if (!params.constructorCalldata || params.constructorCalldata.length === 0) { + throw new BuildTransactionError('Constructor calldata is required'); + } + for (const felt of params.constructorCalldata) { + // Limbs may be 0x0 (e.g. pubkey x/y halves); only require 0x-hex form. + if (!felt || !/^0x[0-9a-fA-F]+$/i.test(felt)) { + throw new BuildTransactionError('Invalid constructor calldata felt, got: ' + felt); + } + } + + this._classHash = params.classHash; + this._salt = params.salt; + this._constructorCalldata = params.constructorCalldata; + this._targetAddress = utils.calculateContractAddressFromHash( + params.salt, + params.classHash, + params.constructorCalldata, + 0 + ); + return this; + } + + /** Derive deploy params from the target user's pubkey. Does not set sender. */ + 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.deployParams({ + classHash: OZ_ETH_ACCOUNT_CLASS_HASH, + salt, + constructorCalldata, + }); + this._targetAddress = address; + return this; + } + + public getTargetAddress(): string | undefined { + return this._targetAddress; + } + + /** @inheritdoc */ + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + if (this._calls.length === 0) { + return; + } + const call = this._calls[0]; + if (!utils.isUdcDeployCall(call)) { + return; + } + const parsed = utils.parseUdcDeployCall(call); + if (!parsed) { + throw new BuildTransactionError('Invalid UDC deploy calldata'); + } + if (parsed.unique) { + throw new BuildTransactionError('UDC deploy with unique=true is not supported; BitGo requires unique=false'); + } + this.deployParams({ + classHash: parsed.classHash, + salt: parsed.salt, + constructorCalldata: parsed.constructorCalldata, + }); + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this.validateUdcDeploy(); + + const udcCall: StarknetCall = { + contractAddress: UDC_ADDRESS, + entrypoint: UDC_DEPLOY_ENTRYPOINT, + calldata: this.compileUdcDeployCalldata( + this._classHash, + this._salt as string, + this._constructorCalldata as string[] + ), + }; + + this._calls = [udcCall]; + + return super.buildImplementation(); + } + + /** Calldata: [classHash, salt, unique=0, ctor_len, ...ctorCalldata] */ + private compileUdcDeployCalldata(classHash: string, salt: string, constructorCalldata: string[]): string[] { + return [classHash, salt, '0x0', '0x' + BigInt(constructorCalldata.length).toString(16), ...constructorCalldata]; + } + + private validateUdcDeploy(): void { + if (!this._sender) { + throw new BuildTransactionError('Sender is required'); + } + if (!utils.isValidAddress(this._sender)) { + throw new BuildTransactionError(`Invalid sender address: ${this._sender}`); + } + if (!this._salt || !this._constructorCalldata || this._constructorCalldata.length === 0) { + throw new BuildTransactionError( + 'UDC deploy requires fromPublicKey() or deployParams({ classHash, salt, constructorCalldata })' + ); + } + } +} diff --git a/modules/sdk-coin-starknet/src/lib/utils.ts b/modules/sdk-coin-starknet/src/lib/utils.ts index 9c78750abd..bf534567c0 100644 --- a/modules/sdk-coin-starknet/src/lib/utils.ts +++ b/modules/sdk-coin-starknet/src/lib/utils.ts @@ -11,12 +11,15 @@ import { L1_GAS_NAME, L2_GAS_NAME, L1_DATA_GAS_NAME, + UDC_ADDRESS, + UDC_DEPLOY_ENTRYPOINT, } from './constants'; import { StarknetTransactionData, StarknetTransactionType, StarknetCall, ParsedTransferData, + ParsedUdcDeployData, InvokeTransactionHashParams, DeployAccountTransactionHashParams, } from './iface'; @@ -192,6 +195,39 @@ export function parseTransferCall(call: StarknetCall): ParsedTransferData | unde }; } +/** True if call targets UDC deployContract. */ +export function isUdcDeployCall(call: StarknetCall | undefined): boolean { + if (!call) { + return false; + } + return ( + call.entrypoint === UDC_DEPLOY_ENTRYPOINT && + normalizeAddress(call.contractAddress) === normalizeAddress(UDC_ADDRESS) && + call.calldata.length >= 4 + ); +} + +/** Parse UDC deployContract calldata: [classHash, salt, unique, ctor_len, ...ctor]. */ +export function parseUdcDeployCall(call: StarknetCall): ParsedUdcDeployData | undefined { + if (!isUdcDeployCall(call)) { + return undefined; + } + const classHash = call.calldata[0]; + const salt = call.calldata[1]; + const uniqueFelt = BigInt(call.calldata[2]); + const ctorLen = Number(BigInt(call.calldata[3])); + if (!Number.isFinite(ctorLen) || ctorLen < 0 || call.calldata.length < 4 + ctorLen) { + return undefined; + } + return { + classHash, + salt, + unique: uniqueFelt !== 0n, + constructorCalldata: call.calldata.slice(4, 4 + ctorLen), + udcAddress: call.contractAddress, + }; +} + /** * Generate a new secp256k1 key pair. */ @@ -389,6 +425,8 @@ export default { normalizeAddress, formatEthAccountSignature, parseTransferCall, + isUdcDeployCall, + parseUdcDeployCall, generateKeyPair, validateRawTransaction, encodeShortString, diff --git a/modules/sdk-coin-starknet/test/unit/transactionBuilder/transactionBuilderFactory.ts b/modules/sdk-coin-starknet/test/unit/transactionBuilder/transactionBuilderFactory.ts index cf8fb60a79..7993642f1d 100644 --- a/modules/sdk-coin-starknet/test/unit/transactionBuilder/transactionBuilderFactory.ts +++ b/modules/sdk-coin-starknet/test/unit/transactionBuilder/transactionBuilderFactory.ts @@ -13,6 +13,13 @@ describe('Starknet TransactionBuilderFactory', () => { }); }); + describe('getUdcDeployBuilder', () => { + it('should return a UDC deploy builder', () => { + const builder = new TransactionBuilderFactory(coinConfig).getUdcDeployBuilder(); + should.exist(builder); + }); + }); + describe('from', () => { it('should rebuild unsigned tx from raw hex', async () => { const builder = await new TransactionBuilderFactory(coinConfig).from(rawTx.transfer.unsigned); diff --git a/modules/sdk-coin-starknet/test/unit/udcDeployBuilder.ts b/modules/sdk-coin-starknet/test/unit/udcDeployBuilder.ts new file mode 100644 index 0000000000..35b831e4cf --- /dev/null +++ b/modules/sdk-coin-starknet/test/unit/udcDeployBuilder.ts @@ -0,0 +1,222 @@ +import should from 'should'; +import { coins } from '@bitgo/statics'; +import { TransactionType } from '@bitgo/sdk-core'; +import { TransactionBuilderFactory } from '../../src/lib/transactionBuilderFactory'; +import { Transaction } from '../../src/lib/transaction'; +import { UdcDeployBuilder } from '../../src/lib/udcDeployBuilder'; +import { Accounts, SandboxTransferData } from '../resources/starknet'; +import { OZ_ETH_ACCOUNT_CLASS_HASH, UDC_ADDRESS, UDC_DEPLOY_ENTRYPOINT } from '../../src/lib/constants'; +import utils from '../../src/lib/utils'; +import { StarknetTransactionType } from '../../src/lib/iface'; + +describe('Starknet UdcDeployBuilder', () => { + const coinConfig = coins.get('starknet'); + const master = Accounts.account1; + const target = Accounts.account2; + + const getBuilder = (): UdcDeployBuilder => new TransactionBuilderFactory(coinConfig).getUdcDeployBuilder(); + + function buildUdcRawHex( + overrides: { + unique?: string; + ctorLen?: string; + constructorCalldata?: string[]; + omitCtor?: boolean; + } = {} + ): string { + const derived = utils.computeStarknetAddress(utils.getUncompressedPublicKey(target.publicKey)); + const ctor = overrides.constructorCalldata ?? derived.constructorCalldata; + const calldata = overrides.omitCtor + ? [OZ_ETH_ACCOUNT_CLASS_HASH, derived.salt, overrides.unique ?? '0x0', overrides.ctorLen ?? '0x99'] + : [ + OZ_ETH_ACCOUNT_CLASS_HASH, + derived.salt, + overrides.unique ?? '0x0', + overrides.ctorLen ?? '0x' + BigInt(ctor.length).toString(16), + ...ctor, + ]; + return Buffer.from( + JSON.stringify({ + senderAddress: master.address, + calls: [ + { + contractAddress: UDC_ADDRESS, + entrypoint: UDC_DEPLOY_ENTRYPOINT, + calldata, + }, + ], + nonce: '0x0', + chainId: SandboxTransferData.chainId, + transactionType: StarknetTransactionType.INVOKE, + }), + 'utf-8' + ).toString('hex'); + } + + describe('Build UDC deploy transaction', () => { + it('should build from target public key and produce a transactionHash', async () => { + const builder = getBuilder(); + builder.sender(master.address).nonce('0x0').chainId(SandboxTransferData.chainId).fromPublicKey(target.publicKey); + + const expected = utils.computeStarknetAddress(utils.getUncompressedPublicKey(target.publicKey)); + builder.getTargetAddress()!.should.equal(expected.address); + utils.normalizeAddress(builder.getTargetAddress()!).should.equal(utils.normalizeAddress(target.address)); + + const tx = (await builder.build()) as Transaction; + const data = tx.starknetTransactionData; + + should.exist(data.transactionHash); + (data.transactionHash as string).should.startWith('0x'); + data.calls.length.should.equal(1); + data.calls[0].contractAddress.should.equal(UDC_ADDRESS); + data.calls[0].entrypoint.should.equal(UDC_DEPLOY_ENTRYPOINT); + data.calls[0].calldata[0].should.equal(OZ_ETH_ACCOUNT_CLASS_HASH); + data.calls[0].calldata[2].should.equal('0x0'); // unique=false + data.calls[0].calldata[3].should.equal('0x4'); // EthAccount ctor len + data.calls[0].calldata.slice(4).should.deepEqual(expected.constructorCalldata); + }); + + it('should keep derived address consistent with computeStarknetAddress', async () => { + const builder = getBuilder(); + builder.fromPublicKey(target.publicKey); + const derived = utils.computeStarknetAddress(utils.getUncompressedPublicKey(target.publicKey)); + builder.getTargetAddress()!.should.equal(derived.address); + derived.salt.should.equal( + utils.parseUdcDeployCall({ + contractAddress: UDC_ADDRESS, + entrypoint: UDC_DEPLOY_ENTRYPOINT, + calldata: [OZ_ETH_ACCOUNT_CLASS_HASH, derived.salt, '0x0', '0x4', ...derived.constructorCalldata], + })!.salt + ); + }); + + it('should include compiledCalldata with a single UDC call', async () => { + const builder = getBuilder(); + builder.sender(master.address).nonce('0x0').chainId(SandboxTransferData.chainId).fromPublicKey(target.publicKey); + + const tx = (await builder.build()) as Transaction; + const compiled = tx.starknetTransactionData.compiledCalldata as string[]; + compiled[0].should.equal('0x1'); + utils.normalizeAddress(compiled[1]).should.equal(utils.normalizeAddress(UDC_ADDRESS)); + }); + + it('should round-trip through toInternalHex and from()', async () => { + const builder = getBuilder(); + builder.sender(master.address).nonce('0x0').chainId(SandboxTransferData.chainId).fromPublicKey(target.publicKey); + + const tx = (await builder.build()) as Transaction; + const internalHex = tx.toInternalHex(); + + const builder2 = await new TransactionBuilderFactory(coinConfig).from(internalHex); + builder2.should.be.instanceof(UdcDeployBuilder); + const tx2 = (await builder2.build()) as Transaction; + + tx2.signableHex.should.equal(tx.signableHex); + tx2.id.should.equal(tx.id); + (builder2 as UdcDeployBuilder).getTargetAddress()!.should.equal(target.address); + const parsed = utils.parseUdcDeployCall(tx2.starknetTransactionData.calls[0]); + should.exist(parsed); + parsed!.unique.should.equal(false); + utils + .normalizeAddress( + utils.calculateContractAddressFromHash(parsed!.salt, parsed!.classHash, parsed!.constructorCalldata, 0) + ) + .should.equal(utils.normalizeAddress(target.address)); + }); + + it('toBroadcastFormat should return Starknet RPC-ready INVOKE JSON', async () => { + const builder = getBuilder(); + builder.sender(master.address).nonce('0x0').chainId(SandboxTransferData.chainId).fromPublicKey(target.publicKey); + + const tx = (await builder.build()) as Transaction; + const parsed = JSON.parse(tx.toBroadcastFormat()); + + parsed.type.should.equal('INVOKE'); + parsed.version.should.equal('0x3'); + parsed.sender_address.should.equal(master.address); + parsed.calldata.should.be.Array(); + parsed.calldata[0].should.equal('0x1'); + }); + + it('should accept explicit deployParams', async () => { + const derived = utils.computeStarknetAddress(utils.getUncompressedPublicKey(target.publicKey)); + const builder = getBuilder(); + builder.sender(master.address).nonce('0x0').chainId(SandboxTransferData.chainId).deployParams({ + classHash: OZ_ETH_ACCOUNT_CLASS_HASH, + salt: derived.salt, + constructorCalldata: derived.constructorCalldata, + }); + + builder.getTargetAddress()!.should.equal(derived.address); + const tx = (await builder.build()) as Transaction; + should.exist(tx.starknetTransactionData.transactionHash); + }); + + it('toJson/explainTransaction should expose target address with amount 0', async () => { + const builder = getBuilder(); + builder.sender(master.address).nonce('0x0').chainId(SandboxTransferData.chainId).fromPublicKey(target.publicKey); + + const tx = (await builder.build()) as Transaction; + const json = tx.toJson(); + json.type.should.equal(TransactionType.ContractCall); + json.sender.should.equal(master.address); + utils.normalizeAddress(json.recipient!).should.equal(utils.normalizeAddress(target.address)); + json.amount!.should.equal('0'); + + const explained = tx.explainTransaction(); + explained.outputs.length.should.equal(1); + utils.normalizeAddress(explained.outputs[0].address).should.equal(utils.normalizeAddress(target.address)); + explained.outputs[0].amount.should.equal('0'); + explained.type!.should.equal(TransactionType.ContractCall); + }); + }); + + describe('Validation', () => { + it('should reject build without sender', async () => { + const builder = getBuilder(); + builder.nonce('0x0').chainId(SandboxTransferData.chainId).fromPublicKey(target.publicKey); + await builder.build().should.be.rejectedWith(/[Ss]ender/); + }); + + it('should reject build without deploy params', async () => { + const builder = getBuilder(); + builder.sender(master.address).nonce('0x0').chainId(SandboxTransferData.chainId); + await builder.build().should.be.rejectedWith(/UDC deploy requires/); + }); + + it('should reject invalid target public key', () => { + const builder = getBuilder(); + (() => builder.fromPublicKey('invalid')).should.throw(/[Ii]nvalid/); + }); + + it('should reject empty constructor calldata in deployParams', () => { + const builder = getBuilder(); + (() => + builder.deployParams({ + classHash: OZ_ETH_ACCOUNT_CLASS_HASH, + salt: '0x1', + constructorCalldata: [], + })).should.throw(/Constructor calldata/); + }); + + it('should reject salt 0x0', () => { + const builder = getBuilder(); + (() => + builder.deployParams({ + classHash: OZ_ETH_ACCOUNT_CLASS_HASH, + salt: '0x0', + constructorCalldata: ['0x1', '0x2', '0x3', '0x4'], + })).should.throw(/Invalid salt/); + }); + + it('should reject unique=true on rehydration via from()', async () => { + const hex = buildUdcRawHex({ unique: '0x1' }); + await new TransactionBuilderFactory(coinConfig).from(hex).should.be.rejectedWith(/unique=true/); + }); + + it('should reject malformed UDC calldata (bad ctor_len) on from()', async () => { + const hex = buildUdcRawHex({ omitCtor: true, ctorLen: '0x99' }); + await new TransactionBuilderFactory(coinConfig).from(hex).should.be.rejectedWith(/Invalid UDC deploy calldata/); + }); + }); +}); diff --git a/modules/sdk-coin-starknet/test/unit/utils.ts b/modules/sdk-coin-starknet/test/unit/utils.ts index d6db407750..2f20444f43 100644 --- a/modules/sdk-coin-starknet/test/unit/utils.ts +++ b/modules/sdk-coin-starknet/test/unit/utils.ts @@ -4,12 +4,14 @@ import utils, { compileExecuteCalldata, calculateInvokeTransactionHash, calculateDeployAccountTransactionHash, + parseUdcDeployCall, + isUdcDeployCall, } 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'; +import { MASK_128, OZ_ETH_ACCOUNT_CLASS_HASH, UDC_ADDRESS, UDC_DEPLOY_ENTRYPOINT } from '../../src/lib/constants'; +import should from 'should'; describe('Starknet Utils', () => { describe('isValidAddress', () => { @@ -32,6 +34,10 @@ describe('Starknet Utils', () => { it('should reject address without 0x prefix', () => { utils.isValidAddress('04a1f29b8b8e3d3c9f6c9b7a8d2e1f0c5b4a3d2e1f0c5b4a3d2e1f0c5b4a3d2e').should.equal(false); }); + + it('should reject zero (addresses must be non-zero)', () => { + utils.isValidAddress('0x0').should.equal(false); + }); }); describe('isValidPublicKey', () => { @@ -306,4 +312,54 @@ describe('Starknet Utils', () => { hash.should.equal(tx.starknetTransactionData.transactionHash); }); }); + + describe('parseUdcDeployCall / isUdcDeployCall', () => { + it('should parse a valid UDC deploy call', () => { + const derived = utils.computeStarknetAddress(utils.getUncompressedPublicKey(Accounts.account2.publicKey)); + const call = { + contractAddress: UDC_ADDRESS, + entrypoint: UDC_DEPLOY_ENTRYPOINT, + calldata: [OZ_ETH_ACCOUNT_CLASS_HASH, derived.salt, '0x0', '0x4', ...derived.constructorCalldata], + }; + isUdcDeployCall(call).should.equal(true); + const parsed = parseUdcDeployCall(call); + should.exist(parsed); + parsed!.unique.should.equal(false); + parsed!.classHash.should.equal(OZ_ETH_ACCOUNT_CLASS_HASH); + parsed!.salt.should.equal(derived.salt); + parsed!.constructorCalldata.should.deepEqual(derived.constructorCalldata); + }); + + it('should reject a transfer call', () => { + const call = { + contractAddress: SandboxTransferData.tokenContract, + entrypoint: 'transfer', + calldata: [Accounts.account2.address, '0x1', '0x0'], + }; + isUdcDeployCall(call).should.equal(false); + should.equal(parseUdcDeployCall(call), undefined); + }); + + it('should parse unique=true but leave unique flag set', () => { + const derived = utils.computeStarknetAddress(utils.getUncompressedPublicKey(Accounts.account2.publicKey)); + const call = { + contractAddress: UDC_ADDRESS, + entrypoint: UDC_DEPLOY_ENTRYPOINT, + calldata: [OZ_ETH_ACCOUNT_CLASS_HASH, derived.salt, '0x1', '0x4', ...derived.constructorCalldata], + }; + const parsed = parseUdcDeployCall(call); + should.exist(parsed); + parsed!.unique.should.equal(true); + }); + + it('should return undefined when ctor_len exceeds remaining calldata', () => { + const call = { + contractAddress: UDC_ADDRESS, + entrypoint: UDC_DEPLOY_ENTRYPOINT, + calldata: [OZ_ETH_ACCOUNT_CLASS_HASH, '0x1', '0x0', '0x99'], + }; + isUdcDeployCall(call).should.equal(true); + should.equal(parseUdcDeployCall(call), undefined); + }); + }); });