Live status: when this page opens it reads keyOfAgent(10260) from Monad mainnet and asks the reference agent for its /health.

Register 143 · Monad mainnet · Track 04: Trust, Identity & AI

The seal anyone can press, and only you can break.

Monad keeps the register anyone can read. Your passkey keeps the only key that opens.

Your AI assistant keeps notes on everything you tell it, in a database you will never see. Letterlock turns your passkey into an encryption address on Monad: posting it once costs 70,863 gas, any app or agent looks it up and seals to it in 25.578 ms, and only your passkey opens the note, even in a browser that stores nothing.

Live on Monad mainnet · Sourcify exact match · 705 tests, 0 failed npx letterlock resolve agent:10260
Recreated from the live app's /seal: the key keyOfAgent(10260) returns on Monad mainnet, epoch 2, posted in block 108,354,286. The seal presses when seal() returns; nothing was sent.

The register, as of 27 September

Each line with the file that proves it. The tests were run again for this page, in a fresh clone of v0.1.0 at 10:43 UTC.

Tests

705

pass in a fresh clone with pnpm verify: 0 failed, 12 skipped, 8 suites.

DEMO.md · Reproduce the proof

Look up and seal

25.578ms

p50 on Monad mainnet, N = 1,000, 0 failed calls; p95 29.636 ms.

bench/RESULTS.md

Post a key

70,863gas

once per person, 0.007228026 MON at 102 gwei. Sealing sends no transaction.

deployments/143.json

New passkey to key on chain

3.423s

create, gas drip and publish, on mainnet, in the app's live check with a virtual passkey.

e2e-results/mainnet-live.json

Posted from a terminal

One key, looked up from anywhere.

The key the live app finds for agent:10260 is the key a laptop finds with the CLI from npm. Sealing needs no passkey, no wallet and no transaction: one read of the register, then HPKE on the sender's own machine.

letterlock-app.vercel.app/seal27 Sep 2026
The live app's /seal page: agent:10260 found in the register at epoch 2, key e5b3 0e2e 52ec 0dec, and an envelope sealed to it under a red wax seal.
The production app on Monad mainnet: agent:10260 found by keyOfAgent at epoch 2, a note sealed in the page, never sent.

letterlock@0.1.0 from npm, against Monad mainnet · 27 Sep 2026, 10:43 UTC · verbatim

$ npx letterlock resolve agent:10260agent:10260 has a key on Monad mainnet (143)  key        0xa38ed883578ddcf912a3d20f2f698ea3f15c00339c1656714402594db6324806  kid        e5b30e2e52ec0dec  epoch      2  published  2026-09-27T03:09:53.000Z (block time)  directory  0xA25BBACAb3fD2e71da1Aa002e54965B488d64b7e $ echo "the dentist moved to Thursday 10:40" | npx letterlock seal agent:10260 -{  "v": 1,  "chainId": 143,  "directory": "0xa25bbacab3fd2e71da1aa002e54965b488d64b7e",  "recipient": "agent:10260",  "epoch": 2,  "kid": "e5b30e2e52ec0dec",  "enc": "l6l7vwUiNY-dkyQxlRi12OWvGO3GNt1iq8vTnLY7OhM",  "ct": "pNVxwOyddi293OWDceF0QAl-dl3UiqZkCrx7Fp-eB80SE1H9Km7YNAbuX3adbZDmFEGlWw"}sealed 36 bytes to agent:10260 (epoch 2, kid e5b30e2e52ec0dec) on Monad mainnet (143): a 269-byte envelope

pnpm verify in a fresh clone of v0.1.0 (1c149e1) · 27 Sep 2026, 10:43 UTC · 61.8 s · its closing summary, verbatim · wider than this screen: scroll it sideways

$ pnpm verifyLetterlock verify · 2026-09-27 10:43 UTC · node v22.22.0 · forge 1.8.3 · 1c149e1   step                               result passed failed   skip  total    time  SDK: unit + anvil (vitest)         PASS      222      0      9    231   13.7s  Contracts (forge test)             PASS      111      0      0    111   24.1s  PRF spike: unit (node:test)        PASS       27      0      3     30    1.1s  Demo app (node:test)               PASS       99      0      0     99    2.2s  Reference agent (vitest)           PASS       95      0      0     95    4.2s  Offline seal/open, no network      PASS       73      0      0     73    1.7s  Scripts: seed, guards (node:test)  PASS       32      0      0     32   12.7s  Readiness checks (unittest)        PASS       46      0      0     46    2.1s  ---------------------------------- ------ ------ ------ ------ ------ -------  all steps                          PASS      705      0     12    717   61.8s

pnpm bench, 5 runs of 200 against rpc.monad.xyz · 27 Sep 2026, 07:44 UTC · the lines it prints, rebuilt from bench/results.json in scripts/bench.ts's own format, unchanged since that run · wider than this screen: scroll it sideways

$ pnpm benchbench: 5 run(s) of N=200 (+5 warm-up rounds each) against https://rpc.monad.xyz, recipient 0xFa72dA61400f345d85BF3d0d55395bDbebDB02b3 (db9784b7c246deb1), a 85-byte note  run 1/5: resolve+seal p50 25.577 ms, cold first resolve 130.267 ms, 0 failed calls  run 2/5: resolve+seal p50 25.504 ms, cold first resolve 82.431 ms, 0 failed calls  run 3/5: resolve+seal p50 25.489 ms, cold first resolve 83.254 ms, 0 failed calls  run 4/5: resolve+seal p50 25.789 ms, cold first resolve 80.377 ms, 0 failed calls  run 5/5: resolve+seal p50 25.582 ms, cold first resolve 77.184 ms, 0 failed calls  resolve          n=1000  p50    21.969  p95    23.826  p99    29.091 ms  seal             n=1000  p50     3.534  p95     5.471  p99     6.338 ms  resolve+seal     n=1000  p50    25.578  p95    29.636  p99    32.436 ms  rpc round trip   n=1000  p50    19.914  p95    21.084  p99    22.619 ms  cold first resolve p50 82.431 ms (n=5) · envelope 365 bytes · failed calls 0 measured, 0 warm-up · mismatches 0  resolve+seal p50 across runs: 25.489 to 25.789 ms (median 25.577 ms)  gas deploy                       1201505  0.12255351 MON  matches deployments/143.json  gas agentRegister                 224739  0.022923378 MON  matches deployments/143.json  gas publish                        70863  0.007228026 MON  matches deployments/143.json  gas publishForAgent               108799  0.011097498 MON  matches deployments/143.json  gas drop                           45780  0.00466956 MON  matches deployments/143.json  gas publishForAgent epoch 2        74652  0.007614504 MON  matches deployments/143.json  wrote bench/results.json and bench/RESULTS.md

Returned to sender

Four letters it will not deliver, and the test for each.

A failure is a typed code, never a guess and never a note sent in the clear.

NO_KEY_PUBLISHED

No one at this address has posted a key.

resolve and sealTo refuse, and no seal happens.

client.test.ts:36
TAMPERED

One byte changed on the way.

Or the envelope was re-addressed to another chain, directory or recipient: HPKE's info binds each one.

envelope.test.ts:85
WRONG_KEY

Another passkey answered the prompt.

Decryption fails and the envelope's key hint names a different key, so the addressee knows to try the passkey that made the address.

envelope.test.ts:68–74
EpochNotNext

A key that skips an epoch.

The contract takes only the current epoch + 1, so one publish can never use up an address's epochs.

Letterlock.t.sol:209

Proven by the test suites and the testnet end-to-end run (e2e-results/testnet.json). Nothing has reverted on mainnet, so there is no mainnet transaction to link for these.

Tracked on Monad mainnet · 27 Sep 2026

A passkey that did not exist at 05:49:40, opening an agent's letter by 05:49:58.

The app's live check drove the production site in Chromium with a virtual passkey that supports PRF, so mera's own client ran every ceremony. Each stop is a mainnet receipt, or the check's own record.

  1. A gas drip to a new passkey account

    Enough MON for one publish, from the app's drip wallet.

    0x2d9646ea…2acb
  2. The passkey's own account posts its key

    msg.sender is the account the same passkey derives: 70,863 gas.

    0xd49f8811…7ddf
  3. Agent 10260 writes to it

    On its own server, knowing only the address: keyOf, seal, drop from its own wallet.

    0x44428957…02d9
  4. Storage cleared, opened again

    The browser forgot everything; one passkey tap re-derived the key and the seal broke. Opening is local, so the record is the check's own: "storage cleared + reopen": opened.

    e2e-results/mainnet-live.json
letterlock-app.vercel.app/open27 Sep 2026
The live app's inbox for 0x4f48…463c: one sealed letter, dropped in block 108,386,081, transaction 0x444289…0602d9, and an Open with passkey button.
That account's inbox in the production app, read from the directory's Dropped events: the agent's letter, block 108,386,081. Its virtual passkey is gone, so here it stays sealed.

From a new passkey to its key on chain: 3.423 s (create, drip, publish). The drip paid 21,000 gas and the publish 70,863; the agent's drop 42,868.

How it seals

Five calls, one passkey.

Derive

Your passkey's PRF output, under Letterlock's own salt, becomes an X25519 key. Nothing is stored: by construction a synced copy of the passkey gives the same key. Verified in a browser with its storage cleared; Mac to iPad on real hardware is still pending (see Questions).

WebAuthn PRF · mera 0.2.0 · HKDF-SHA256

Publish

The same passkey derives the Monad account that posts the public half, so a key's msg.sender is the passkey itself.

publish(pub, epoch) · 70,863 gas

Resolve

Any app reads keyOf(address). An agent's key resolves only while its ERC-8004 owner is the one who posted it. The register, as the live app reads it:

The live app's /register page: every key posted to the directory on Monad mainnet, newest first, each with its epoch, key strip and transaction.
keyOf · keyOfAgent · IdentityRegistry 0x8004…a432

Seal

HPKE to the resolved key, with the chain, the directory, the recipient and the epoch bound into the key schedule. The sender needs no passkey and sends no transaction.

HPKE info of the envelope abovelp("letterlock/v1")
|| u64(143)
|| lp(0xa25bbacab3fd2e71da1aa002e54965b488d64b7e)
|| lp("agent:10260")
|| u32(2)

A 36-byte note made a 269-byte envelope. Sealing alone: p50 3.534 ms on the sender's own CPU. The lookup, one read: p50 21.969 ms.

HPKE RFC 9180 · X25519 · ChaCha20-Poly1305

Open, and rotate

One passkey tap re-derives the key and opens the note, even after the browser's storage is cleared. rotate() moves to epoch + 1 with a new salt, and every earlier epoch still re-derives, so old notes keep opening.

openWithPasskey · rotate() · agent 10260 at epoch 2

For your app

Seal to anyone in three lines.

npm i letterlockNode 20.19 or later
import { letterlock } from "letterlock";const ll = letterlock({ chain: "monad" });const envelope = await ll.sealTo("agent:10260", new TextEncoder().encode("only its key opens this"));// plain JSON: store it, post it, or drop() it on the directory
  • An agent is an address too. agent:<id> works wherever 0x… does, through ERC-8004's registry on Monad mainnet.
  • Every failure has a name. NO_KEY_PUBLISHED, TAMPERED, WRONG_KEY, CHAIN_UNAVAILABLE: never a fall-back to the clear. All codes.
  • Recipients open on one site. A passkey belongs to the site that made it, so people publish and open at the Letterlock app, and every other app seals to them.

Honest by design

What it does not do, in the README's own words.

Anyone can seal to anyone. HPKE base mode is anonymous, and a copied envelope can be dropped again. The app never shows a sender; the agent seals a nonce and a time inside each task.

README · Honest limits, 1

The rpId's host can derive every key. Whoever serves letterlock-app.vercel.app can run passkey ceremonies for it. It is a Vercel project name; a registered domain would be sturdier, and moving takes an SDK release.

README · Honest limits, 4

No recovery. If every synced copy of the passkey is lost, so is the key.

README · Honest limits, 3

All thirteen limits, and the thirteen attacks with the test that defeats each

Questions a judge would ask

Asked, and answered from the record.

Can the Letterlock app read my notes?

No server holds a key: the site keeps your passkey's id and your address, nothing else. The honest caveat is limit 4: whoever serves the pinned rpId, letterlock-app.vercel.app, could run a passkey ceremony for it. A registered domain is on the roadmap.

Does it open on my other devices?

By construction: the same synced passkey gives the same PRF output, so the same key. Verified on mainnet: a browser with its storage cleared re-derived the key and opened the note (the live check, 27 Sep, above).

Not yet verified: Mac to iPad on real hardware. The demo video will show it; until then this page does not claim it.

What is on chain?

Public keys, as KeyPublished events, and envelopes, as Dropped events: drop stores nothing. The recipient, the epoch and the timing of every drop are public (limit 2).

What is mocked?

Nothing in the flow: the mainnet directory (Sourcify exact match), the live app and the live agent, ERC-8004 agent 10260. The first keys in the register are the project's own demo and test keys, and /register labels each one (limit 13).

Why Monad?

A key directory is read on every seal and written once per person. Mainnet receipts: a first publish 70,863 gas, a drop of a 490-byte envelope 45,780 gas, both at 102 gwei. mera gives every passkey a Monad account with no wallet extension, and ERC-8004's registry on Monad mainnet gives agents an identity to publish under.

How do I seal from my app?

npm i letterlock and the three lines above, or npx letterlock resolve agent:10260 from any terminal. Every failure is a typed code, listed in docs/DX.md.

Which passkeys work?

Providers with the WebAuthn PRF extension, such as iCloud Keychain and Google Password Manager. Others get PRF_UNSUPPORTED, by name.

Try it yourself

Two devices, three steps.

  1. Create your address. Two passkey prompts, three on some browsers; a gas drip pays for the one publish.
  2. Ask the agent to write to you. It knows only your address, and cannot open what it sealed.
  3. Open it here, or on another device. Clear this browser first for the strong version.
Open the judge's route
letterlock-app.vercel.app/judge27 Sep 2026
The live app's /judge page: two devices, three steps, starting with Create my encryption address.