Skip to content

Accounts and keys

This is the page that most changes how you use the chain, so it is worth reading before you write any code.

Frost has no post-quantum externally-owned account. Protocol-level accounts do not carry ML-DSA keys, and no address is derived from an ML-DSA public key. Instead, a post-quantum account is a smart contract: a small piece of code that holds its own public key and verifies its own signatures in a VERIFY frame.

This is not a workaround, it is the only shape that works. A 1,952-byte public key cannot be compressed into a 20-byte address, and lattice signatures offer no public-key recovery, so there is nothing for the protocol to derive an EOA from. Putting the key in the account solves both problems at once — and, as a bonus, means the chain needs no registry mapping addresses to keys.

Ordinary secp256k1 EOAs still work on Frost, for compatibility. They are simply not quantum-safe, and the chain’s own treasury and deployment flow do not use them.

An account’s address is a pure function of its code and its key material, so it can be computed entirely offline, before anything exists on chain:

initcode = [constructor | runtime | key material]
salt = keccak256("frost/account/v1" ‖ ACCOUNT_CODE_ID ‖ uint64_le(index))
address = keccak256(0xff ‖ FACTORY ‖ salt ‖ keccak256(initcode))[12:]

where FACTORY is the deterministic CREATE2 factory predeployed at 0x4e59b448…B4956C.

The practical consequence is the thing to internalise: you can receive funds at an address that does not exist yet. A wallet can generate a key and display a receiving address immediately, with no transaction, no deployment, and no gas. Funds accumulate at an address with no code, which is perfectly normal.

The account then materialises itself. Its first outgoing transaction carries a DEPLOY frame that deploys the contract and pays for that deployment out of the balance already sitting at the address. There is no separate “deploy your wallet” step, no sponsor required, and no moment where the user must hold a different kind of asset to get started.

Frost ships a small set of pre-built account contracts. There is no Solidity involved: each is hand-assembled EVM bytecode, generated by the reference tooling, so the code is small, auditable, and identical for every user of that type.

Code ID Type Scheme Where the key lives
{ACCOUNTS.codeIds.fixedMldsa44} Fixed-key v1 ML-DSA-44 (0x15) Embedded directly in the contract’s code
{ACCOUNTS.codeIds.fixedMldsa65} Fixed-key v1 ML-DSA-65 (0x14) Embedded directly in the contract’s code
{ACCOUNTS.codeIds.rotatableV2} Rotatable v2 ML-DSA-65 (0x14) Active key behind a storage pointer; backup key committed by hash

Fixed-key accounts are the simplest thing that works. The public key is part of the contract code, so verification is a code copy and a precompile call. The key can never be changed — if you lose it, the account is gone.

The rotatable v2 account is the one to use by default. It holds two keys:

  • an active key, stored behind a pointer contract, used for everyday spends;
  • a backup key, of which the account stores only keccak256(backupPk).

Losing the device holding the active key is survivable. The backup key authorises a rotation that installs a new active key and a new backup commitment, at the same address. The rotation path verifies the backup authorisation specifically, so a compromised active key can never rotate the account — it can spend, but it cannot lock you out. Every rotation consumes the old backup commitment and installs a fresh one.

ML-DSA-65 is not an arbitrary choice for the rotatable account: it is the parameter set Apple’s Secure Enclave supports, which is what makes hardware custody possible. See Hardware custody.

Account bytecode is fixed by hash and never edited. A change to the code is definitionally a new code ID, never a modification of an existing one.

The reason is a direct consequence of counterfactual addressing. If the bytecode for code ID 0x03 changed, every not-yet-deployed account of that type would silently re-derive to a different address — stranding any funds already sent to the old one, with no way to reach them. So the rule is absolute, and it is enforced by tests that pin the code hashes.

Putting the pieces together, an ordinary transfer from a rotatable account is:

frames:
[0] VERIFY target: (self) flags: 3 gas: 30,000 data: (empty)
[1] SENDER target: recipient value: … gas: 50,000
signatures:
[0] {scheme: ARBITRARY, msg: (empty), signature: ML-DSA-65 over the sig-hash}

The VERIFY frame runs the account’s code, which copies the signature out of the transaction with SIGPARAM, loads its own active public key, calls the 0x14 precompile, and APPROVEs only if the precompile returns 1.

Note what the account does not do: it never trusts a public key supplied by the transaction. The key comes from its own state or its own code. That binding is what makes the verification mean anything.