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.
- Generate keys. Two seeds: active and backup.
- Derive the address. Offline, from the public keys alone.
- Receive funds. At an address with no code.
- First send. The account deploys itself and pays for it.
Nothing between steps 1 and 3 touches the chain.
Choose an account type
Section titled “Choose an account type”| Code ID | Type | Scheme | Rotatable | Use when |
|---|---|---|---|---|
0x03 |
Rotatable v2 | ML-DSA-65 | Yes | Default. Always, unless you have a specific reason not to. |
0x02 |
Fixed-key v1 | ML-DSA-65 | No | You need the smallest possible verification path and accept that key loss is terminal. |
0x01 |
Fixed-key v1 | ML-DSA-44 | No | Gas is the binding constraint. Cheapest verification; no Secure Enclave support. |
0x04 |
Hash-commit v3 | ML-DSA-65 | Yes | The account stores only keccak256(pk) and every spend carries the key in its witness — cheaper to create, dearer per spend. Bytecode frozen 2026-08-11; supported by frametx (-type v3 / -accounttype v3) and viem-8141’s buildSlim* builders. Not the default. |
Use the rotatable v2 account. It survives losing your signing device, it is the type the reference tooling defaults to and the one this guide walks through, 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.
1. Generate keys
Section titled “1. Generate keys”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-65backup.publicKeyKeccak; // 32-byte commitment — this is what the account storesimport secretsfrom web3_8141.signing.mldsa import MldsaSigner
active_seed = "0x" + secrets.token_bytes(32).hex()backup_seed = "0x" + secrets.token_bytes(32).hex()
active = MldsaSigner(active_seed) # .public_key is 1,952 bytesbackup = MldsaSigner(backup_seed) # .public_key_keccak is the commitmentframetx keygen -variant mldsa65# → {"scheme":"ml-dsa-65","variant":"mldsa65","seed":"0x…","publicKeyKeccak":"0x…"}2. Derive the address
Section titled “2. Derive the address”The address is a CREATE2 address computed against the factory predeployed at
0x4e59b44847b379578588920cA78FbF26c0B4956C:
initcode = [constructor | runtime | activePk (1,952 B) | keccak256(backupPk) (32 B)]salt = keccak256("frost/account/v1" ‖ 0x03 ‖ uint64_le(index))address = keccak256(0xff ‖ 0x4e59b44847b379578588920cA78FbF26c0B4956C ‖ salt ‖ keccak256(initcode))[12:]import { counterfactualAddress } from 'viem-8141';
const account = counterfactualAddress({ activePublicKey: active.publicKey, backupPublicKeyHash: backup.publicKeyKeccak,});from web3_8141.account import ( CODE_ID_ROTATABLE_V2, FACTORY_ADDRESS, account_salt, create2_address,)from web3_8141.frametx import FrametxCLI
# The two halves of the rule, if you already have the initcode:salt = account_salt(CODE_ID_ROTATABLE_V2, index=0)account = create2_address(FACTORY_ADDRESS, salt, initcode)
# web3-8141 does not assemble the initcode, so in practice:derived = FrametxCLI().address( account_type="v2", active_seed=active_seed, backup_seed=backup_seed,)account = derived["address"] # also returns salt and initcodeKeccakframetx address -type v2 \ -activeseed 0x… \ -backupseed 0x…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.
3. Receive funds
Section titled “3. Receive funds”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.
4. First send
Section titled “4. First send”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 } from 'viem-8141';
const tx = buildFirstSendTransaction({ activePublicKey: active.publicKey, backupPublicKeyHash: backup.publicKeyKeccak, recipient: '0x…', value: 4000n,});
const signed = await signFrameTransactionWitness({ transaction: tx, signer: active });const hash = await client.sendFrameTransaction({ transaction: signed });frametx send -deploy -accounttype v2 -variant mldsa65 \ -seed 0xACTIVE -backupseed 0xBACKUP \ -recipient 0x… -value 4000 -nonce 0Afterwards:
eth_getCodereturns 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.
Verifying it worked
Section titled “Verifying it worked”# code is presentcurl -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 writtencurl -s https://rpc.frostfi.net -H 'content-type: application/json' \ --data '{"jsonrpc":"2.0","id":1,"method":"eth_getStorageAt", "params":["0xACCOUNT","0x0","latest"]}'- Send a transaction — ordinary spends and contract calls.
- Rotate a key — the recovery flow. Test it before you need it.
- Account contracts — storage layout and dispatch rules.