Experimental. Starknet support is experimental. The derivation path and account contract format may change. Validate against the latest OpenZeppelin account class hash and StarkNet JSON-RPC spec before using in production.
Overview
Starknet uses a unique derivation path: the secp256k1 public key from the EVM wallet is used as a seed to derive a Stark-curve private key viagrindKey. This produces a Stark public key and a counterfactual contract address computed via Pedersen hashing. The account must be deployed before it can send transactions.
| Property | Value |
|---|---|
| Curve | secp256k1 → Stark curve |
| Root Wallet | EVM |
| Address Format | Pedersen-hashed contract address |
| Hashing | Poseidon (transactions), Pedersen (address) |
| Serialization | StarkNet JSON-RPC |
| Token | STRK (18 decimals) |
Dependencies
npm install @noble/curves @noble/hashes @scure/starknet
Derive Address
Recover the secp256k1 key, derive a Stark private key viagrindKey, compute the public key and counterfactual OpenZeppelin account address:
import { grindKey, getStarkKey, pedersen } from "@scure/starknet";
const OZ_ACCOUNT_CLASS_HASH =
"0x061dac032f228abef9c6626f995015233097ae253a7f72d68552db02f2971b8f";
const STARK_PRIME = BigInt(
"0x800000000000011000000000000000000000000000000000000000000000001",
);
function feltToHex(n: bigint): string {
return "0x" + n.toString(16);
}
function ped(a: bigint, b: bigint): bigint {
return BigInt(pedersen(a, b));
}
function computeHashOnElements(elements: bigint[]): bigint {
let h = BigInt(0);
for (const e of elements) h = ped(h, e);
return ped(h, BigInt(elements.length));
}
function computeContractAddress(
classHash: bigint,
salt: bigint,
constructorCalldata: bigint[],
): string {
const prefix = BigInt("0x535441524b4e45545f434f4e54524143545f41444452455353");
const calldataHash = computeHashOnElements(constructorCalldata);
const address = computeHashOnElements([
prefix, BigInt(0), salt, classHash, calldataHash,
]);
return feltToHex(address % STARK_PRIME);
}
async function deriveStarknetAddress(
evmWallet: WalletAccount,
dynamicClient: DynamicClient,
): Promise<{ address: string; publicKey: string; privateKey: string }> {
const compressedPubkey = await recoverEvmPublicKey(
"STARKNET_PUBKEY_RECOVERY",
evmWallet,
dynamicClient,
);
// grindKey: iterative SHA-256 until result is within Stark curve order
const privateKeyHex = grindKey(compressedPubkey);
const publicKey = getStarkKey(privateKeyHex);
const publicKeyBigInt = BigInt(publicKey);
const classHash = BigInt(OZ_ACCOUNT_CLASS_HASH);
const address = computeContractAddress(classHash, publicKeyBigInt, [publicKeyBigInt]);
return { address, publicKey, privateKey: "0x" + privateKeyHex };
}
Sign a Message
Hash the message with SHA-256, reduce modulo the Stark prime, and sign with the Stark private key:import { sign } from "@scure/starknet";
import { sha256 } from "@noble/hashes/sha2";
function signStarknetMessage(message: string, privateKey: string): string {
const messageBytes = new TextEncoder().encode(message);
const sha256Hash = sha256(messageBytes);
const msgFelt = BigInt("0x" + bytesToHex(sha256Hash)) % STARK_PRIME;
const sig = sign(feltToHex(msgFelt), strip0x(privateKey));
return feltToHex(sig.r) + ":" + feltToHex(sig.s);
}
Verify a Signature
Reconstruct the message hash and verify using the full Stark public key:import { verify, getPublicKey } from "@scure/starknet";
import { sha256 } from "@noble/hashes/sha2";
function verifyStarknetSignature(
message: string,
signature: string,
privateKey: string,
): boolean {
const messageBytes = new TextEncoder().encode(message);
const sha256Hash = sha256(messageBytes);
const msgFelt = BigInt("0x" + bytesToHex(sha256Hash)) % STARK_PRIME;
const [rHex, sHex] = signature.split(":");
if (!rHex || !sHex) return false;
const sigHex = strip0x(rHex).padStart(64, "0") + strip0x(sHex).padStart(64, "0");
const fullPubKey = getPublicKey(strip0x(privateKey), true);
return verify(sigHex, feltToHex(msgFelt), fullPubKey);
}
Check Balance
Query the STRK token balance via the ERC-20balanceOf function:
async function getStarknetBalance(address: string): Promise<string> {
const STRK_TOKEN = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d";
const BALANCE_OF_SELECTOR =
"0x2e4263afad30923c891518314c3c95dbe830a16874e8abc5777a9a20b54c76e";
const result = await starknetRpc("starknet_call", [{
contract_address: STRK_TOKEN,
entry_point_selector: BALANCE_OF_SELECTOR,
calldata: [address],
}, "latest"]);
const arr = result as string[];
if (!arr || arr.length === 0) return "0";
const low = BigInt(arr[0]);
const high = arr.length > 1 ? BigInt(arr[1]) : BigInt(0);
const balance = low + (high << BigInt(128));
const whole = balance / BigInt(10 ** 18);
const frac = balance % BigInt(10 ** 18);
if (frac === BigInt(0)) return whole.toString();
const fracStr = frac.toString().padStart(18, "0").replace(/0+$/, "");
return `${whole}.${fracStr}`;
}
Deploy Account
The account must be deployed before sending transactions. This is a one-time operation:import { sign, poseidonHashMany } from "@scure/starknet";
function hashFeeFields(tip: bigint): bigint {
const resourceBounds = poseidonHashMany([
BigInt("0x4c315f47415300000000000000000000"),
BigInt(0x2000), BigInt("0x1000000000000"),
BigInt("0x4c325f47415300000000000000000000"),
BigInt(0x200000), BigInt("0x10000000000"),
BigInt("0x4c315f444154415f47415300000000000000000000"),
BigInt(0x2000), BigInt("0x100000"),
]);
return poseidonHashMany([tip, resourceBounds]);
}
async function deployStarknetAccount(
address: string,
publicKey: string,
privateKey: string,
): Promise<string> {
const classHash = BigInt(OZ_ACCOUNT_CLASS_HASH);
const pubKeyFelt = BigInt(publicKey);
const chainId = BigInt("0x534e5f5345504f4c4941"); // SN_SEPOLIA
// Compute deploy_account transaction hash using Poseidon
const txHash = poseidonHashMany([
BigInt("0x6465706c6f795f6163636f756e74"), // "deploy_account"
BigInt(3), // version
BigInt(address),
hashFeeFields(BigInt(0)),
poseidonHashMany([]), // paymaster_data
chainId,
BigInt(0), // nonce
BigInt(0), // DA mode
poseidonHashMany([pubKeyFelt]), // constructor_calldata_hash
classHash,
pubKeyFelt, // salt
]);
const sig = sign(feltToHex(txHash), strip0x(privateKey));
const result = await starknetRpc("starknet_addDeployAccountTransaction", {
deploy_account_transaction: {
type: "DEPLOY_ACCOUNT",
version: "0x3",
signature: [feltToHex(sig.r), feltToHex(sig.s)],
nonce: "0x0",
contract_address_salt: feltToHex(pubKeyFelt),
constructor_calldata: [feltToHex(pubKeyFelt)],
class_hash: OZ_ACCOUNT_CLASS_HASH,
resource_bounds: {
l1_gas: { max_amount: "0x2000", max_price_per_unit: "0x1000000000000" },
l2_gas: { max_amount: "0x200000", max_price_per_unit: "0x10000000000" },
l1_data_gas: { max_amount: "0x2000", max_price_per_unit: "0x100000" },
},
tip: "0x0",
paymaster_data: [],
nonce_data_availability_mode: "L1",
fee_data_availability_mode: "L1",
},
});
return result.transaction_hash;
}
Send a Transfer
Send STRK tokens using an INVOKE v3 transaction:import { sign, poseidonHashMany } from "@scure/starknet";
async function sendStarknetTransfer(
to: string,
amount: number,
address: string,
publicKey: string,
privateKey: string,
): Promise<string> {
// Convert amount to u256 (18 decimals)
const amountWei = BigInt(Math.round(amount * 10 ** 18));
const amountLow = feltToHex(amountWei & ((BigInt(1) << BigInt(128)) - BigInt(1)));
const amountHigh = feltToHex(amountWei >> BigInt(128));
const nonce = await getNonce(address);
const STRK_TOKEN = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d";
const transferSelector = BigInt(
"0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e",
);
// Calldata for __execute__
const calldata = [
"0x1", // number of calls
STRK_TOKEN, // contract address
feltToHex(transferSelector), // selector
"0x3", // calldata length
to, // recipient
amountLow, // amount low
amountHigh, // amount high
];
const chainId = BigInt("0x534e5f5345504f4c4941");
const calldataFelts = calldata.map(c => BigInt(c));
// Compute INVOKE v3 transaction hash
const txHash = poseidonHashMany([
BigInt("0x696e766f6b65"), // "invoke"
BigInt(3), // version
BigInt(address),
hashFeeFields(BigInt(0)),
poseidonHashMany([]), // paymaster_data
chainId,
nonce,
BigInt(0), // DA mode
poseidonHashMany([]), // account_deployment_data
poseidonHashMany(calldataFelts),
]);
const sig = sign(feltToHex(txHash), strip0x(privateKey));
const result = await starknetRpc("starknet_addInvokeTransaction", {
invoke_transaction: {
type: "INVOKE",
version: "0x3",
signature: [feltToHex(sig.r), feltToHex(sig.s)],
nonce: feltToHex(nonce),
sender_address: address,
calldata,
resource_bounds: {
l1_gas: { max_amount: "0x2000", max_price_per_unit: "0x1000000000000" },
l2_gas: { max_amount: "0x200000", max_price_per_unit: "0x10000000000" },
l1_data_gas: { max_amount: "0x2000", max_price_per_unit: "0x100000" },
},
tip: "0x0",
paymaster_data: [],
account_deployment_data: [],
nonce_data_availability_mode: "L1",
fee_data_availability_mode: "L1",
},
});
return result.transaction_hash;
}
The account must be deployed and funded with STRK before sending transactions. If the account is not deployed, call
deployStarknetAccount first.