Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

C5 — Derive, don't invent

From cognokratos/arktos-wallet · docs/capability/05-derive-dont-invent.md · pinned revision 92650a034799

Cryptographic identity should come from deterministic standards, never from model reasoning.

Question: an address, a derivation path or a checksum each has exactly one correct value for a given wallet and index. Which component should produce that value, and what makes the result trustworthy?

This lesson is not a blockchain primer. It treats BIP39, BIP32, BIP44, BIP86 and EIP-55 purely as software contracts: what they make reproducible, what they make canonical, and what breaks when you deviate from them.

Mental model

A model asked for "the Ethereum address of wallet main, index 0" can produce a string that looks right: 0x, 40 hex characters, plausible mixed case. A look-alike that is wrong is worse than an error, because it is accepted, and funds sent to it are gone.

The standards turn that question into a pure function:

f(recovery phrase, chain, network, index) → (path, public key, address)

Same inputs, same outputs, on any compliant implementation, forever. So the right division of labor is:

DecisionWho makes it
Which wallet, which indexThe caller (model), constrained by schema and ownership
Which networkServer configuration (BITCOIN_NETWORK). Never the model
The path, the key, the address, the checksumThe standards, executed by Arktos

The two derivations

Bitcoin (configured network):

BIP39 phrase → seed
  ↓ BIP32
m/86'/coin'/0'/0/index        coin = 0' on mainnet, 1' on testnet/signet/regtest
  ↓ BIP86 (key-path-only Taproot, BIP341 tweak with no script tree)
P2TR address, bech32m          bc1p… | tb1p… | bcrt1p…

Ethereum (any EVM chain):

BIP39 phrase → seed
  ↓ BIP32
m/44'/60'/0'/0/index
  ↓ secp256k1 public key, uncompressed, without the 0x04 prefix
  ↓ Keccak-256, last 20 bytes
canonical lowercase hex        stored in accounts.address
  ↓ EIP-55 mixed-case checksum
returned address

In the code

PropertyWhere it is enforced
One source of truth for pathsNetwork::derivation_path in src/domain.rs
The database agreesA CHECK constraint in migrations/V2__account_network.sql rejects any row whose derivation_path is not the canonical path for its chain, network and index, and any Ethereum address that is not lowercase
Stored equals derivedWalletStore::insert_account in src/wallet_store.rs: if a concurrent request already stored the row, its address and public key must equal the new derivation, or the call fails with CorruptData
Typed inputsDerivationIndex (non-hardened, < 2^31), WalletName, BitcoinNetwork (exactly mainnet/testnet/signet/regtest; Mainnet is rejected), EthereumChainId (positive) in src/domain.rs and src/config.rs. Invalid configuration fails at startup and is never defaulted
Pinned vectorssrc/wallet_manager.rs tests: BIP86 reference vectors, independently generated test-network vectors, BIP44 Ethereum vectors, and the EIP-55 specification vectors, all for the public abandon … about test phrase
Network is identityaccounts is unique on (wallet_id, chain_type, network, account_index). V2 added network because BITCOIN_NETWORK can change between restarts

One naming wrinkle: the API calls the last path component account_index, but in BIP44 terms it is the address index (the account' level is fixed at 0'). src/domain.rs records the name as kept "for compatibility". It is a small example of a public name that is now part of the contract.

Lab

Observe: published vectors

cargo test --lib wallet_manager::tests
cargo test --test secret_storage_tests known_mnemonic_derives_published_addresses_end_to_end

The second test seals the public test phrase exactly as create_wallet would, then goes through the full service path: decrypt, derive, store, return. It checks that the result is the BIP86 and BIP44 reference addresses. Any other standards-compliant wallet restoring that phrase arrives at the same addresses. Interoperability is a recovery property.

Predict, then observe: stability

With the lab server:

mcp "$A" create_wallet '{"wallet_name":"det"}'
mcp "$A" get_bitcoin_address '{"wallet_name":"det","account_index":0}'
mcp "$A" get_bitcoin_address '{"wallet_name":"det","account_index":0}'
mcp "$A" get_bitcoin_address '{"wallet_name":"det","account_index":1}'

The two index-0 results are identical, including created_at, because the second call reads the stored row. Index 1 has a different path, key and address.

Break: change the network

Stop the server and restart it with a different Bitcoin network, keeping the same database and keys. Before each call, predict the path prefix, the address prefix, and whether the public key changes.

BITCOIN_NETWORK=testnet cargo run --quiet --bin arktos-wallet   # then:
mcp "$A" get_bitcoin_address '{"wallet_name":"det","account_index":0}'

BITCOIN_NETWORK=signet cargo run --quiet --bin arktos-wallet    # then the same call
BITCOIN_NETWORK=regtest cargo run --quiet --bin arktos-wallet   # then the same call

Inspect

NetworkPathPublic key vs mainnetAddress prefix
mainnetm/86'/0'/0'/0/0n/abc1p
testnetm/86'/1'/0'/0/0different: coin type 1' is a different branch of the treetb1p
signetm/86'/1'/0'/0/0same key as testnettb1p, the same address as testnet
regtestm/86'/1'/0'/0/0same key as testnetbcrt1p, the same key with a different encoding

So "network" means two separate things: the key domain (coin type, which selects which key) and the encoding domain (the bech32 human-readable part, which selects how the key is written). cargo test --lib bitcoin_addresses_parse_for_their_network_only shows the encoding domain doing its job, because a test-network address does not parse as valid for mainnet. List the stored rows to see that each network got its own accounts row:

labsql "SELECT chain_type, network, account_index, derivation_path, address FROM accounts ORDER BY id;"

Break: change the EVM chain

ETHEREUM_CHAIN_ID=11155111 cargo run --quiet --bin arktos-wallet   # then:
mcp "$A" get_ethereum_address '{"wallet_name":"det","account_index":0}'

The address is unchanged and only chain_id differs. Ethereum addresses do not depend on the chain, so Arktos stores Ethereum accounts under the single network value evm and does not store the chain ID at all. Why report it then? The chain ID becomes essential the moment anything is signed: EIP-155 puts it into the transaction signature so that a transaction signed for one chain cannot be replayed on another. The address identifies an account, and the chain ID identifies a transaction domain. See the case study.

Explain

An agent reports "your address is 0x9858Ef…" from a conversation an hour ago. Should a downstream system trust that string, or call get_ethereum_address again? Consider cost, staleness, and the fact that a model's memory of an address is text it produced, while the tool result is the output of a function.

Failure mode

  • Asking the model to compute, reformat or recall an address, path or checksum.
  • Letting the model choose the network ("use testnet for this one"), which turns configuration into a prompt-injectable parameter.
  • A second, slightly different path formatter somewhere else in the code. The single derivation_path function removes the reason to write one, and the database CHECK rejects any non-canonical path that reaches storage.
  • Changing a derivation detail without pinned vectors to catch it. Every address already handed out would silently stop being reproducible.

Takeaway

If a standard defines the answer deterministically, don't delegate it to a probabilistic system.

Deterministic cryptography should remain deterministic.

Previous: C4 · Next: C6 — Bind identity to capability

This chapter is maintained in cognokratos/arktos-wallet beside the code it teaches. The book shows docs/capability/05-derive-dont-invent.md at revision 92650a0347993622cbb3e5eeac2c908069c67fd6 (branch main). View source at this revision · Report a correction.

Corrections are made upstream against the current main branch and appear here when the book's pin for this source is updated.