Skip to content

Create a wallet

Creating a post-quantum account on Frost has an unusual shape, because the account does not have to exist before it can receive money.

  1. Generate keys. Two seeds: active and backup.
  2. Derive the address. Offline, from the public keys alone.
  3. Receive funds. At an address with no code.
  4. First send. The account deploys itself and pays for it.

Nothing between steps 1 and 3 touches the chain.

Code ID Type Scheme Rotatable Use when
{ACCOUNTS.codeIds.rotatableV2} Rotatable v2 ML-DSA-65 Yes Default. Always, unless you have a specific reason not to.
{ACCOUNTS.codeIds.fixedMldsa65} Fixed-key v1 ML-DSA-65 No You need the smallest possible verification path and accept that key loss is terminal.
{ACCOUNTS.codeIds.fixedMldsa44} Fixed-key v1 ML-DSA-44 No Gas is the binding constraint. Cheapest verification; no Secure Enclave support.

Use the rotatable v2 account. It is the only type that survives losing your signing device, it is the type the reference tooling defaults to, and ML-DSA-65 is the parameter set Apple’s Secure Enclave can produce signatures for.

The fixed-key types are simpler and marginally cheaper, but their key is welded into the contract’s code. If you lose it, the account is unreachable forever.

An ML-DSA secret key is derived from a 32-byte seed, so the seed is the secret key. Generate it from a cryptographically secure source.

import { randomBytes } from 'node:crypto';
import { bytesToHex } from 'viem';
import { createMldsaSigner } from 'viem-8141';
const active = createMldsaSigner({ seed: bytesToHex(randomBytes(32)) });
const backup = createMldsaSigner({ seed: bytesToHex(randomBytes(32)) });
active.publicKey; // 1,952 bytes, ML-DSA-65
backup.publicKeyKeccak; // 32-byte commitment — this is what the account stores

The address is a CREATE2 address computed against the factory predeployed at {ACCOUNTS.factory}:

initcode = [constructor | runtime | activePk (1,952 B) | keccak256(backupPk) (32 B)]
salt = keccak256("{ACCOUNTS.saltDomain}" ‖ {ACCOUNTS.codeIds.rotatableV2} ‖ uint64_le(index))
address = keccak256(0xff ‖ {ACCOUNTS.factory} ‖ salt ‖ keccak256(initcode))[12:]
import { counterfactualAddress } from 'viem-8141';
const account = counterfactualAddress({
activePublicKey: active.publicKey,
backupPublicKeyHash: backup.publicKeyKeccak,
});

The index in the salt lets one keypair address multiple accounts. Leave it at 0 unless you need that.

This computation involves no network access. A wallet can show a receiving address the instant the key exists.

Send funds to the address like any other. eth_getCode will return 0x and eth_getTransactionCount will return 0 — the account genuinely does not exist yet, and that is fine. Balances live in the state trie keyed by address; they do not require code.

Use the faucet on the testnet.

The first transaction from the account carries a DEPLOY frame. It materialises the contract through the factory and then does whatever you asked for, all in one transaction, paid for from the balance already at the address.

import { buildFirstSendTransaction, signFrameTransactionWitness,
serializeFrameTransaction } from 'viem-8141';
const tx = buildFirstSendTransaction({
activePublicKey: active.publicKey,
backupPublicKeyHash: backup.publicKeyKeccak,
recipient: '0x…',
value: 4000n,
});
const signed = await signFrameTransactionWitness(tx, active);
const hash = await client.sendFrameTransaction(serializeFrameTransaction(signed));

Afterwards:

  • eth_getCode returns the account runtime.
  • Storage slot 0 holds the address of a pointer contract containing the active public key; slot 1 holds keccak256(backupPk).
  • The nonce is 3.
Terminal window
# code is present
curl -s https://rpc.frostfi.net -H 'content-type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"eth_getCode","params":["0xACCOUNT","latest"]}'
# both storage slots are written
curl -s https://rpc.frostfi.net -H 'content-type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"eth_getStorageAt",
"params":["0xACCOUNT","0x0","latest"]}'