Skip to content

Commit 55f4078

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 ba966a5 commit 55f4078

23 files changed

Lines changed: 1485 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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// OZ EthAccountUpgradeable class hash (v0.17.0) — secp256k1 signature verification
2+
export const OZ_ETH_ACCOUNT_CLASS_HASH = '0x3940bc18abf1df6bc540cabadb1cad9486c6803b95801e57b6153ae21abfe06';
3+
4+
// STRK token contract (same on both mainnet and sepolia)
5+
export const STRK_TOKEN_CONTRACT = '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d';
6+
7+
// felt252 max value (2^251 + 17 * 2^192 + 1)
8+
export const FELT_MAX = (1n << 251n) + 17n * (1n << 192n) + 1n;
9+
10+
// u256 split mask (128 bits)
11+
export const MASK_128 = (1n << 128n) - 1n;
12+
13+
// Contract address bound: 2^251 - 256
14+
export const ADDR_BOUND = 2n ** 251n - 256n;
15+
16+
// encodeShortString('STARKNET_CONTRACT_ADDRESS')
17+
export const CONTRACT_ADDRESS_PREFIX = 0x535441524b4e45545f434f4e54524143545f41444452455353n;
18+
19+
export const DEFAULT_SEED_SIZE_BYTES = 16;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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: string; max_price_per_unit: string };
20+
l1_gas: { max_amount: string; max_price_per_unit: string };
21+
l1_data_gas: { max_amount: string; max_price_per_unit: string };
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+
}
34+
35+
export interface ParsedTransferData {
36+
recipient: string;
37+
amount: string;
38+
tokenContract: string;
39+
}
40+
41+
export interface TxData {
42+
id?: string;
43+
sender: string;
44+
senderPublicKey?: string;
45+
recipient?: string;
46+
amount?: string;
47+
fee?: string;
48+
nonce: string;
49+
type?: BitGoTransactionType;
50+
}
51+
52+
export interface StarknetTransactionExplanation extends BaseTransactionExplanation {
53+
sender?: string;
54+
type?: BitGoTransactionType;
55+
}
56+
57+
export interface TransactionHexParams {
58+
transactionHex: string;
59+
signableHex?: string;
60+
}
61+
62+
export interface TssVerifyStarknetAddressOptions extends TssVerifyAddressOptions {
63+
rootAddress?: string;
64+
}
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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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+
import { DEFAULT_SEED_SIZE_BYTES } from './constants';
13+
14+
export class KeyPair extends Secp256k1ExtendedKeyPair {
15+
constructor(source?: KeyPairOptions) {
16+
super(source);
17+
if (!source) {
18+
const seed = randomBytes(DEFAULT_SEED_SIZE_BYTES);
19+
this.hdNode = bip32.fromSeed(seed);
20+
} else if (isSeed(source)) {
21+
this.hdNode = bip32.fromSeed(source.seed);
22+
} else if (isPrivateKey(source)) {
23+
super.recordKeysFromPrivateKey(source.prv);
24+
} else if (isPublicKey(source)) {
25+
super.recordKeysFromPublicKey(source.pub);
26+
} else {
27+
throw new Error('Invalid key pair options');
28+
}
29+
30+
if (this.hdNode) {
31+
this.keyPair = Secp256k1ExtendedKeyPair.toKeyPair(this.hdNode);
32+
}
33+
}
34+
35+
/** @inheritdoc */
36+
getKeys(): DefaultKeys {
37+
return {
38+
pub: this.getPublicKey({ compressed: true }).toString('hex'),
39+
prv: this.getPrivateKey()?.toString('hex'),
40+
};
41+
}
42+
43+
/** @inheritdoc */
44+
getAddress(): string {
45+
return utils.getAddressFromPublicKey(this.getKeys().pub);
46+
}
47+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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, { parseTransferCall } 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+
};
51+
52+
if (parsed.signature && parsed.signature.length > 0) {
53+
this._signedTransaction = rawTransaction;
54+
}
55+
56+
utils.validateRawTransaction(this._starknetTransactionData);
57+
this._id = parsed.transactionHash || '';
58+
} catch (error) {
59+
throw new InvalidTransactionError(`Invalid transaction: ${error.message}`);
60+
}
61+
}
62+
63+
/** @inheritdoc */
64+
toJson(): TxData {
65+
if (!this._starknetTransactionData) {
66+
throw new InvalidTransactionError('Empty transaction');
67+
}
68+
const transfer =
69+
this._starknetTransactionData.calls.length > 0
70+
? parseTransferCall(this._starknetTransactionData.calls[0])
71+
: undefined;
72+
return {
73+
id: this._id,
74+
sender: this._starknetTransactionData.senderAddress,
75+
recipient: transfer?.recipient,
76+
amount: transfer?.amount,
77+
nonce: this._starknetTransactionData.nonce,
78+
type: TransactionType.Send,
79+
};
80+
}
81+
82+
/** @inheritDoc */
83+
explainTransaction(): StarknetTransactionExplanation {
84+
const result = this.toJson();
85+
const displayOrder = ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee'];
86+
const outputs: TransactionRecipient[] = [];
87+
88+
if (result.recipient && result.amount) {
89+
outputs.push({
90+
address: result.recipient,
91+
amount: result.amount,
92+
});
93+
}
94+
95+
return {
96+
displayOrder,
97+
id: this.id,
98+
outputs,
99+
outputAmount: result.amount || '0',
100+
fee: { fee: '0' },
101+
type: result.type,
102+
changeOutputs: [],
103+
changeAmount: '0',
104+
};
105+
}
106+
107+
/** @inheritdoc */
108+
toBroadcastFormat(): string {
109+
const data = this._starknetTransactionData;
110+
if (!data) {
111+
throw new InvalidTransactionError('Empty transaction');
112+
}
113+
const json = JSON.stringify(data);
114+
return Buffer.from(json, 'utf-8').toString('hex');
115+
}
116+
117+
/** @inheritdoc */
118+
canSign(_key: BaseKey): boolean {
119+
return false;
120+
}
121+
}

0 commit comments

Comments
 (0)