npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

letterlock

v0.1.2

Published

A passkey becomes an encryption address on Monad: resolve keyOf(address) and seal data only that passkey can open, on any device it syncs to.

Readme

letterlock

A passkey becomes an encryption address on Monad. The owner's device derives an X25519 key from the passkey's WebAuthn PRF output (through mera) and publishes only the public half in the Letterlock directory contract. Anyone resolves keyOf(address) or keyOfAgent(id) and seals bytes to it with HPKE (RFC 9180); only the same passkey, on any device it syncs to, opens them.

Quickstart: seal to someone

import { letterlock } from "letterlock";

const ll = letterlock({ chain: "monad" }); // the mainnet directory, over the public RPC
const envelope = await ll.sealTo("0x…recipient", new TextEncoder().encode("only you can read this"));
// store or send the envelope JSON anywhere; only the recipient's passkey opens it

No passkey, no key material and no transaction: sealTo is one contract read and one HPKE seal. It throws NO_KEY_PUBLISHED when the recipient has no key.

From a terminal (npm i letterlock installs the SDK and this CLI):

npm i -g letterlock                       # or prefix any command below with npx
letterlock resolve agent:10260            # the key an address or an ERC-8004 agent published
letterlock seal 0x…recipient note.txt --out envelope.json
letterlock verify 0x…recipient            # seal a random nonce: whoever reads it back holds the passkey
letterlock inbox 0x…recipient --from-block 108289180

Receive: publish a key and open (browser)

import { LETTERLOCK_RP_ID, createEncryptionAddress, letterlock, meraAccount } from "letterlock";

const ll = letterlock({ chain: "monad" });
const rp = { id: LETTERLOCK_RP_ID, name: "Letterlock" };
const { keys, credential } = await createEncryptionAddress({ rp, user: { name: "maya", displayName: "Maya" } });
const account = await meraAccount({ rpId: LETTERLOCK_RP_ID, credential }); // the same passkey's EVM account
await ll.publish({ account, keys }); // msg.sender is the passkey account; it needs MON for gas
const { envelopes } = await ll.inbox(account.address);
const bytes = await ll.open(envelopes[0].envelope, { credential }); // one prompt, on any synced device

rotate({ account }) publishes the next epoch's key, derived from the account's own passkey (a meraAccount() names it; any other account needs credential); envelopes sealed to earlier epochs still open. A device can hold several passkeys for the site, so the SDK keeps the key on the account's passkey: for a meraAccount(), publish, rotate and publishForAgent refuse a key from any other passkey (or one rebuilt from its fields) before anything is signed.

API

| Call | Passkey prompts | What it does | |---|---|---| | letterlock({ chain, rpcUrl?, directory?, rpId? }) | 0 | client for "monad" (143) or "monad-testnet" (10143); the directory addresses are built in, and a directory you pass is checked once (EIP-55 checksum, code, NO_AGENT()) before anything is read or sent | | resolve(to) | 0 | one keyOf / keyOfAgent read → { recipient, publicKey, epoch, kid, updatedAt } | | sealTo(to, bytes) | 0 | resolve + seal: an envelope bound to this chain, directory, recipient and epoch | | inbox(to, { fromBlock?, toBlock? }) | 0 | the envelopes dropped for a recipient (Dropped logs), in pages the RPC accepts, up to the finalized block by default (two blocks behind the head on Monad): a poll resumed from toBlock + 1 misses nothing. toBlock: "latest" reads to the head, where a block can still be replaced | | drop({ account, envelope }) | 0 | a transaction that emits the envelope for its recipient (the demo transport; ≤ 16 KiB) | | publish({ account, keys }) | 0 | a transaction that publishes the account's key; keys.epoch must be the current epoch + 1, and a meraAccount() takes only keys that name its own passkey | | publishForAgent({ account, agentId, keys }) | 0 | the same for an ERC-8004 agent the account owns (mainnet only), with a key from deriveForAgent for that agent | | deriveForAgent({ rpId, agentId, epoch, credential? }) | 1 | an agent's key from its owner's passkey, with the agent id in the derivation: the key its server holds, never the owner's own | | rotate({ account, credential? }) | 1 | derives the next epoch's key from the account's passkey and publishes it; credential can be left out only for a meraAccount(), whose passkey pins the prompt | | open(envelope, { credential? }) | 1 | re-derives the key for the envelope's recipient (the passkey's own, or its agent's for agent:<id>) and epoch, and opens it | | meraAccount({ rpId, credential? }) | 1 | the passkey's EVM account as a viem account, signing through a mera session | | createEncryptionAddress, deriveFromPasskey, seal, open(envelope, keys), encodeEnvelope, decodeEnvelope | | the protocol functions the client is built on |

account is any viem account: meraAccount(), or privateKeyToAccount() for a server. Every failure is a LetterlockError with a code:

| Code | Meaning | |---|---| | NO_KEY_PUBLISHED | the recipient has no key that resolves now | | EPOCH_MISMATCH | an envelope and a key name different epochs, or a publish named an epoch other than the current + 1 | | WRONG_KEY / TAMPERED | the wrong passkey was chosen / the envelope was altered | | PRF_UNSUPPORTED / PASSKEY_FAILED | the authenticator returns no PRF output / the prompt was cancelled or found no passkey | | NOT_AGENT_OWNER | the account does not own that ERC-8004 agent | | INSUFFICIENT_FUNDS | the account cannot pay for gas | | CHAIN_UNAVAILABLE | the RPC or the ERC-8004 registry gave no answer: unknown, never "no key" | | INPUT_INVALID | a malformed recipient, key, envelope or option, the wrong chain behind rpcUrl, a directory with a bad checksum or no Letterlock directory at it, a publish under another rpId, or a key that does not name the meraAccount()'s passkey |

Honest limits

  • One rpId. PRF output is bound to the WebAuthn rpId, so Letterlock pins one: LETTERLOCK_RP_ID (app.letterlock.edycu.dev). publish, rotate and publishForAgent refuse any other rpId (a key derived on localhost could never be re-derived in production), and a key that does not carry its rpId: publish the key object createEncryptionAddress, deriveFromPasskey or deriveForAgent returned, not { publicKey, epoch } rebuilt from it. unsafeAllowAnyRpId: true lifts this, for tests only. Browsers let a page use only its own domain as the rpId, so people publish their key on the Letterlock app itself, and other apps only seal to them. That host serves the Letterlock app (the Vercel project letterlock-app), on the owner's own domain, edycu.dev, since 0.1.1 (CHANGELOG.md): 0.1.0 pinned letterlock-app.vercel.app, a Vercel project name, and keys derived there cannot be re-derived here. Whoever serves pages on the host, or on a subdomain of it, can run passkey ceremonies for the rpId and so derive every Letterlock key, the passkey accounts included (docs/SPEC.md §6): the domain must stay registered to the Letterlock owner and pointed at the app for as long as keys derived under it are in use, and moving to another host takes a new SDK release, since the SDK pins it.
  • What is in the directory. On Monad mainnet (0xA25BBACAb3fD2e71da1Aa002e54965B488d64b7e) the deploy smoke test published two DEMO KEYs (the deployer's, and agent 10260's epoch 1), derived from random bytes with no passkey; the testnet key is a TEST KEY of the same kind. Whoever holds their stand-ins can open anything sealed to them: do not seal real notes to them. Agent 10260 now resolves to epoch 2, the reference agent's own key (examples/agent-memory), derived from a seed only the agent's host and its operator hold.
  • Not hidden: who sent an envelope (HPKE base mode is anonymous, and a copied envelope can be dropped again), and the recipient, epoch and timing of every drop, which are public onchain.
  • No recovery. If every synced copy of the passkey is lost, so is the key.
  • Gas. On mainnet the smoke test's publish used 70,863 gas and its 490-byte drop 45,780 (receipts in deployments/143.json). Monad charges the gas limit, and a new passkey account holds no MON until someone sends it some.
  • Two prompts to publish. One for the key (Letterlock's PRF salt), one for the account (mera's salt): mera 0.2 evaluates one salt per ceremony.
  • Agent keys. An agent's key comes from its owner's passkey through deriveForAgent, with the agent id in the PRF salt and the HKDF info, so it is not the owner's own key: the agent's server can hold it and still cannot open the owner's notes. publishForAgent refuses a key derived for an address, and the account's own published key. Whoever holds an agent's secret key opens everything sealed to that agent in that epoch: publish the next epoch if its server leaks.
  • Inbox reads logs. rpc.monad.xyz serves eth_getLogs 100 blocks at a time (checked 2026-09-27; rpc1.monad.xyz answered a 1,000,000-block range the same day). inbox narrows its pages to what the RPC accepts, so a scan from the directory's deploy block grows with the chain: on 2026-09-27 its 23,218 blocks took 234 requests and 34 s on rpc.monad.xyz (4 in flight), and 3 requests and 1 s with rpcUrl: "https://rpc1.monad.xyz". Pass fromBlock, a wider RPC, or index Dropped yourself.
  • Runtimes. Passkeys need WebAuthn (a browser) or a mera WebAuthnClient. mera 0.2.0 is a preview and declares Node ≥ 24; the CLI never touches a passkey and runs on Node ≥ 20.19. mera's type declarations use Symbol.dispose: TypeScript needs lib esnext (or skipLibCheck).
  • The CLI handles no key material, except drop's signing key, read from the environment variable that --private-key-env names; a key typed on the command line is refused and never echoed.

Development

pnpm --filter letterlock test        # unit and chain tests; the chain tests need Foundry's forge and anvil
pnpm --filter letterlock test:live   # read-only checks against Monad mainnet and testnet
pnpm --filter letterlock build       # tsup, then scripts/stage.mjs: dist/ is the npm package
cd packages/letterlock && npm publish ./dist   # the workspace manifest is private; dist/package.json is the one published

Protocol (derivation, envelope format, contract rules): docs/SPEC.md in the repository. MIT licensed.