MLXDocs
Transactions

Keys and signing

Markdown

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.app with the seed as user.id, and reads it back from userHandle after 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 inputValue
hashSHA-256
input key materialthe 32-byte seed
saltthe UTF-8 bytes of mlx-wallet-v1
infothe UTF-8 bytes of ed25519-signing-key
length32 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 undefined left out; null is kept;
  • no whitespace;
  • strings and numbers written as JSON.stringify writes them, so non-ASCII characters stay as they are and are hashed as UTF-8;
  • arrays keep their order.
canonical
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):

  1. Take the transaction without sig.
  2. text = canonical(tx).
  3. hash = SHA-256(UTF-8 bytes of text), 32 bytes. Its lowercase hex is the transaction hash.
  4. sig = Ed25519.sign(hash, secret): the signature is over the 32 raw hash bytes, not over the hex string and not over the JSON.
  5. Send the transaction with sig added, 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:

canonical(tx)
{"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).

Install
npm install @noble/curves@1.9 @noble/hashes@1.8 @scure/bip39@1.6
sign.mjs
import { 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 } }));
Output of node sign.mjs
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:

send.mjs (after the definitions above)
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:

WhatCanonical objectUsed 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.