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 |
|---|---|---|---|---|
{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.
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 = MldsaSigner(secrets.token_bytes(32))backup = MldsaSigner(secrets.token_bytes(32))frametx keygen -variant mldsa65# → {"scheme":"ml-dsa","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
{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,});from web3_8141.account import ( CODE_ID_ROTATABLE_V2, FACTORY_ADDRESS, account_salt, create2_address,)
salt = account_salt(CODE_ID_ROTATABLE_V2, index=0)account = create2_address(FACTORY_ADDRESS, salt, initcode)frametx 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, 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));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.