From c77dc954686e6c2f72c0c56dc1628b5a009de983 Mon Sep 17 00:00:00 2001 From: Shubham Damkondwar Date: Fri, 15 May 2026 19:49:34 +0530 Subject: [PATCH] feat(sdk-coin-starknet): implement Starknet SDK module CECHO-924 Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/sdk-coin-starknet/.mocharc.yml | 8 + modules/sdk-coin-starknet/package.json | 59 ++++++ modules/sdk-coin-starknet/src/index.ts | 4 + .../sdk-coin-starknet/src/lib/constants.ts | 33 ++++ modules/sdk-coin-starknet/src/lib/iface.ts | 81 ++++++++ modules/sdk-coin-starknet/src/lib/index.ts | 9 + modules/sdk-coin-starknet/src/lib/keyPair.ts | 48 +++++ .../sdk-coin-starknet/src/lib/transaction.ts | 120 ++++++++++++ .../src/lib/transactionBuilder.ts | 159 ++++++++++++++++ .../src/lib/transactionBuilderFactory.ts | 45 +++++ .../src/lib/transferBuilder.ts | 55 ++++++ modules/sdk-coin-starknet/src/lib/utils.ts | 167 ++++++++++++++++ modules/sdk-coin-starknet/src/register.ts | 8 + modules/sdk-coin-starknet/src/starknet.ts | 180 ++++++++++++++++++ modules/sdk-coin-starknet/src/tstarknet.ts | 37 ++++ .../test/resources/starknet.ts | 93 +++++++++ .../sdk-coin-starknet/test/unit/keyPair.ts | 58 ++++++ .../sdk-coin-starknet/test/unit/starknet.ts | 87 +++++++++ .../test/unit/transaction.ts | 55 ++++++ .../transactionBuilderFactory.ts | 76 ++++++++ modules/sdk-coin-starknet/test/unit/utils.ts | 81 ++++++++ modules/sdk-coin-starknet/tsconfig.json | 16 ++ 22 files changed, 1479 insertions(+) create mode 100644 modules/sdk-coin-starknet/.mocharc.yml create mode 100644 modules/sdk-coin-starknet/package.json create mode 100644 modules/sdk-coin-starknet/src/index.ts create mode 100644 modules/sdk-coin-starknet/src/lib/constants.ts create mode 100644 modules/sdk-coin-starknet/src/lib/iface.ts create mode 100644 modules/sdk-coin-starknet/src/lib/index.ts create mode 100644 modules/sdk-coin-starknet/src/lib/keyPair.ts create mode 100644 modules/sdk-coin-starknet/src/lib/transaction.ts create mode 100644 modules/sdk-coin-starknet/src/lib/transactionBuilder.ts create mode 100644 modules/sdk-coin-starknet/src/lib/transactionBuilderFactory.ts create mode 100644 modules/sdk-coin-starknet/src/lib/transferBuilder.ts create mode 100644 modules/sdk-coin-starknet/src/lib/utils.ts create mode 100644 modules/sdk-coin-starknet/src/register.ts create mode 100644 modules/sdk-coin-starknet/src/starknet.ts create mode 100644 modules/sdk-coin-starknet/src/tstarknet.ts create mode 100644 modules/sdk-coin-starknet/test/resources/starknet.ts create mode 100644 modules/sdk-coin-starknet/test/unit/keyPair.ts create mode 100644 modules/sdk-coin-starknet/test/unit/starknet.ts create mode 100644 modules/sdk-coin-starknet/test/unit/transaction.ts create mode 100644 modules/sdk-coin-starknet/test/unit/transactionBuilder/transactionBuilderFactory.ts create mode 100644 modules/sdk-coin-starknet/test/unit/utils.ts create mode 100644 modules/sdk-coin-starknet/tsconfig.json diff --git a/modules/sdk-coin-starknet/.mocharc.yml b/modules/sdk-coin-starknet/.mocharc.yml new file mode 100644 index 0000000000..d84fbf5b25 --- /dev/null +++ b/modules/sdk-coin-starknet/.mocharc.yml @@ -0,0 +1,8 @@ +require: 'tsx' +timeout: 120000 +reporter: 'min' +reporter-option: + - 'cdn=true' + - 'json=false' +exit: true +spec: ['test/unit/**/*.ts'] diff --git a/modules/sdk-coin-starknet/package.json b/modules/sdk-coin-starknet/package.json new file mode 100644 index 0000000000..10950af41d --- /dev/null +++ b/modules/sdk-coin-starknet/package.json @@ -0,0 +1,59 @@ +{ + "name": "@bitgo/sdk-coin-starknet", + "version": "1.0.0", + "description": "BitGo SDK coin library for Starknet", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "scripts": { + "build": "npm run prepare", + "build-ts": "yarn tsc --build --incremental --verbose .", + "fmt": "prettier --write .", + "check-fmt": "prettier --check '**/*.{ts,js,json}'", + "clean": "rm -r ./dist", + "lint": "eslint --quiet .", + "prepare": "npm run build-ts", + "test": "npm run coverage", + "coverage": "nyc -- npm run unit-test", + "unit-test": "mocha" + }, + "author": "BitGo SDK Team ", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "repository": { + "type": "git", + "url": "https://github.com/BitGo/BitGoJS.git", + "directory": "modules/sdk-coin-starknet" + }, + "lint-staged": { + "*.{js,ts}": [ + "yarn prettier --write", + "yarn eslint --fix" + ] + }, + "publishConfig": { + "access": "public" + }, + "nyc": { + "extension": [ + ".ts" + ] + }, + "dependencies": { + "@bitgo/sdk-core": "^36.44.0", + "@bitgo/sdk-lib-mpc": "^10.12.0", + "@bitgo/secp256k1": "^1.11.0", + "@bitgo/statics": "^58.39.0", + "bignumber.js": "^9.1.1", + "starknet": "^6.23.1" + }, + "devDependencies": { + "@bitgo/sdk-api": "^1.79.2", + "@bitgo/sdk-test": "^9.1.42" + }, + "gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c", + "files": [ + "dist" + ] +} diff --git a/modules/sdk-coin-starknet/src/index.ts b/modules/sdk-coin-starknet/src/index.ts new file mode 100644 index 0000000000..7665a1f793 --- /dev/null +++ b/modules/sdk-coin-starknet/src/index.ts @@ -0,0 +1,4 @@ +export * from './lib'; +export * from './starknet'; +export * from './tstarknet'; +export * from './register'; diff --git a/modules/sdk-coin-starknet/src/lib/constants.ts b/modules/sdk-coin-starknet/src/lib/constants.ts new file mode 100644 index 0000000000..d977126e62 --- /dev/null +++ b/modules/sdk-coin-starknet/src/lib/constants.ts @@ -0,0 +1,33 @@ +export const DECIMALS = 18; + +// OZ EthAccountUpgradeable class hash (v0.17.0) — secp256k1 signature verification +export const OZ_ETH_ACCOUNT_CLASS_HASH = '0x3940bc18abf1df6bc540cabadb1cad9486c6803b95801e57b6153ae21abfe06'; + +// STRK token contract (same on both mainnet and sepolia) +export const STRK_TOKEN_CONTRACT = '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d'; + +// ETH token contract on Starknet +export const ETH_TOKEN_CONTRACT = '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7'; + +// Chain IDs +export const MAINNET_CHAIN_ID = '0x534e5f4d41494e'; // SN_MAIN +export const TESTNET_CHAIN_ID = '0x534e5f5345504f4c4941'; // SN_SEPOLIA + +// RPC endpoints +export const MAINNET_RPC_URL = 'https://starknet-mainnet-rpc.publicnode.com/'; +export const TESTNET_RPC_URL = 'https://starknet-sepolia-rpc.publicnode.com/'; + +// felt252 max value (2^251 + 17 * 2^192 + 1) +export const FELT_MAX = (1n << 251n) + 17n * (1n << 192n) + 1n; + +// u256 split mask (128 bits) +export const MASK_128 = (1n << 128n) - 1n; + +// Default resource bounds for EthAccount (secp256k1 verification is gas-heavy ~24M L2 gas) +export const DEFAULT_RESOURCE_BOUNDS = { + l2_gas: { max_amount: 30000000n, max_price_per_unit: 100000000000n }, + l1_gas: { max_amount: 0n, max_price_per_unit: 100000000000000n }, + l1_data_gas: { max_amount: 1000n, max_price_per_unit: 10000000000n }, +}; + +export const ROOT_PATH = 'm/0'; diff --git a/modules/sdk-coin-starknet/src/lib/iface.ts b/modules/sdk-coin-starknet/src/lib/iface.ts new file mode 100644 index 0000000000..8ae3dd44db --- /dev/null +++ b/modules/sdk-coin-starknet/src/lib/iface.ts @@ -0,0 +1,81 @@ +import { + TransactionExplanation as BaseTransactionExplanation, + TransactionType as BitGoTransactionType, + TssVerifyAddressOptions, +} from '@bitgo/sdk-core'; + +export enum StarknetTransactionType { + INVOKE = 'INVOKE', + DEPLOY_ACCOUNT = 'DEPLOY_ACCOUNT', +} + +export interface StarknetCall { + contractAddress: string; + entrypoint: string; + calldata: string[]; +} + +export interface StarknetResourceBounds { + l2_gas: { max_amount: bigint; max_price_per_unit: bigint }; + l1_gas: { max_amount: bigint; max_price_per_unit: bigint }; + l1_data_gas: { max_amount: bigint; max_price_per_unit: bigint }; +} + +export interface StarknetTransactionData { + senderAddress: string; + calls: StarknetCall[]; + nonce?: string; + chainId: string; + resourceBounds?: StarknetResourceBounds; + transactionType: StarknetTransactionType; + signature?: string[]; + transactionHash?: string; + // For token transfers + receiverAddress?: string; + amount?: string; + tokenContract?: string; +} + +export interface TxData { + id?: string; + sender: string; + senderPublicKey?: string; + recipient?: string; + amount?: string; + fee?: string; + nonce?: string; + type?: BitGoTransactionType; +} + +export interface StarknetTransactionExplanation extends BaseTransactionExplanation { + sender?: string; + type?: BitGoTransactionType; +} + +export interface TransactionHexParams { + transactionHex: string; + signableHex?: string; +} + +export interface TssVerifyStarknetAddressOptions extends TssVerifyAddressOptions { + rootAddress?: string; +} + +export interface RecoveryOptions { + userKey: string; + backupKey: string; + bitgoKey?: string; + rootAddress?: string; + recoveryDestination: string; + walletPassphrase: string; +} + +export interface RecoveryTransaction { + id: string; + tx: string; +} + +export interface UnsignedSweepRecoveryTransaction { + txHex: string; + coin: string; +} diff --git a/modules/sdk-coin-starknet/src/lib/index.ts b/modules/sdk-coin-starknet/src/lib/index.ts new file mode 100644 index 0000000000..839446d4bd --- /dev/null +++ b/modules/sdk-coin-starknet/src/lib/index.ts @@ -0,0 +1,9 @@ +import * as Utils from './utils'; +export * from './iface'; + +export { KeyPair } from './keyPair'; +export { TransactionBuilder } from './transactionBuilder'; +export { TransferBuilder } from './transferBuilder'; +export { TransactionBuilderFactory } from './transactionBuilderFactory'; +export { Transaction } from './transaction'; +export { Utils }; diff --git a/modules/sdk-coin-starknet/src/lib/keyPair.ts b/modules/sdk-coin-starknet/src/lib/keyPair.ts new file mode 100644 index 0000000000..7c711b0f33 --- /dev/null +++ b/modules/sdk-coin-starknet/src/lib/keyPair.ts @@ -0,0 +1,48 @@ +import { + DefaultKeys, + KeyPairOptions, + Secp256k1ExtendedKeyPair, + isSeed, + isPrivateKey, + isPublicKey, +} from '@bitgo/sdk-core'; +import utils from './utils'; +import { bip32 } from '@bitgo/secp256k1'; +import { randomBytes } from 'crypto'; + +const DEFAULT_SEED_SIZE_BYTES = 16; + +export class KeyPair extends Secp256k1ExtendedKeyPair { + constructor(source?: KeyPairOptions) { + super(source); + if (!source) { + const seed = randomBytes(DEFAULT_SEED_SIZE_BYTES); + this.hdNode = bip32.fromSeed(seed); + } else if (isSeed(source)) { + this.hdNode = bip32.fromSeed(source.seed); + } else if (isPrivateKey(source)) { + super.recordKeysFromPrivateKey(source.prv); + } else if (isPublicKey(source)) { + super.recordKeysFromPublicKey(source.pub); + } else { + throw new Error('Invalid key pair options'); + } + + if (this.hdNode) { + this.keyPair = Secp256k1ExtendedKeyPair.toKeyPair(this.hdNode); + } + } + + /** @inheritdoc */ + getKeys(): DefaultKeys { + return { + pub: this.getPublicKey({ compressed: true }).toString('hex'), + prv: this.getPrivateKey()?.toString('hex'), + }; + } + + /** @inheritdoc */ + getAddress(): string { + return utils.getAddressFromPublicKey(this.getKeys().pub); + } +} diff --git a/modules/sdk-coin-starknet/src/lib/transaction.ts b/modules/sdk-coin-starknet/src/lib/transaction.ts new file mode 100644 index 0000000000..7e91b1b144 --- /dev/null +++ b/modules/sdk-coin-starknet/src/lib/transaction.ts @@ -0,0 +1,120 @@ +import { + BaseKey, + BaseTransaction, + TransactionRecipient, + TransactionType, + InvalidTransactionError, +} from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { StarknetTransactionData, StarknetTransactionType, StarknetTransactionExplanation, TxData } from './iface'; +import utils from './utils'; + +export class Transaction extends BaseTransaction { + protected _starknetTransactionData!: StarknetTransactionData; + protected _signedTransaction?: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + get starknetTransactionData(): StarknetTransactionData { + return this._starknetTransactionData; + } + + set starknetTransactionData(data: StarknetTransactionData) { + this._starknetTransactionData = data; + } + + get signedTransaction(): string | undefined { + return this._signedTransaction; + } + + set signedTransaction(tx: string) { + this._signedTransaction = tx; + } + + async fromRawTransaction(rawTransaction: string): Promise { + try { + const buffer = Buffer.from(rawTransaction, 'hex'); + const jsonString = buffer.toString('utf-8'); + const parsed = JSON.parse(jsonString); + + this._starknetTransactionData = { + senderAddress: parsed.senderAddress, + calls: parsed.calls || [], + nonce: parsed.nonce, + chainId: parsed.chainId, + transactionType: parsed.transactionType || StarknetTransactionType.INVOKE, + signature: parsed.signature, + transactionHash: parsed.transactionHash, + receiverAddress: parsed.receiverAddress, + amount: parsed.amount, + tokenContract: parsed.tokenContract, + }; + + if (parsed.signature && parsed.signature.length > 0) { + this._signedTransaction = rawTransaction; + } + + utils.validateRawTransaction(this._starknetTransactionData); + this._id = parsed.transactionHash || ''; + } catch (error) { + throw new InvalidTransactionError(`Invalid transaction: ${error.message}`); + } + } + + /** @inheritdoc */ + toJson(): TxData { + if (!this._starknetTransactionData) { + throw new InvalidTransactionError('Empty transaction'); + } + return { + id: this._id, + sender: this._starknetTransactionData.senderAddress, + recipient: this._starknetTransactionData.receiverAddress, + amount: this._starknetTransactionData.amount, + nonce: this._starknetTransactionData.nonce, + type: TransactionType.Send, + }; + } + + /** @inheritDoc */ + explainTransaction(): StarknetTransactionExplanation { + const result = this.toJson(); + const displayOrder = ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee']; + const outputs: TransactionRecipient[] = []; + + if (result.recipient && result.amount) { + outputs.push({ + address: result.recipient, + amount: result.amount, + }); + } + + return { + displayOrder, + id: this.id, + outputs, + outputAmount: result.amount || '0', + fee: { fee: '0' }, + type: result.type, + changeOutputs: [], + changeAmount: '0', + }; + } + + /** @inheritdoc */ + 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'); + } + + /** @inheritdoc */ + canSign(_key: BaseKey): boolean { + return true; + } +} diff --git a/modules/sdk-coin-starknet/src/lib/transactionBuilder.ts b/modules/sdk-coin-starknet/src/lib/transactionBuilder.ts new file mode 100644 index 0000000000..2d05a1b9a7 --- /dev/null +++ b/modules/sdk-coin-starknet/src/lib/transactionBuilder.ts @@ -0,0 +1,159 @@ +import { BaseAddress, BaseKey, BaseTransactionBuilder, BuildTransactionError, SigningError } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import BigNumber from 'bignumber.js'; +import { StarknetTransactionData, StarknetTransactionType, StarknetCall } from './iface'; +import { Transaction } from './transaction'; +import utils from './utils'; + +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 _receiverAddress?: string; + protected _amount?: string; + protected _tokenContract?: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new Transaction(_coinConfig); + } + + public sender(address: string, pubKey?: string): this { + if (!address || !utils.isValidAddress(address)) { + throw new BuildTransactionError('Invalid or missing address, got: ' + address); + } + if (pubKey && !utils.isValidPublicKey(pubKey)) { + throw new BuildTransactionError('Invalid pubKey, got: ' + pubKey); + } + this._sender = address; + if (pubKey) { + this._publicKey = pubKey; + } + return this; + } + + public receiverId(address: string): this { + if (!address || !utils.isValidAddress(address)) { + throw new BuildTransactionError('Invalid or missing receiver address, got: ' + address); + } + this._receiverAddress = address; + return this; + } + + public amount(value: string): this { + const val = new BigNumber(value); + if (val.isNaN() || val.isNegative()) { + throw new BuildTransactionError(`Invalid amount: ${value}`); + } + this._amount = value; + return this; + } + + public nonce(nonce: string): this { + this._nonce = nonce; + return this; + } + + public chainId(chainId: string): this { + this._chainId = chainId; + return this; + } + + public tokenContract(address: string): this { + if (!utils.isValidAddress(address)) { + throw new BuildTransactionError('Invalid token contract address'); + } + this._tokenContract = address; + return this; + } + + /** @inheritdoc */ + get transaction(): Transaction { + return this._transaction; + } + + /** @inheritdoc */ + set transaction(transaction: Transaction) { + this._transaction = transaction; + } + + initBuilder(tx: Transaction): void { + this._transaction = tx; + const data = tx.starknetTransactionData; + this._sender = data.senderAddress; + this._receiverAddress = data.receiverAddress; + this._amount = data.amount; + this._calls = data.calls || []; + this._nonce = data.nonce; + this._chainId = data.chainId; + this._tokenContract = data.tokenContract; + } + + /** @inheritdoc */ + validateAddress(address: BaseAddress): void { + if (!utils.isValidAddress(address.address)) { + throw new BuildTransactionError('Invalid address'); + } + } + + /** @inheritdoc */ + validateKey(key: BaseKey): void { + if (!key || !key.key) { + throw new SigningError('Key is required'); + } + if (!utils.isValidPrivateKey(key.key)) { + throw new SigningError('Invalid private key'); + } + } + + /** @inheritdoc */ + validateTransaction(_transaction: Transaction): void { + // Subclasses provide specific validation + } + + /** @inheritdoc */ + validateValue(value: BigNumber): void { + if (value.isNaN() || value.isNegative()) { + throw new BuildTransactionError(`Invalid value: ${value.toString()}`); + } + } + + /** @inheritdoc */ + validateRawTransaction(rawTransaction: string): void { + if (!rawTransaction) { + throw new BuildTransactionError('Raw transaction is empty'); + } + } + + /** @inheritdoc */ + protected fromImplementation(rawTransaction: string): Transaction { + this._transaction.fromRawTransaction(rawTransaction); + return this._transaction; + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + const data: StarknetTransactionData = { + senderAddress: this._sender || '', + calls: this._calls, + nonce: this._nonce, + chainId: this._chainId || '', + transactionType: StarknetTransactionType.INVOKE, + receiverAddress: this._receiverAddress, + amount: this._amount, + tokenContract: this._tokenContract, + }; + + this._transaction.starknetTransactionData = data; + return this._transaction; + } + + /** @inheritdoc */ + protected signImplementation(key: BaseKey): Transaction { + this.validateKey(key); + return this._transaction; + } +} diff --git a/modules/sdk-coin-starknet/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-starknet/src/lib/transactionBuilderFactory.ts new file mode 100644 index 0000000000..de691811d8 --- /dev/null +++ b/modules/sdk-coin-starknet/src/lib/transactionBuilderFactory.ts @@ -0,0 +1,45 @@ +import { BaseTransactionBuilderFactory, InvalidTransactionError, MethodNotImplementedError } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { Transaction } from './transaction'; +import { TransactionBuilder } from './transactionBuilder'; +import { TransferBuilder } from './transferBuilder'; +import { StarknetTransactionType } from './iface'; + +export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + /** @inheritdoc */ + async from(rawTransaction: string): Promise { + const transaction = new Transaction(this._coinConfig); + await transaction.fromRawTransaction(rawTransaction); + try { + switch (transaction.starknetTransactionData.transactionType) { + case StarknetTransactionType.INVOKE: + return this.getTransferBuilder(transaction); + default: + throw new InvalidTransactionError('Invalid transaction type'); + } + } catch (e) { + throw new InvalidTransactionError('Invalid transaction: ' + e.message); + } + } + + private static initializeBuilder(tx: Transaction | undefined, builder: T): T { + if (tx) { + builder.initBuilder(tx); + } + return builder; + } + + /** @inheritdoc */ + getTransferBuilder(tx?: Transaction): TransferBuilder { + return TransactionBuilderFactory.initializeBuilder(tx, new TransferBuilder(this._coinConfig)); + } + + /** @inheritdoc */ + getWalletInitializationBuilder(): void { + throw new MethodNotImplementedError(); + } +} diff --git a/modules/sdk-coin-starknet/src/lib/transferBuilder.ts b/modules/sdk-coin-starknet/src/lib/transferBuilder.ts new file mode 100644 index 0000000000..7e0c6d47bc --- /dev/null +++ b/modules/sdk-coin-starknet/src/lib/transferBuilder.ts @@ -0,0 +1,55 @@ +import { BuildTransactionError } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from './transaction'; +import { StarknetCall } from './iface'; +import { STRK_TOKEN_CONTRACT } from './constants'; +import utils from './utils'; + +export class TransferBuilder extends TransactionBuilder { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this.validateTransfer(); + + const tokenContract = this._tokenContract || STRK_TOKEN_CONTRACT; + + const transferCall: StarknetCall = { + contractAddress: tokenContract, + entrypoint: 'transfer', + calldata: this.compileTransferCalldata(this._receiverAddress as string, this._amount as string), + }; + + this._calls = [transferCall]; + + return super.buildImplementation(); + } + + private compileTransferCalldata(recipient: string, amount: string): string[] { + const amountBig = BigInt(amount); + const MASK_128 = (1n << 128n) - 1n; + + return [recipient, '0x' + (amountBig & MASK_128).toString(16), '0x' + (amountBig >> 128n).toString(16)]; + } + + private validateTransfer(): void { + if (!this._sender) { + throw new BuildTransactionError('Sender is required'); + } + if (!this._receiverAddress) { + throw new BuildTransactionError('Receiver is required'); + } + if (!this._amount) { + throw new BuildTransactionError('Amount is required'); + } + if (!utils.isValidAddress(this._sender)) { + throw new BuildTransactionError(`Invalid sender address: ${this._sender}`); + } + if (!utils.isValidAddress(this._receiverAddress)) { + throw new BuildTransactionError(`Invalid receiver address: ${this._receiverAddress}`); + } + } +} diff --git a/modules/sdk-coin-starknet/src/lib/utils.ts b/modules/sdk-coin-starknet/src/lib/utils.ts new file mode 100644 index 0000000000..e98b51601c --- /dev/null +++ b/modules/sdk-coin-starknet/src/lib/utils.ts @@ -0,0 +1,167 @@ +import { FELT_MAX, MASK_128, OZ_ETH_ACCOUNT_CLASS_HASH } from './constants'; +import { StarknetTransactionData } from './iface'; + +const EC = require('elliptic').ec; +const ec = new EC('secp256k1'); + +/** + * Starknet addresses are felt252 values represented as 0x-prefixed hex. + * Valid range: 0 < address < FELT_MAX, with 0x prefix and hex chars. + */ +export function isValidAddress(address: string): boolean { + if (typeof address !== 'string') return false; + if (!address.match(/^0x[0-9a-fA-F]{1,64}$/)) return false; + try { + const val = BigInt(address); + return val > 0n && val < FELT_MAX; + } catch { + return false; + } +} + +/** + * Validate a secp256k1 public key (compressed 33 bytes or uncompressed 65 bytes). + */ +export function isValidPublicKey(key: string): boolean { + if (typeof key !== 'string') return false; + try { + if (/^04[0-9a-fA-F]{128}$/.test(key)) { + ec.keyFromPublic(key, 'hex'); + return true; + } + if (/^(02|03)[0-9a-fA-F]{64}$/.test(key)) { + ec.keyFromPublic(key, 'hex'); + return true; + } + return false; + } catch { + return false; + } +} + +/** + * Validate a secp256k1 private key (32 bytes hex). + */ +export function isValidPrivateKey(key: string): boolean { + if (typeof key !== 'string') return false; + return /^[0-9a-fA-F]{64}$/.test(key); +} + +/** + * Get uncompressed public key (x || y, 128 hex chars, no 04 prefix). + */ +export function getUncompressedPublicKey(compressedHex: string): string { + if (compressedHex.startsWith('04') && compressedHex.length === 130) { + return compressedHex.slice(2); + } + if (compressedHex.startsWith('02') || compressedHex.startsWith('03')) { + const key = ec.keyFromPublic(compressedHex, 'hex'); + return key.getPublic(false, 'hex').slice(2); + } + if (compressedHex.length === 128) { + return compressedHex; + } + throw new Error(`Invalid public key format: ${compressedHex.substring(0, 10)}...`); +} + +/** + * Compile EthAccount constructor calldata from full secp256k1 public key. + * Returns [x_low, x_high, y_low, y_high] as felt252 strings. + */ +export function compileEthAccountConstructorCalldata(fullPublicKey: string): string[] { + const pubX = BigInt('0x' + fullPublicKey.slice(0, 64)); + const pubY = BigInt('0x' + fullPublicKey.slice(64)); + + return [ + '0x' + (pubX & MASK_128).toString(16), + '0x' + (pubX >> 128n).toString(16), + '0x' + (pubY & MASK_128).toString(16), + '0x' + (pubY >> 128n).toString(16), + ]; +} + +/** + * Compute Starknet EthAccount counterfactual address from public key. + * Uses Starknet's pedersen-based contract address calculation. + */ +export function computeStarknetAddress(fullPublicKey: string): { + address: string; + constructorCalldata: string[]; + salt: string; +} { + // We need starknet.js for address computation + const { hash } = require('starknet'); + const constructorCalldata = compileEthAccountConstructorCalldata(fullPublicKey); + + const pubX = BigInt('0x' + fullPublicKey.slice(0, 64)); + const salt = '0x' + (pubX % FELT_MAX).toString(16); + + const address = hash.calculateContractAddressFromHash(salt, OZ_ETH_ACCOUNT_CLASS_HASH, constructorCalldata, 0); + + return { address, constructorCalldata, salt }; +} + +/** + * Derive Starknet EthAccount address from compressed secp256k1 public key. + */ +export function getAddressFromPublicKey(compressedPublicKey: string): string { + const fullPublicKey = getUncompressedPublicKey(compressedPublicKey); + const { address } = computeStarknetAddress(fullPublicKey); + return address; +} + +/** + * Format ECDSA signature for Starknet EthAccount. + * Returns [r_low, r_high, s_low, s_high, v]. + */ +export function formatEthAccountSignature(r: string, s: string, recid: number): string[] { + const rBig = BigInt('0x' + r); + const sBig = BigInt('0x' + s); + const v = BigInt(recid); + + return [ + '0x' + (rBig & MASK_128).toString(16), + '0x' + (rBig >> 128n).toString(16), + '0x' + (sBig & MASK_128).toString(16), + '0x' + (sBig >> 128n).toString(16), + '0x' + v.toString(16), + ]; +} + +/** + * Generate a new secp256k1 key pair. + */ +export function generateKeyPair(seed?: Buffer): { pub: string; prv: string } { + const { KeyPair: StarknetKeyPair } = require('./keyPair'); + const keyPair = seed ? new StarknetKeyPair({ seed }) : new StarknetKeyPair(); + const { pub, prv } = keyPair.getKeys(); + if (!prv) { + throw new Error('Private key is missing in the generated key pair.'); + } + return { pub, prv }; +} + +/** + * Validate raw transaction data has required fields. + */ +export function validateRawTransaction(tx: StarknetTransactionData): void { + if (!tx.senderAddress) { + throw new Error('Missing sender address'); + } + if (!isValidAddress(tx.senderAddress)) { + throw new Error(`Invalid sender address: ${tx.senderAddress}`); + } +} + +export default { + isValidAddress, + isValidPublicKey, + isValidPrivateKey, + getUncompressedPublicKey, + compileEthAccountConstructorCalldata, + computeStarknetAddress, + getAddressFromPublicKey, + formatEthAccountSignature, + generateKeyPair, + validateRawTransaction, +}; diff --git a/modules/sdk-coin-starknet/src/register.ts b/modules/sdk-coin-starknet/src/register.ts new file mode 100644 index 0000000000..0604e15a66 --- /dev/null +++ b/modules/sdk-coin-starknet/src/register.ts @@ -0,0 +1,8 @@ +import { BitGoBase } from '@bitgo/sdk-core'; +import { Starknet } from './starknet'; +import { Tstarknet } from './tstarknet'; + +export const register = (sdk: BitGoBase): void => { + sdk.register('starknet', Starknet.createInstance); + sdk.register('tstarknet', Tstarknet.createInstance); +}; diff --git a/modules/sdk-coin-starknet/src/starknet.ts b/modules/sdk-coin-starknet/src/starknet.ts new file mode 100644 index 0000000000..569417ed87 --- /dev/null +++ b/modules/sdk-coin-starknet/src/starknet.ts @@ -0,0 +1,180 @@ +import { + AuditDecryptedKeyParams, + BaseCoin, + BitGoBase, + InvalidAddressError, + KeyPair, + MPCAlgorithm, + MultisigType, + multisigTypes, + ParsedTransaction, + ParseTransactionOptions, + SignedTransaction, + SigningError, + SignTransactionOptions, + VerifyTransactionOptions, + verifyMPCWalletAddress, + UnexpectedAddressError, +} from '@bitgo/sdk-core'; +import { coins, BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; +import { createHash, Hash } from 'crypto'; + +import { StarknetTransactionExplanation, TransactionHexParams, TssVerifyStarknetAddressOptions } from './lib/iface'; +import { TransactionBuilderFactory } from './lib/transactionBuilderFactory'; +import utils from './lib/utils'; +import { auditEcdsaPrivateKey } from '@bitgo/sdk-lib-mpc'; + +export class Starknet extends BaseCoin { + protected readonly _staticsCoin: Readonly; + protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly) { + super(bitgo); + + if (!staticsCoin) { + throw new Error('missing required constructor parameter staticsCoin'); + } + + this._staticsCoin = staticsCoin; + } + + static createInstance(bitgo: BitGoBase, staticsCoin?: Readonly): BaseCoin { + return new Starknet(bitgo, staticsCoin); + } + + getChain(): string { + return 'starknet'; + } + + getBaseChain(): string { + return 'starknet'; + } + + getFamily(): string { + return this._staticsCoin.family; + } + + getFullName(): string { + return 'Starknet'; + } + + getBaseFactor(): number { + return Math.pow(10, this._staticsCoin.decimalPlaces); + } + + async explainTransaction(params: TransactionHexParams): Promise { + const factory = this.getBuilderFactory(); + const txBuilder = await factory.from(params.transactionHex); + const transaction = await txBuilder.build(); + return transaction.explainTransaction(); + } + + async verifyTransaction(params: VerifyTransactionOptions): Promise { + const { txParams, txPrebuild } = params; + const txHex = txPrebuild?.txHex; + if (!txHex) { + throw new Error('txHex is required'); + } + + await this.explainTransaction({ transactionHex: txHex }); + + if (Array.isArray(txParams.recipients) && txParams.recipients.length > 0) { + if (txParams.recipients.length > 1) { + throw new Error( + `${this.getChain()} doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.` + ); + } + } + return true; + } + + async isWalletAddress(params: TssVerifyStarknetAddressOptions): Promise { + const { address } = params; + + if (!this.isValidAddress(address)) { + throw new InvalidAddressError(`invalid address: ${address}`); + } + + const result = await verifyMPCWalletAddress( + { ...params, keyCurve: 'secp256k1' }, + this.isValidAddress.bind(this), + (pubKey) => utils.getAddressFromPublicKey(pubKey) + ); + + if (!result) { + throw new UnexpectedAddressError(`address validation failure: address ${address} is not a wallet address`); + } + + return true; + } + + async parseTransaction(params: ParseTransactionOptions): Promise { + return {}; + } + + public generateKeyPair(seed?: Buffer): KeyPair { + return utils.generateKeyPair(seed); + } + + isValidAddress(address: string): boolean { + return utils.isValidAddress(address); + } + + async signTransaction( + params: SignTransactionOptions & { txPrebuild: { txHex: string }; prv: string } + ): Promise { + const txHex = params?.txPrebuild?.txHex; + const privateKey = params?.prv; + if (!txHex) { + throw new SigningError('missing required txPrebuild parameter: params.txPrebuild.txHex'); + } + if (!privateKey) { + throw new SigningError('missing required prv parameter: params.prv'); + } + const factory = this.getBuilderFactory(); + const txBuilder = await factory.from(params.txPrebuild.txHex); + txBuilder.sign({ key: params.prv }); + const tx = await txBuilder.build(); + return { + txHex: tx.toBroadcastFormat(), + }; + } + + isValidPub(key: string): boolean { + return utils.isValidPublicKey(key); + } + + isValidPrv(key: string): boolean { + return utils.isValidPrivateKey(key); + } + + /** @inheritDoc */ + supportsTss(): boolean { + return true; + } + + /** inherited doc */ + getDefaultMultisigType(): MultisigType { + return multisigTypes.tss; + } + + /** @inheritDoc */ + getMPCAlgorithm(): MPCAlgorithm { + return 'ecdsa'; + } + + /** @inheritDoc **/ + getHashFunction(): Hash { + return createHash('sha256'); + } + + private getBuilderFactory(): TransactionBuilderFactory { + return new TransactionBuilderFactory(coins.get(this.getBaseChain())); + } + + /** @inheritDoc */ + auditDecryptedKey({ multiSigType, prv, publicKey }: AuditDecryptedKeyParams): void { + if (multiSigType !== 'tss') { + throw new Error('Unsupported multisigtype'); + } + auditEcdsaPrivateKey(prv as string, publicKey as string); + } +} diff --git a/modules/sdk-coin-starknet/src/tstarknet.ts b/modules/sdk-coin-starknet/src/tstarknet.ts new file mode 100644 index 0000000000..96ea151b20 --- /dev/null +++ b/modules/sdk-coin-starknet/src/tstarknet.ts @@ -0,0 +1,37 @@ +import { BaseCoin, BitGoBase } from '@bitgo/sdk-core'; +import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; +import { Starknet } from './starknet'; + +export class Tstarknet extends Starknet { + protected readonly _staticsCoin: Readonly; + protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly) { + super(bitgo, staticsCoin); + + if (!staticsCoin) { + throw new Error('missing required constructor parameter staticsCoin'); + } + + this._staticsCoin = staticsCoin; + } + + static createInstance(bitgo: BitGoBase, staticsCoin?: Readonly): BaseCoin { + return new Tstarknet(bitgo, staticsCoin); + } + + public getChain(): string { + return 'tstarknet'; + } + + getBaseFactor(): number { + return Math.pow(10, this._staticsCoin.decimalPlaces); + } + + /** @inheritDoc */ + supportsTss(): boolean { + return true; + } + + public getFullName(): string { + return 'Testnet Starknet'; + } +} diff --git a/modules/sdk-coin-starknet/test/resources/starknet.ts b/modules/sdk-coin-starknet/test/resources/starknet.ts new file mode 100644 index 0000000000..d17372ab8e --- /dev/null +++ b/modules/sdk-coin-starknet/test/resources/starknet.ts @@ -0,0 +1,93 @@ +import { StarknetTransactionType } from '../../src/lib/iface'; + +export const Accounts = { + account1: { + secretKey: 'c5bccb8f471c5c9eb6483aa77ee4b700003b1e12df430a24d93238eb378b968b', + publicKey: + '042ab77b959e28c4fa47fa8fb9e57cec3d66df5684d076ac2e4c5f28fd69a23dd31a59f908c8add51eab3530b4ac5d015166eaf2198c52fa9a8df7cfaeb8fdb7d4', + address: '0x04a1f29b8b8e3d3c9f6c9b7a8d2e1f0c5b4a3d2e1f0c5b4a3d2e1f0c5b4a3d2e', + }, + account2: { + secretKey: '73312c28d0d455b6a29a9a66811ffda94f3db6bfd57bf5c2bed917ee5928e15f', + publicKey: + '044e01707f70f6ad8d9f79e5f2c2f0bac5e91520e5e2491354c6c7827b59d44148847f9180ac9679a6ce66f69c330551a99f8f9b7419c437705602a54c258a9dfe', + address: '0x05b2e38c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a', + }, + account3: { + publicKey: + '042281378584012843130dce9b19002f88a949f237397e2f6cda2db1392d54f6345faaf51c384fbfe4e8f67eb12fdb53732d2ddfe7470f9310a0bf824dad3f6b1b', + secretKey: '7b4de3d8cc3e312c70f674b52f11818205546ca7036c8071997c46e429160dc3', + address: '0x06c3f49d0e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c', + }, + errorsAccounts: { + account1: { + secretKey: 'not ok', + publicKey: 'not ok', + address: 'not ok', + }, + account2: { + secretKey: 'test_test', + publicKey: 'test_test', + address: 'invalid', + }, + }, +}; + +export const StarknetTransactionData = { + senderAddress: Accounts.account1.address, + receiverAddress: Accounts.account2.address, + amount: '1000000000000000000', + calls: [ + { + contractAddress: '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d', + entrypoint: 'transfer', + calldata: [Accounts.account2.address, '0xde0b6b3a7640000', '0x0'], + }, + ], + chainId: '0x534e5f5345504f4c4941', + transactionType: StarknetTransactionType.INVOKE, +}; + +// Raw transaction hex (JSON serialized to hex) +const rawTxUnsigned = Buffer.from( + JSON.stringify({ + senderAddress: Accounts.account1.address, + calls: StarknetTransactionData.calls, + nonce: '0', + chainId: '0x534e5f5345504f4c4941', + transactionType: StarknetTransactionType.INVOKE, + receiverAddress: Accounts.account2.address, + amount: '1000000000000000000', + tokenContract: '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d', + }), + 'utf-8' +).toString('hex'); + +const rawTxSigned = Buffer.from( + JSON.stringify({ + senderAddress: Accounts.account1.address, + calls: StarknetTransactionData.calls, + nonce: '0', + chainId: '0x534e5f5345504f4c4941', + transactionType: StarknetTransactionType.INVOKE, + signature: ['0xabc123', '0xdef456', '0x789012', '0x345678', '0x0'], + transactionHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + receiverAddress: Accounts.account2.address, + amount: '1000000000000000000', + tokenContract: '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d', + }), + 'utf-8' +).toString('hex'); + +export const rawTx = { + transfer: { + unsigned: rawTxUnsigned, + signed: rawTxSigned, + }, +}; + +export const TEST_AMOUNTS = { + small: '1000000000000000000', + medium: '10000000000000000000', + large: '999999999999999999999999', +}; diff --git a/modules/sdk-coin-starknet/test/unit/keyPair.ts b/modules/sdk-coin-starknet/test/unit/keyPair.ts new file mode 100644 index 0000000000..c5859727fa --- /dev/null +++ b/modules/sdk-coin-starknet/test/unit/keyPair.ts @@ -0,0 +1,58 @@ +import should from 'should'; +import { KeyPair } from '../../src/lib/keyPair'; +import { Accounts } from '../resources/starknet'; + +describe('Starknet KeyPair', () => { + describe('Constructor', () => { + it('should create keypair from public key only', () => { + const kp = new KeyPair({ pub: Accounts.account1.publicKey }); + const keys = kp.getKeys(); + should.exist(keys.pub); + }); + + it('should create keypair from private key', () => { + const kp = new KeyPair({ prv: Accounts.account1.secretKey }); + const keys = kp.getKeys(); + should.exist(keys.pub); + should.exist(keys.prv); + }); + + it('should create random keypair when no source', () => { + const kp = new KeyPair(); + const keys = kp.getKeys(); + should.exist(keys.pub); + should.exist(keys.prv); + }); + + it('should throw on invalid public key', () => { + should.throws(() => new KeyPair({ pub: 'invalid_key_format' })); + }); + + it('should throw on invalid private key', () => { + should.throws(() => new KeyPair({ prv: 'not_a_valid_key' })); + }); + }); + + describe('Key Generation', () => { + it('should create unique key pairs each time', () => { + const kp1 = new KeyPair(); + const kp2 = new KeyPair(); + kp1.getKeys().pub.should.not.equal(kp2.getKeys().pub); + }); + }); + + describe('getAddress', () => { + it('should return a valid Starknet address from keypair', () => { + const kp = new KeyPair({ pub: Accounts.account1.publicKey }); + const address = kp.getAddress(); + should.exist(address); + address.should.startWith('0x'); + }); + + it('should return different addresses for different keys', () => { + const kp1 = new KeyPair({ pub: Accounts.account1.publicKey }); + const kp2 = new KeyPair({ pub: Accounts.account2.publicKey }); + kp1.getAddress().should.not.equal(kp2.getAddress()); + }); + }); +}); diff --git a/modules/sdk-coin-starknet/test/unit/starknet.ts b/modules/sdk-coin-starknet/test/unit/starknet.ts new file mode 100644 index 0000000000..cbd39305d6 --- /dev/null +++ b/modules/sdk-coin-starknet/test/unit/starknet.ts @@ -0,0 +1,87 @@ +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { Starknet, Tstarknet } from '../../src/index'; +import * as testData from '../resources/starknet'; +import should from 'should'; + +describe('Starknet', function () { + let bitgo: TestBitGoAPI; + let basecoin; + + before(async function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.safeRegister('starknet', Starknet.createInstance); + bitgo.safeRegister('tstarknet', Tstarknet.createInstance); + bitgo.initializeTestVars(); + basecoin = bitgo.coin('starknet'); + }); + + it('should return the right info', function () { + const starknet = bitgo.coin('starknet'); + const tstarknet = bitgo.coin('tstarknet'); + + starknet.getChain().should.equal('starknet'); + starknet.getFamily().should.equal('starknet'); + starknet.getFullName().should.equal('Starknet'); + starknet.getBaseFactor().should.equal(1e18); + starknet.supportsTss().should.equal(true); + + tstarknet.getChain().should.equal('tstarknet'); + tstarknet.getFamily().should.equal('starknet'); + tstarknet.getFullName().should.equal('Testnet Starknet'); + tstarknet.getBaseFactor().should.equal(1e18); + tstarknet.supportsTss().should.equal(true); + }); + + describe('Address validation', () => { + it('should validate a correct Starknet address', function () { + basecoin.isValidAddress(testData.Accounts.account1.address).should.equal(true); + }); + + it('should validate a second correct address', function () { + basecoin.isValidAddress(testData.Accounts.account2.address).should.equal(true); + }); + + it('should reject an obviously invalid address', function () { + basecoin.isValidAddress('not_an_address').should.equal(false); + }); + + it('should reject an empty address', function () { + basecoin.isValidAddress('').should.equal(false); + }); + }); + + describe('Public key validation', () => { + it('should validate a correct uncompressed public key', function () { + basecoin.isValidPub(testData.Accounts.account1.publicKey).should.equal(true); + }); + + it('should reject an invalid public key', function () { + basecoin.isValidPub('invalid-public-key').should.equal(false); + }); + }); + + describe('MPC support', () => { + it('should support TSS', function () { + basecoin.supportsTss().should.equal(true); + }); + + it('should return ECDSA as MPC algorithm', function () { + basecoin.getMPCAlgorithm().should.equal('ecdsa'); + }); + }); + + describe('Key pair generation', () => { + it('should generate a key pair', function () { + const keyPair = basecoin.generateKeyPair(); + should.exist(keyPair.pub); + should.exist(keyPair.prv); + }); + + it('should generate different key pairs each time', function () { + const kp1 = basecoin.generateKeyPair(); + const kp2 = basecoin.generateKeyPair(); + kp1.pub.should.not.equal(kp2.pub); + }); + }); +}); diff --git a/modules/sdk-coin-starknet/test/unit/transaction.ts b/modules/sdk-coin-starknet/test/unit/transaction.ts new file mode 100644 index 0000000000..df0be2f3fc --- /dev/null +++ b/modules/sdk-coin-starknet/test/unit/transaction.ts @@ -0,0 +1,55 @@ +import should from 'should'; +import { coins } from '@bitgo/statics'; +import { Transaction } from '../../src/lib/transaction'; +import { rawTx, Accounts } from '../resources/starknet'; + +describe('Starknet Transaction', () => { + describe('Parse unsigned transaction', () => { + it('should parse unsigned transfer hex and extract correct fields', async () => { + const coinConfig = coins.get('starknet'); + const tx = new Transaction(coinConfig); + await tx.fromRawTransaction(rawTx.transfer.unsigned); + + const json = tx.toJson(); + json.sender.should.equal(Accounts.account1.address); + should.exist(json.amount); + }); + }); + + describe('Parse signed transaction', () => { + it('should parse signed transfer hex and extract correct fields', async () => { + const coinConfig = coins.get('starknet'); + const tx = new Transaction(coinConfig); + await tx.fromRawTransaction(rawTx.transfer.signed); + + const json = tx.toJson(); + json.sender.should.equal(Accounts.account1.address); + should.exist(json.amount); + }); + }); + + describe('Transaction Explanation', () => { + it('should explain a parsed transaction', async () => { + const coinConfig = coins.get('starknet'); + const tx = new Transaction(coinConfig); + await tx.fromRawTransaction(rawTx.transfer.signed); + + const exp = tx.explainTransaction(); + should.exist(exp); + should.exist(exp.outputs); + exp.outputs.length.should.be.greaterThan(0); + }); + }); + + describe('toBroadcastFormat', () => { + it('should produce non-empty broadcast format for unsigned tx', 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); + }); + }); +}); diff --git a/modules/sdk-coin-starknet/test/unit/transactionBuilder/transactionBuilderFactory.ts b/modules/sdk-coin-starknet/test/unit/transactionBuilder/transactionBuilderFactory.ts new file mode 100644 index 0000000000..84c84d241d --- /dev/null +++ b/modules/sdk-coin-starknet/test/unit/transactionBuilder/transactionBuilderFactory.ts @@ -0,0 +1,76 @@ +import should from 'should'; +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory } from '../../../src/lib/transactionBuilderFactory'; +import { Accounts, rawTx, TEST_AMOUNTS } from '../../resources/starknet'; + +describe('Starknet TransactionBuilderFactory', () => { + const coinConfig = coins.get('starknet'); + + describe('getTransferBuilder', () => { + it('should return a transfer builder', () => { + const builder = new TransactionBuilderFactory(coinConfig).getTransferBuilder(); + should.exist(builder); + }); + }); + + describe('from', () => { + it('should rebuild unsigned tx from raw hex', async () => { + const builder = await new TransactionBuilderFactory(coinConfig).from(rawTx.transfer.unsigned); + const tx = await builder.build(); + const json = tx.toJson(); + should.exist(json); + json.sender.should.equal(Accounts.account1.address); + }); + + it('should rebuild signed tx from raw hex', async () => { + const builder = await new TransactionBuilderFactory(coinConfig).from(rawTx.transfer.signed); + const tx = await builder.build(); + const json = tx.toJson(); + should.exist(json); + }); + }); +}); + +describe('Starknet TransferBuilder', () => { + const coinConfig = coins.get('starknet'); + + const getBuilder = () => new TransactionBuilderFactory(coinConfig).getTransferBuilder(); + + describe('Build unsigned transaction', () => { + it('should build an unsigned transfer with required fields', async () => { + const builder = getBuilder(); + builder.sender(Accounts.account1.address).receiverId(Accounts.account2.address).amount(TEST_AMOUNTS.small); + + const tx = await builder.build(); + should.exist(tx); + + const json = tx.toJson(); + should.exist(json); + json.sender.should.equal(Accounts.account1.address); + }); + }); + + describe('Validation', () => { + it('should reject missing sender', async () => { + const builder = getBuilder(); + builder.receiverId(Accounts.account2.address).amount(TEST_AMOUNTS.small); + await builder.build().should.be.rejectedWith('Sender is required'); + }); + + it('should reject missing recipient', async () => { + const builder = getBuilder(); + builder.sender(Accounts.account1.address).amount(TEST_AMOUNTS.small); + await builder.build().should.be.rejectedWith('Receiver is required'); + }); + + it('should reject invalid sender address', () => { + const builder = getBuilder(); + should.throws(() => builder.sender('not_a_valid_address')); + }); + + it('should reject negative amount', () => { + const builder = getBuilder(); + should.throws(() => builder.amount('-1')); + }); + }); +}); diff --git a/modules/sdk-coin-starknet/test/unit/utils.ts b/modules/sdk-coin-starknet/test/unit/utils.ts new file mode 100644 index 0000000000..56d0ad38f8 --- /dev/null +++ b/modules/sdk-coin-starknet/test/unit/utils.ts @@ -0,0 +1,81 @@ +import utils from '../../src/lib/utils'; +import { Accounts } from '../resources/starknet'; +import 'should'; + +describe('Starknet Utils', () => { + describe('isValidAddress', () => { + it('should accept a valid Starknet address', () => { + utils.isValidAddress(Accounts.account1.address).should.equal(true); + }); + + it('should accept a second valid address', () => { + utils.isValidAddress(Accounts.account2.address).should.equal(true); + }); + + it('should reject an invalid address', () => { + utils.isValidAddress('not_an_address').should.equal(false); + }); + + it('should reject an empty string', () => { + utils.isValidAddress('').should.equal(false); + }); + + it('should reject address without 0x prefix', () => { + utils.isValidAddress('04a1f29b8b8e3d3c9f6c9b7a8d2e1f0c5b4a3d2e1f0c5b4a3d2e1f0c5b4a3d2e').should.equal(false); + }); + }); + + describe('isValidPublicKey', () => { + it('should accept a valid uncompressed public key', () => { + utils.isValidPublicKey(Accounts.account1.publicKey).should.equal(true); + }); + + it('should reject an invalid public key', () => { + utils.isValidPublicKey('not_a_key').should.equal(false); + }); + + it('should reject an empty string', () => { + utils.isValidPublicKey('').should.equal(false); + }); + }); + + describe('isValidPrivateKey', () => { + it('should accept a valid private key', () => { + utils.isValidPrivateKey(Accounts.account1.secretKey).should.equal(true); + }); + + it('should reject an invalid private key', () => { + utils.isValidPrivateKey('not_a_key').should.equal(false); + }); + + it('should reject an empty string', () => { + utils.isValidPrivateKey('').should.equal(false); + }); + }); + + describe('getUncompressedPublicKey', () => { + it('should return 128 hex chars from uncompressed key', () => { + const result = utils.getUncompressedPublicKey(Accounts.account1.publicKey); + result.length.should.equal(128); + }); + }); + + describe('formatEthAccountSignature', () => { + it('should format signature as 5 felt252 values', () => { + const r = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + const s = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + const result = utils.formatEthAccountSignature(r, s, 0); + result.length.should.equal(5); + result.forEach((val) => val.should.startWith('0x')); + }); + + it('should handle recid 0 and 1', () => { + const r = 'aaaa'; + const s = 'bbbb'; + const sig0 = utils.formatEthAccountSignature(r, s, 0); + const sig1 = utils.formatEthAccountSignature(r, s, 1); + sig0[4].should.equal('0x0'); + sig1[4].should.equal('0x1'); + }); + }); +}); diff --git a/modules/sdk-coin-starknet/tsconfig.json b/modules/sdk-coin-starknet/tsconfig.json new file mode 100644 index 0000000000..40ad00917c --- /dev/null +++ b/modules/sdk-coin-starknet/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "references": [ + { "path": "../sdk-core" }, + { "path": "../statics" }, + { "path": "../sdk-lib-mpc" }, + { "path": "../secp256k1" }, + { "path": "../sdk-api" }, + { "path": "../sdk-test" } + ] +}