Skip to main content
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 via grindKey. 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.
PropertyValue
Curvesecp256k1 → Stark curve
Root WalletEVM
Address FormatPedersen-hashed contract address
HashingPoseidon (transactions), Pedersen (address)
SerializationStarkNet JSON-RPC
TokenSTRK (18 decimals)

Dependencies

npm install @noble/curves @noble/hashes @scure/starknet

Derive Address

Recover the secp256k1 key, derive a Stark private key via grindKey, 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-20 balanceOf 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.