No one at this address has posted a key.
resolve and sealTo refuse, and no seal happens.
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
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.
npx letterlock resolve agent:10260
/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.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.
pass in a fresh clone with pnpm verify: 0 failed, 12 skipped, 8 suites.
p50 on Monad mainnet, N = 1,000, 0 failed calls; p95 29.636 ms.
bench/RESULTS.mdonce per person, 0.007228026 MON at 102 gwei. Sealing sends no transaction.
deployments/143.jsoncreate, gas drip and publish, on mainnet, in the app's live check with a virtual passkey.
e2e-results/mainnet-live.jsonPosted from a terminal
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.
agent:10260 found by keyOfAgent at epoch 2, a note sealed in the page, never sent.$ 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 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 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
A failure is a typed code, never a guess and never a note sent in the clear.
resolve and sealTo refuse, and no seal happens.
Or the envelope was re-addressed to another chain, directory or recipient: HPKE's info binds each one.
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–74The contract takes only the current epoch + 1, so one publish can never use up an address's epochs.
Letterlock.t.sol:209Proven 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
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.
Enough MON for one publish, from the app's drip wallet.
0x2d9646ea…2acbmsg.sender is the account the same passkey derives: 70,863 gas.
On its own server, knowing only the address: keyOf, seal, drop from its own wallet.
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.
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
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-SHA256The same passkey derives the Monad account that posts the public half, so a key's msg.sender is the passkey itself.
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:

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-Poly1305One 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.
For your app
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
agent:<id> works wherever 0x… does, through ERC-8004's registry on Monad mainnet.NO_KEY_PUBLISHED, TAMPERED, WRONG_KEY, CHAIN_UNAVAILABLE: never a fall-back to the clear. All codes.Honest by design
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
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.
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.
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).
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).
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.
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.
Providers with the WebAuthn PRF extension, such as iCloud Keychain and Google Password Manager. Others get PRF_UNSUPPORTED, by name.
Try it yourself