Keys and signing
A wallet's Ed25519 key comes from its 32-byte seed through HKDF, and a transaction is signed by hashing its canonical JSON with SHA-256 and signing those 32 bytes.
From the 24 words to the key
A wallet has one secret: a 32-byte seed, made at random when the wallet is created.
- The recovery phrase is the seed written as 24 English words: the seed is the phrase's BIP-39 entropy (
entropyToMnemonic/mnemonicToEntropy). It is not the BIP-39 PBKDF2 seed that crypto wallets usually derive from a phrase. - A passkey holds the same seed as its user handle. The web wallet creates the passkey for relying party
mlx.appwith the seed asuser.id, and reads it back fromuserHandleafter Face ID or Touch ID. A passkey whose user handle isn't 32 bytes isn't an MLX wallet.
The signing key is derived from the seed with HKDF-SHA256 (seedToSecret in lib/chain/crypto.ts):
| HKDF input | Value |
|---|---|
| hash | SHA-256 |
| input key material | the 32-byte seed |
| salt | the UTF-8 bytes of mlx-wallet-v1 |
| info | the UTF-8 bytes of ed25519-signing-key |
| length | 32 bytes |
The 32 bytes are the Ed25519 private key (seed form). Its public key, as 64 lowercase hex characters, is the wallet's pk: create_account records it, and every later transaction from the wallet must carry the same pk. One key controls one wallet.
Canonical JSON
Hashes and signatures are computed over canonical JSON (canonical in lib/chain/crypto.ts):
- object keys sorted (JavaScript's default string sort), at every level;
- keys whose value is
undefinedleft out;nullis kept; - no whitespace;
- strings and numbers written as
JSON.stringifywrites them, so non-ASCII characters stay as they are and are hashed as UTF-8; - arrays keep their order.
function canonical(v) {
if (v === null || typeof v !== "object") return JSON.stringify(v);
if (Array.isArray(v)) return "[" + v.map(canonical).join(",") + "]";
const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort();
return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonical(v[k])).join(",") + "}";
}The bytes that are signed
For a transaction tx with the fields chain, from, nonce, ts, pk and body (txHash and signTx in lib/chain/types.ts):
- Take the transaction without
sig. text = canonical(tx).hash = SHA-256(UTF-8 bytes of text), 32 bytes. Its lowercase hex is the transaction hash.sig = Ed25519.sign(hash, secret): the signature is over the 32 raw hash bytes, not over the hex string and not over the JSON.- Send the transaction with
sigadded, as 128 hex characters.
The ledger checks the same way (verifyTx): it recomputes the hash from the received fields and verifies sig against pk. Any change to any field, including ts, gives another hash and a signature that no longer verifies.
For the transfer on the overview, step 2 gives:
{"body":{"amount":"2500000","to":"shop.mlx","token":"USDC","type":"transfer"},"chain":"mlx-1","from":"luca.mlx","nonce":4,"pk":"7812b1435b317df04f45731ac508ae7774d4731ee0445fd46ccdcc5019faa524","ts":1793116800000}Worked example
This script derives the key from a recovery phrase and signs a transfer of 2.5 USDC from luca.mlx to shop.mlx. It uses only @noble/curves, @noble/hashes and @scure/bip39, the libraries the wallet uses (tested with @noble/curves 1.9, @noble/hashes 1.8, @scure/bip39 1.6).
npm install @noble/curves@1.9 @noble/hashes@1.8 @scure/bip39@1.6import { ed25519 } from "@noble/curves/ed25519";
import { sha256 } from "@noble/hashes/sha2";
import { hkdf } from "@noble/hashes/hkdf";
import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/hashes/utils";
import { mnemonicToEntropy } from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english";
// 1. The 24 words are the 32-byte seed (BIP-39 entropy). A public test phrase: never use it for real funds.
const phrase =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
const seed = mnemonicToEntropy(phrase, wordlist);
// 2. The signing key: HKDF-SHA256(seed, salt "mlx-wallet-v1", info "ed25519-signing-key", 32 bytes).
const secret = hkdf(sha256, seed, utf8ToBytes("mlx-wallet-v1"), utf8ToBytes("ed25519-signing-key"), 32);
const pk = bytesToHex(ed25519.getPublicKey(secret));
// 3. Canonical JSON: keys sorted, no whitespace, undefined left out.
function canonical(v) {
if (v === null || typeof v !== "object") return JSON.stringify(v);
if (Array.isArray(v)) return "[" + v.map(canonical).join(",") + "]";
const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort();
return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonical(v[k])).join(",") + "}";
}
// 4. The transaction without `sig`. Fixed nonce and time so the output below is reproducible.
const tx = {
chain: "mlx-1",
from: "luca.mlx",
nonce: 4,
ts: 1793116800000,
pk,
body: { type: "transfer", to: "shop.mlx", token: "USDC", amount: "2500000" }
};
// 5. hash = SHA-256 of the canonical JSON; sig = Ed25519 over the 32 hash bytes.
const hash = bytesToHex(sha256(utf8ToBytes(canonical(tx))));
const sig = bytesToHex(ed25519.sign(hexToBytes(hash), secret));
console.log("pk ", pk);
console.log("hash", hash);
console.log("sig ", sig);
console.log(JSON.stringify({ tx: { ...tx, sig } }));pk 7812b1435b317df04f45731ac508ae7774d4731ee0445fd46ccdcc5019faa524
hash 977ce97b9880accccb1263c207dba53bdfb0419646a3831c127ced2e07b1d024
sig ce8c7621b38777895b06107168e08a542bf9d5fbaf2ac53bb1b9a1b1d3ade2635d5c00150a2f76990b49330365d964c2b55d3289bbc9cc3e380550a2715cb10f
{"tx":{"chain":"mlx-1","from":"luca.mlx","nonce":4,"ts":1793116800000,"pk":"7812b1435b317df04f45731ac508ae7774d4731ee0445fd46ccdcc5019faa524","body":{"type":"transfer","to":"shop.mlx","token":"USDC","amount":"2500000"},"sig":"ce8c7621b38777895b06107168e08a542bf9d5fbaf2ac53bb1b9a1b1d3ade2635d5c00150a2f76990b49330365d964c2b55d3289bbc9cc3e380550a2715cb10f"}}Ed25519 signatures are deterministic, so the same phrase and fields give exactly this signature, and the ledger's own verifyTx accepts it. The output is the body for POST /api/tx.
Signing a real transaction
For a transaction the ledger will take, read the chain id and the wallet's nonce first and sign with the current time:
const { chain } = await (await fetch("https://mlx.app/api/stats")).json();
const { account } = await (await fetch("https://mlx.app/api/account/luca.mlx")).json();
const live = { chain, from: account.name, nonce: account.nonce + 1, ts: Date.now(), pk, body: tx.body };
const liveHash = bytesToHex(sha256(utf8ToBytes(canonical(live))));
const res = await fetch("https://mlx.app/api/tx", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ tx: { ...live, sig: bytesToHex(ed25519.sign(hexToBytes(liveHash), secret)) } })
});
console.log(res.status, await res.json());The public key must be the one account.pk shows; otherwise the reply is "Signer does not control this account".
Checking a published transaction
Every transaction on the explorer can be checked the same way: take tx from GET /api/tx/<hash>, drop sig, hash its canonical JSON and verify sig against pk. The hash you compute must equal the record's hash.
One exception: on public reads, a bind_device record shows the kind of Mac instead of its computer name and leaves out the Mac's proof (marked "nameHidden": true), so its hash can't be recomputed from what is published.
Other things a wallet signs
The same key signs a few requests that are not transactions. Each is a SHA-256 hash of canonical JSON, signed like a transaction hash:
| What | Canonical object | Used by |
|---|---|---|
| Read the wallet's own device list | { "type": "mlx-devices", "chain", "account", "ts" } | POST /api/account/<name>/devices (devicesHash in lib/wallet/devices.ts) |
| Read the names of the wallet's own Macs | { "type": "mlx-macs", "chain", "account", "ts" } | POST /api/account/<name>/macs (macsHash) |
A Mac's own key is P-256 in its Secure Enclave, not the wallet key. It signs its link request and its requests to the network; see Worker protocol.