Skip to content

Commit 12b9a12

Browse files
feat(sdk-coin-starknet): implement Starknet SDK module CECHO-924
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b95cdbc commit 12b9a12

23 files changed

Lines changed: 1495 additions & 0 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules
2+
.idea
3+
public
4+
dist
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "../../.eslintrc.json",
3+
"rules": {
4+
"@typescript-eslint/explicit-module-boundary-types": "error",
5+
"indent": "off"
6+
}
7+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
require: 'tsx'
2+
timeout: 120000
3+
reporter: 'min'
4+
reporter-option:
5+
- 'cdn=true'
6+
- 'json=false'
7+
exit: true
8+
spec: ['test/unit/**/*.ts']
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"name": "@bitgo/sdk-coin-starknet",
3+
"version": "1.0.0",
4+
"description": "BitGo SDK coin library for Starknet",
5+
"main": "./dist/src/index.js",
6+
"types": "./dist/src/index.d.ts",
7+
"scripts": {
8+
"build": "npm run prepare",
9+
"build-ts": "yarn tsc --build --incremental --verbose .",
10+
"fmt": "prettier --write .",
11+
"check-fmt": "prettier --check '**/*.{ts,js,json}'",
12+
"clean": "rm -r ./dist",
13+
"lint": "eslint --quiet .",
14+
"prepare": "npm run build-ts",
15+
"test": "npm run coverage",
16+
"coverage": "nyc -- npm run unit-test",
17+
"unit-test": "mocha"
18+
},
19+
"author": "BitGo SDK Team <sdkteam@bitgo.com>",
20+
"license": "MIT",
21+
"engines": {
22+
"node": ">=20"
23+
},
24+
"repository": {
25+
"type": "git",
26+
"url": "https://github.com/BitGo/BitGoJS.git",
27+
"directory": "modules/sdk-coin-starknet"
28+
},
29+
"lint-staged": {
30+
"*.{js,ts}": [
31+
"yarn prettier --write",
32+
"yarn eslint --fix"
33+
]
34+
},
35+
"publishConfig": {
36+
"access": "public"
37+
},
38+
"nyc": {
39+
"extension": [
40+
".ts"
41+
]
42+
},
43+
"dependencies": {
44+
"@bitgo/sdk-core": "^36.44.0",
45+
"@bitgo/sdk-lib-mpc": "^10.12.0",
46+
"@bitgo/secp256k1": "^1.11.0",
47+
"@bitgo/statics": "^58.39.0",
48+
"@scure/starknet": "^1.1.0",
49+
"bignumber.js": "^9.1.1"
50+
},
51+
"devDependencies": {
52+
"@bitgo/sdk-api": "^1.79.2",
53+
"@bitgo/sdk-test": "^9.1.42"
54+
},
55+
"gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c",
56+
"files": [
57+
"dist"
58+
]
59+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './lib';
2+
export * from './starknet';
3+
export * from './register';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
export const DECIMALS = 18;
2+
3+
// OZ EthAccountUpgradeable class hash (v0.17.0) — secp256k1 signature verification
4+
export const OZ_ETH_ACCOUNT_CLASS_HASH = '0x3940bc18abf1df6bc540cabadb1cad9486c6803b95801e57b6153ae21abfe06';
5+
6+
// STRK token contract (same on both mainnet and sepolia)
7+
export const STRK_TOKEN_CONTRACT = '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d';
8+
9+
// ETH token contract on Starknet
10+
export const ETH_TOKEN_CONTRACT = '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7';
11+
12+
// Chain IDs
13+
export const MAINNET_CHAIN_ID = '0x534e5f4d41494e'; // SN_MAIN
14+
export const TESTNET_CHAIN_ID = '0x534e5f5345504f4c4941'; // SN_SEPOLIA
15+
16+
// RPC endpoints
17+
export const MAINNET_RPC_URL = 'https://starknet-mainnet-rpc.publicnode.com/';
18+
export const TESTNET_RPC_URL = 'https://starknet-sepolia-rpc.publicnode.com/';
19+
20+
// felt252 max value (2^251 + 17 * 2^192 + 1)
21+
export const FELT_MAX = (1n << 251n) + 17n * (1n << 192n) + 1n;
22+
23+
// u256 split mask (128 bits)
24+
export const MASK_128 = (1n << 128n) - 1n;
25+
26+
// Contract address bound: 2^251 - 256
27+
export const ADDR_BOUND = 2n ** 251n - 256n;
28+
29+
// encodeShortString('STARKNET_CONTRACT_ADDRESS')
30+
export const CONTRACT_ADDRESS_PREFIX = 0x535441524b4e45545f434f4e54524143545f41444452455353n;
31+
32+
// Default resource bounds for EthAccount (secp256k1 verification is gas-heavy ~24M L2 gas)
33+
export const DEFAULT_RESOURCE_BOUNDS = {
34+
l2_gas: { max_amount: 30000000n, max_price_per_unit: 100000000000n },
35+
l1_gas: { max_amount: 0n, max_price_per_unit: 100000000000000n },
36+
l1_data_gas: { max_amount: 1000n, max_price_per_unit: 10000000000n },
37+
};
38+
39+
export const ROOT_PATH = 'm/0';
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import {
2+
TransactionExplanation as BaseTransactionExplanation,
3+
TransactionType as BitGoTransactionType,
4+
TssVerifyAddressOptions,
5+
} from '@bitgo/sdk-core';
6+
7+
export enum StarknetTransactionType {
8+
INVOKE = 'INVOKE',
9+
DEPLOY_ACCOUNT = 'DEPLOY_ACCOUNT',
10+
}
11+
12+
export interface StarknetCall {
13+
contractAddress: string;
14+
entrypoint: string;
15+
calldata: string[];
16+
}
17+
18+
export interface StarknetResourceBounds {
19+
l2_gas: { max_amount: bigint; max_price_per_unit: bigint };
20+
l1_gas: { max_amount: bigint; max_price_per_unit: bigint };
21+
l1_data_gas: { max_amount: bigint; max_price_per_unit: bigint };
22+
}
23+
24+
export interface StarknetTransactionData {
25+
senderAddress: string;
26+
calls: StarknetCall[];
27+
nonce?: string;
28+
chainId: string;
29+
resourceBounds?: StarknetResourceBounds;
30+
transactionType: StarknetTransactionType;
31+
signature?: string[];
32+
transactionHash?: string;
33+
// For token transfers
34+
receiverAddress?: string;
35+
amount?: string;
36+
tokenContract?: string;
37+
}
38+
39+
export interface TxData {
40+
id?: string;
41+
sender: string;
42+
senderPublicKey?: string;
43+
recipient?: string;
44+
amount?: string;
45+
fee?: string;
46+
nonce?: string;
47+
type?: BitGoTransactionType;
48+
}
49+
50+
export interface StarknetTransactionExplanation extends BaseTransactionExplanation {
51+
sender?: string;
52+
type?: BitGoTransactionType;
53+
}
54+
55+
export interface TransactionHexParams {
56+
transactionHex: string;
57+
signableHex?: string;
58+
}
59+
60+
export interface TssVerifyStarknetAddressOptions extends TssVerifyAddressOptions {
61+
rootAddress?: string;
62+
}
63+
64+
export interface RecoveryOptions {
65+
userKey: string;
66+
backupKey: string;
67+
bitgoKey?: string;
68+
rootAddress?: string;
69+
recoveryDestination: string;
70+
walletPassphrase: string;
71+
}
72+
73+
export interface RecoveryTransaction {
74+
id: string;
75+
tx: string;
76+
}
77+
78+
export interface UnsignedSweepRecoveryTransaction {
79+
txHex: string;
80+
coin: string;
81+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Utils from './utils';
2+
export * from './iface';
3+
4+
export { KeyPair } from './keyPair';
5+
export { TransactionBuilder } from './transactionBuilder';
6+
export { TransferBuilder } from './transferBuilder';
7+
export { TransactionBuilderFactory } from './transactionBuilderFactory';
8+
export { Transaction } from './transaction';
9+
export { Utils };
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {
2+
DefaultKeys,
3+
KeyPairOptions,
4+
Secp256k1ExtendedKeyPair,
5+
isSeed,
6+
isPrivateKey,
7+
isPublicKey,
8+
} from '@bitgo/sdk-core';
9+
import utils from './utils';
10+
import { bip32 } from '@bitgo/secp256k1';
11+
import { randomBytes } from 'crypto';
12+
13+
const DEFAULT_SEED_SIZE_BYTES = 16;
14+
15+
export class KeyPair extends Secp256k1ExtendedKeyPair {
16+
constructor(source?: KeyPairOptions) {
17+
super(source);
18+
if (!source) {
19+
const seed = randomBytes(DEFAULT_SEED_SIZE_BYTES);
20+
this.hdNode = bip32.fromSeed(seed);
21+
} else if (isSeed(source)) {
22+
this.hdNode = bip32.fromSeed(source.seed);
23+
} else if (isPrivateKey(source)) {
24+
super.recordKeysFromPrivateKey(source.prv);
25+
} else if (isPublicKey(source)) {
26+
super.recordKeysFromPublicKey(source.pub);
27+
} else {
28+
throw new Error('Invalid key pair options');
29+
}
30+
31+
if (this.hdNode) {
32+
this.keyPair = Secp256k1ExtendedKeyPair.toKeyPair(this.hdNode);
33+
}
34+
}
35+
36+
/** @inheritdoc */
37+
getKeys(): DefaultKeys {
38+
return {
39+
pub: this.getPublicKey({ compressed: true }).toString('hex'),
40+
prv: this.getPrivateKey()?.toString('hex'),
41+
};
42+
}
43+
44+
/** @inheritdoc */
45+
getAddress(): string {
46+
return utils.getAddressFromPublicKey(this.getKeys().pub);
47+
}
48+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import {
2+
BaseKey,
3+
BaseTransaction,
4+
TransactionRecipient,
5+
TransactionType,
6+
InvalidTransactionError,
7+
} from '@bitgo/sdk-core';
8+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
9+
import { StarknetTransactionData, StarknetTransactionType, StarknetTransactionExplanation, TxData } from './iface';
10+
import utils from './utils';
11+
12+
export class Transaction extends BaseTransaction {
13+
protected _starknetTransactionData!: StarknetTransactionData;
14+
protected _signedTransaction?: string;
15+
16+
constructor(_coinConfig: Readonly<CoinConfig>) {
17+
super(_coinConfig);
18+
}
19+
20+
get starknetTransactionData(): StarknetTransactionData {
21+
return this._starknetTransactionData;
22+
}
23+
24+
set starknetTransactionData(data: StarknetTransactionData) {
25+
this._starknetTransactionData = data;
26+
}
27+
28+
get signedTransaction(): string | undefined {
29+
return this._signedTransaction;
30+
}
31+
32+
set signedTransaction(tx: string) {
33+
this._signedTransaction = tx;
34+
}
35+
36+
async fromRawTransaction(rawTransaction: string): Promise<void> {
37+
try {
38+
const buffer = Buffer.from(rawTransaction, 'hex');
39+
const jsonString = buffer.toString('utf-8');
40+
const parsed = JSON.parse(jsonString);
41+
42+
this._starknetTransactionData = {
43+
senderAddress: parsed.senderAddress,
44+
calls: parsed.calls || [],
45+
nonce: parsed.nonce,
46+
chainId: parsed.chainId,
47+
transactionType: parsed.transactionType || StarknetTransactionType.INVOKE,
48+
signature: parsed.signature,
49+
transactionHash: parsed.transactionHash,
50+
receiverAddress: parsed.receiverAddress,
51+
amount: parsed.amount,
52+
tokenContract: parsed.tokenContract,
53+
};
54+
55+
if (parsed.signature && parsed.signature.length > 0) {
56+
this._signedTransaction = rawTransaction;
57+
}
58+
59+
utils.validateRawTransaction(this._starknetTransactionData);
60+
this._id = parsed.transactionHash || '';
61+
} catch (error) {
62+
throw new InvalidTransactionError(`Invalid transaction: ${error.message}`);
63+
}
64+
}
65+
66+
/** @inheritdoc */
67+
toJson(): TxData {
68+
if (!this._starknetTransactionData) {
69+
throw new InvalidTransactionError('Empty transaction');
70+
}
71+
return {
72+
id: this._id,
73+
sender: this._starknetTransactionData.senderAddress,
74+
recipient: this._starknetTransactionData.receiverAddress,
75+
amount: this._starknetTransactionData.amount,
76+
nonce: this._starknetTransactionData.nonce,
77+
type: TransactionType.Send,
78+
};
79+
}
80+
81+
/** @inheritDoc */
82+
explainTransaction(): StarknetTransactionExplanation {
83+
const result = this.toJson();
84+
const displayOrder = ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee'];
85+
const outputs: TransactionRecipient[] = [];
86+
87+
if (result.recipient && result.amount) {
88+
outputs.push({
89+
address: result.recipient,
90+
amount: result.amount,
91+
});
92+
}
93+
94+
return {
95+
displayOrder,
96+
id: this.id,
97+
outputs,
98+
outputAmount: result.amount || '0',
99+
fee: { fee: '0' },
100+
type: result.type,
101+
changeOutputs: [],
102+
changeAmount: '0',
103+
};
104+
}
105+
106+
/** @inheritdoc */
107+
toBroadcastFormat(): string {
108+
const data = this._starknetTransactionData;
109+
if (!data) {
110+
throw new InvalidTransactionError('Empty transaction');
111+
}
112+
const json = JSON.stringify(data);
113+
return Buffer.from(json, 'utf-8').toString('hex');
114+
}
115+
116+
/** @inheritdoc */
117+
canSign(_key: BaseKey): boolean {
118+
return true;
119+
}
120+
}

0 commit comments

Comments
 (0)