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 Stark private key is derived entirely locally — the only WaaS prompt is the secp256k1 key recovery.
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"; // trufflehog:ignore
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(): Promise<{
  address: string;
  publicKey: string;
  privateKey: string;
}> {
  const compressedPubkey = await recoverEvmPubkey("STARKNET_PUBKEY_RECOVERY");

  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 };
}
Cache the result of deriveStarknetAddress per session. Key recovery is the only WaaS prompt — all Starknet signing uses the locally-derived Stark private key.

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);
}

Sign a Transaction

Starknet transactions are signed entirely locally using the derived Stark private key — no additional WaaS calls are needed:
import { sign, poseidonHashMany } from "@scure/starknet";

async function sendStarknetTransfer(
  to: string,
  amount: number,
  address: string,
  publicKey: string,
  privateKey: string,
): Promise<string> {
  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"; // trufflehog:ignore
  const transferSelector = BigInt(
    "0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e", // trufflehog:ignore
  );

  const calldata = [
    "0x1",
    STRK_TOKEN,
    feltToHex(transferSelector),
    "0x3",
    to,
    amountLow,
    amountHigh,
  ];

  const chainId = BigInt("0x534e5f5345504f4c4941"); // SN_SEPOLIA
  const calldataFelts = calldata.map(c => BigInt(c));

  const resourceBounds = poseidonHashMany([
    BigInt("0x4c315f47415300000000000000000000"),
    BigInt(0x2000), BigInt("0x1000000000000"),
    BigInt("0x4c325f47415300000000000000000000"),
    BigInt(0x200000), BigInt("0x10000000000"),
    BigInt("0x4c315f444154415f47415300000000000000000000"),
    BigInt(0x2000), BigInt("0x100000"),
  ]);

  const txHash = poseidonHashMany([
    BigInt("0x696e766f6b65"),
    BigInt(3),
    BigInt(address),
    poseidonHashMany([BigInt(0), resourceBounds]),
    poseidonHashMany([]),
    chainId,
    nonce,
    BigInt(0),
    poseidonHashMany([]),
    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;
}

Verify a Signature

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);
}
For full implementation details including account deployment see the JavaScript Starknet guide. The account deployment transaction is also signed entirely locally — no React Native-specific changes are needed.