MemoDueAgentic
DevnetDevnet only: never mainnet without a human's explicit approval for that action. The note link is a secret (it holds the key): never log it, never post it, never put it on chain or in a URL query. Lost link = lost note; nobody, including the operators, can recover it. Use is subject to the Terms.
What it is
MemoDue stores a sealed one-time note on Solana devnet. You (the agent) encrypt a short text locally, choose when it unlocks and how long it stays readable, and get back a link. Whoever holds the link can read the text only inside that window. Solana stores ciphertext only, never the plaintext or the key.
- New notes are always gated (version 5): the key is split.
k1sits in the link;k2is wrapped for a gate that releases it only between unlock and expiry. Text keyk = HKDF-SHA256(k1 ‖ k2, salt = note address); AES-256-GCM, then drand quicknet time lock. - Link:
read.html?account=<note>#k=<k1>&g=<k2 wrapped for the gate>. Both parts are in the URL fragment, which browsers never send to a server. - Agents create AGENT-class notes on Tier 1 (Arcium) by default. Humans use the create page with a wallet.
Quick start (CLI, about 5 minutes)
Assumes the agent tools are installed in ./memodue and you have a funded devnet keypair. Every command prints JSON and exits 1 on a refusal.
# 1. Configure (environment only; keypairs are never command arguments) export MEMODUE_KEYPAIR="$HOME/.config/solana/agent-devnet.json" # pays create (and reads, if no reader keypair) export MEMODUE_PAGE="https://devnet.memodue.com/index.html" # links point at the official site (recommended, see Config) # optional: export MEMODUE_RPC_URL="https://<your devnet RPC>" # your own devnet RPC (see Own RPC) cd memodue # 2. Price check (reads the on-chain fee table) node agent/cli.mjs quote # 3. Create: unlocks in 2 min, readable for 60 s after that. Link goes to a 0600 file, not stdout. printf '%s' 'meet at 10:00' > note.txt node agent/cli.mjs create --in 2m --readable 60s --text-file note.txt --link-out link.txt # 4. Status (needs only the address; no key, no fee) node agent/cli.mjs status <account from step 3> # 5. Read after unlock (a Solana transaction paid by your wallet, about 0.00002 SOL) node agent/cli.mjs read --link-file link.txt # 6. Close to get the rent back: no CLI command yet, use the SDK snippet in "Close and rent" # 7. Delete the link when you are done rm -f link.txt note.txt
Before unlock, read returns {"ok": false, "phase": "waiting"} and pays nothing. After expiry it returns "phase": "expired" and pays nothing.
Prerequisites
| Need | Details |
|---|---|
| Node.js 20+ | The SDK, CLI and MCP server are plain ES modules. |
| Agent tools | The agent/ folder of the MemoDue source plus npm install. See Get the tools. |
| Devnet keypair | A Solana keypair file (JSON array of 64 numbers, as written by solana-keygen new -o <file>). Keep it outside any repo, mode 600. Pass its path in MEMODUE_KEYPAIR. |
| Devnet SOL | Creator: create fee (0.01 SOL for an AGENT Tier 1 note today) + rent (about 0.0033 SOL for a short note) + transaction fees. Budget 0.02 SOL. Reader wallet: at least 0.0036 SOL balance (about 0.0035 SOL is held during a read and returned). Get devnet SOL from a devnet faucet, e.g. solana airdrop 1 --url devnet. |
| RPC | Default: the public https://api.devnet.solana.com. If that is rate-limited or unreachable from your host, set MEMODUE_RPC_URL to your own devnet RPC (rules and risks). |
| Network access | Your RPC, https://api.drand.sh (time-lock beacons). Arcium reads go through Solana transactions, so no extra host. |
Get the tools
This website does not serve the agent tools: agent/sdk.mjs, agent/cli.mjs, agent/mcp.mjs and scripts/ are only in the MemoDue source repository, which is not public yet. Ask the operator for access. The browser modules this site serves (chain.js, gate-create.js, …) are the same code but expect a bundler or import map, not a plain Node install.
# with access to the source repository git clone <MemoDue repository URL> memodue cd memodue npm install # @solana/web3.js, tlock-js (Node 20+) node agent/cli.mjs quote # smoke test: prints the AGENT Tier 1 fee as JSON
What is in agent/: sdk.mjs (Node SDK), cli.mjs (JSON command line), mcp.mjs (local MCP stdio server), rpc.mjs (own-RPC checks), lit-route.mjs (optional Lit route), README.md.
Config (environment variables, never tool arguments)
| Variable | Use |
|---|---|
MEMODUE_KEYPAIR | Path to the payer / creator keypair file. Required to create. Also pays Arcium reads when no reader keypair is set. |
MEMODUE_READER_KEYPAIR | Optional reader wallet: pays Arcium reads. Required for reader-bound notes (created with --reader <wallet>). |
MEMODUE_PAGE | Page the returned link points at. Set it to https://devnet.memodue.com/index.html, the official site (also the built-in default of current tools; older copies default to a temporary host a human cannot open). https://memodue-devnet.onrender.com/index.html is the same site on the fallback host and also works. Any other host prints a warning: whoever serves the page a link opens sees the note key. The link always uses read.html in that page's folder. |
MEMODUE_RPC_URL | Optional: your own devnet RPC (Own RPC). |
MEMODUE_GATE_CONFIG | Optional JSON file overriding the gate settings. Not needed: without it the tools use the devnet Arcium gate this website uses. |
MEMODUE_AGENT_LIT_KEY | Optional: your own Lit usage key, only for --tier lit (Tiers). |
MEMODUE_GATE_DEMO, MEMODUE_WRITER | Local demo / test stand-ins only. Leave unset for real devnet notes. |
Create
CLI (flags: --tier tier1|arcium|lit|icp (default tier1), --in <duration> (default 5m) or --at <ISO time>, --readable <duration> (default 24h), --text "…" or --text-file f, --reader <wallet>, --max-fee <lamports> (the most you agree to pay as the create fee; default the fee table fee read at create time), --link-out f; durations like 90s, 30m, 2h, 1d):
node agent/cli.mjs create --in 30s --readable 60s --text-file note.txt --link-out link.txt
Example result (devnet, 2026-10-17; the link is written to link.txt, never printed):
{
"account": "B1bPrsZW92H3JfihWYrSsd4V5YCc4p6DSdGDg8jZBGtM",
"link": "(written to link.txt)",
"tier": "tier1",
"mode": "ARCIUM_GATED",
"class": "AGENT",
"unlockUnix": 1792206426,
"expiryUnix": 1792206486,
"clampedToCap": false,
"signatures": ["4G2jWa9L…"]
}
SDK (run from the repository root):
import { createAgentNote, loadAgentGateConfig, loadKeypair } from "./agent/sdk.mjs";
const gateConfig = await loadAgentGateConfig(); // devnet Arcium Tier 1 by default
const payer = loadKeypair(process.env.MEMODUE_KEYPAIR); // never a tool argument
const { account, link, unlockUnix, expiryUnix } = await createAgentNote({
text: "meet at 10:00", payer, gateConfig,
tier: "tier1", // default (Arcium). "lit" needs your own Lit key; "icp" is off
unlockIn: "2h", // or unlockAt: "2026-10-18T09:00:00Z"; Tier 1: at most 24 h after now
readable: "24h", // at least 60 s; expiry at most 48 h after now (longer is clamped)
// rpcUrl: "https://…", // optional, same rules as MEMODUE_RPC_URL
});
// link is SECRET: store it (e.g. a 0600 file) and hand it only to the reader.
- Plaintext limit: 4096 UTF-8 bytes. It never leaves your machine; only ciphertext goes on chain.
- The create is simulated before signing, so a refused create (caps, paused or retired gate, missing fee table, blocklist) costs nothing.
- The note address is a fresh keypair each time; one create is one transaction (longer texts add
appendtransactions). - Reader-bound note: add
--reader <wallet>(SDKreader). Only that wallet can read; any other wallet's read is refused inside the gate after paying one read.
Read
node agent/cli.mjs read --link-file link.txt
import { loadAgentGateConfig, loadKeypair, readNote } from "./agent/sdk.mjs";
const r = await readNote({ link, gateConfig: await loadAgentGateConfig(),
payer: loadKeypair(process.env.MEMODUE_READER_KEYPAIR ?? process.env.MEMODUE_KEYPAIR) });
// never throws on a refusal:
// { ok: true, plaintext, mode: "ARCIUM_GATED", class: "AGENT", expiryUnix, gate: { costLamports, timing, signatures } }
// { ok: false, phase: "waiting" | "expired" | "closed-or-missing" | "needs-sol" | …, reason }
- Unlock and expiry follow the Solana chain clock (Clock sysvar
SysvarC1ock11111111111111111111111111111111,unix_timestamp), not your device clock. Gates apply no grace. Onwaiting, wait and retry. - Each Arcium read is a Solana transaction signed by your wallet: about 20,623 lamports (measured on devnet), with about 0.0035 SOL held during the read and returned in a second transaction. It is simulated first, so a read that would be refused costs nothing. Typical time 5–9 s.
- Status without the key:
node agent/cli.mjs status <account>returns mode, class,unlockUnix,expiryUnix, takedown state andphase(waiting,readable,expired,taken-down,suspended,closed-or-missing). - "One-time" means one reading window: the gate does not count reads, so any holder of the link (or only the bound reader, for a reader-bound note) can read from unlock until expiry, each read paid by that reader; never after a takedown.
- Humans open the same link in a browser on
read.htmlwith a Phantom-style wallet.
Close and get the rent back
A note holds rent (about 0.0033 SOL for a short note; rent for 110 bytes + ciphertext). Nothing closes a note automatically. The SDK, CLI and MCP server have no close command yet; close with creatorCloseSealedAccount from web/chain.js in the same repository:
import * as web3 from "@solana/web3.js";
import { loadKeypair } from "./agent/sdk.mjs";
import { connectAgentRpc } from "./agent/rpc.mjs"; // honours MEMODUE_RPC_URL, checks devnet genesis
import { creatorCloseSealedAccount } from "./web/chain.js";
const { connection } = await connectAgentRpc({ web3 });
const creator = loadKeypair(process.env.MEMODUE_KEYPAIR);
const { signature } = await creatorCloseSealedAccount({ web3, connection, creator, account });
process.exit(0); // web3.js may keep sockets open; end one-shot scripts explicitly
| Who | When (Solana clock) | Gets |
|---|---|---|
| Creator (signs) | from unlock (an unfinished write: any time; a finally taken-down note: at once) | the note's lamports minus the tombstone rent; no tip |
| Anyone (keeper) | from the note's expiry | the close tip (tip stored in the note at create, 10,000 lamports by default); the creator still gets the rest |
- Tombstone: every close moves the 0-byte rent minimum (650,240 lamports on devnet) into the PDA
["tomb", note], which no key can empty. That part is not refunded, and a later create at the same address fails with error 32TOMBSTONED(the tools always use a fresh address). - Measured (devnet, 2026-10-17): rent 3,322,320 lamports, refund 2,667,080 lamports after the tombstone and the close transaction fee.
- The create fee is never refunded. Closing after expiry is the clean pattern; closing between unlock and expiry ends the reading window early (the account is gone).
- Closing removes the ciphertext from account state, not from transaction history. It cannot be decrypted without
k2, which the gate stops releasing at expiry. - Full rules (accounts, legacy versions, keeper script): close (opcode 1).
Tiers
| Tier | Status for agents | Unlock | Readable until | Who pays a read |
|---|---|---|---|---|
tier1 = arcium (default) | On. Devnet gate 5HEfLzYwRBi3jAQFnUeF8L6HvD1VaFDFDUpSEt3zMi6R, Arcium cluster 456; same gate as the website. | ≤ 24 h after creating | ≥ 60 s after unlock, ≤ 48 h after creating | the reader's wallet, ~20,623 lamports |
lit | Optional, only with your own Lit usage key (MEMODUE_AGENT_LIT_KEY), scoped to MemoDue's action CIDs; never a master or wildcard key. MemoDue never ships or pays for a Lit key. Refused without one. | ≤ 24 h | ≤ 48 h | your Lit account |
icp (Tier 2) | Off until its gate is deployed (refused with gate-off). | — | — | — |
There is no Plain (ungated) tier for new notes (error 31 PLAIN_CREATE_RETIRED). The class (AGENT / HUMAN) is declared by the creator and priced, not verified.
Costs (devnet SOL)
| Item | Amount | Paid by / refunded? |
|---|---|---|
| Create fee, AGENT Tier 1 (Arcium) | 10,000,000 lamports (0.01 SOL) on 2026-10-17 | creator → treasury; never refunded. Set in the on-chain fee table (PDA 6CqA1DeaCwaYYP2JnJ4R1AVfJDjtw6Vp1eABAy8nN9H7) and can change: always run quote first (draftPrice: true means the table could not be read). |
| Note rent | ~3,322,320 lamports for a short note; more for longer text | creator; refunded on close minus the tombstone |
| Tombstone | 650,240 lamports | taken from the note's rent at close; never refunded |
| Arcium read | ~20,623 lamports per read | reader's wallet; a refused read costs nothing |
| Read deposit | ~0.0035 SOL held during a read | reader's wallet; returned in the same read flow (needs ≥ 0.0036 SOL balance) |
| Transaction fees | ~5,000 lamports per signature (plus a small priority fee) | signer |
Measured end to end (2026-10-17): create 13,332,320 lamports (fee + rent + tx), one read 20,623, close refund 2,667,080; net about 0.0107 SOL for one note read once.
Limits
| Limit | Value | If exceeded |
|---|---|---|
| Plaintext | 4096 UTF-8 bytes | refused before anything is sent |
| Unlock (Tier 1) | in the future, ≤ 24 h after creating; rounded up to the next drand round (≤ 3 s later) | refused (error 30 UNLOCK_TOO_FAR), nothing paid |
| Readable window | ≥ 60 s after unlock | refused (error 27 EXPIRY_INVALID), nothing paid |
| Expiry (Tier 1) | ≤ 48 h after creating | the tools clamp it to the cap (clampedToCap: true); the program refuses a later one (error 28) |
| Cluster | devnet only | mainnet URLs and the mainnet genesis are refused |
Link safety
- The full link is the only key. Neither part alone opens the note, but together they do: treat it like a password.
- Keep both parts in the URL fragment (
#k=…&g=…). Never move them into the query string, a log, a memo, an instruction, an account, a commit or a public post. - Use
--link-out <file>(written mode 600) instead of stdout, hand the link only to the intended reader, and delete your copy when done. - A lost link cannot be recovered by anyone, including the operators. There is no backup.
- Never log
location.hashin a browser. Never pass a keypair as a tool argument (the MCP server rejects one).
Errors your agent will see
phase / code | Meaning | What to do |
|---|---|---|
waiting | before unlock (chain clock) | wait until unlockUnix, retry; nothing was paid |
expired | after expiry; the gate no longer releases the key | final; close the note to get the rent back |
closed-or-missing | account not found (closed, or wrong address) | final for gated notes |
taken-down, suspended | moderation takedown or gate suspension | no read; see the Terms |
needs-wallet | no keypair configured for an Arcium read | set MEMODUE_READER_KEYPAIR or MEMODUE_KEYPAIR |
needs-sol | reader wallet below ~0.0036 SOL | fund the reader wallet |
needs-reader-wallet | note is bound to one reader wallet | set that wallet as MEMODUE_READER_KEYPAIR |
gate-refused | the gate refused; gateError e.g. Locked, TakenDown, BindingMismatch | read reason; Locked = retry later |
gate-off | tier not available (ICP, or Lit without your own key) | use tier1 |
GatedCreateRefused (error.code) | UNLOCK_TOO_FAR, EXPIRY_TOO_FAR, EXPIRY_INVALID, GATE_PAUSED, GATE_RETIRED, FEE_TABLE_MISSING, TOMBSTONED, PLAIN_CREATE_RETIRED, FEE_ABOVE_MAX | fix the input or retry later; nothing was signed or paid (FEE_ABOVE_MAX: the fee went up after you read it; quote again) |
SANCTIONS_MATCH / BlockedByProgram | wallet on the sanctions list or an enforced on-chain blocklist | stop; do not work around it |
Raw program error numbers: Program errors.
MCP server (local, stdio)
node agent/mcp.mjs speaks newline-delimited JSON-RPC (initialize, tools/list, tools/call, ping). It runs on your machine, so plaintext and keys stay there. Client config (Claude Desktop / Cursor style):
{
"mcpServers": {
"memodue": {
"command": "node",
"args": ["/absolute/path/to/memodue/agent/mcp.mjs"],
"env": {
"MEMODUE_KEYPAIR": "/home/me/.config/solana/agent-devnet.json",
"MEMODUE_PAGE": "https://devnet.memodue.com/index.html"
}
}
}
}
| Tool | Arguments | Returns |
|---|---|---|
memodue_quote | tier?, class? (agent) | feeLamports, readCostLamports, caps, rules |
memodue_create_gated_note | text, tier?, unlock_in? (5m) or unlock_at?, readable_for? (24h), reader?, max_fee_lamports? (decimal string; default the fee read at call time) | account, link (SECRET), unlockUnix, expiryUnix, maxFeeLamports |
memodue_note_status | account | mode, class, times, takedown, phase |
memodue_read_note | link | ok + plaintext, or ok: false + phase |
memodue_close | account | closed + refundedLamports + tombstone (creator only: MEMODUE_KEYPAIR must be the note's creator; the tombstone is never returned) |
Refusals come back as results with ok: false, not as protocol errors. A keypair argument is rejected. There is no close tool yet (see Close and rent).
Own RPC (MEMODUE_RPC_URL)
- SDK, CLI and MCP server only (option
rpcUrlor envMEMODUE_RPC_URL). This website itself uses onlyhttps://api.devnet.solana.com. - https only (http only for
localhost); nouser:pass@. Before anything is read, signed or sent,getGenesisHashmust return the devnet genesisEtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG; a mainnet host or genesis is refused. - Errors show only
scheme://host/…, never the full URL or a provider key. Keep the key in your environment, not in code or logs. Bring your own provider key; MemoDue never ships one. - Risks: a dishonest RPC can lie on reads (worst case a failed or wasted transaction; it cannot open a note early, because the gate uses the on-chain clock) and drop or delay sends. It sees public transaction data only; the link key never leaves your agent.
Rules for an agent
- Devnet only. Mainnet needs explicit human approval for that specific action.
- Use the SDK, CLI or MCP server. If you must build transactions yourself, match
codec.jsandgate-core.jsexactly. - Never put plaintext,
k1ork2in an instruction, account, log, memo, or URL query. The key goes in the fragment only. - Treat the full link as a secret; hand it only to the intended reader.
- The create and close helpers screen wallets against the bundled sanctions list (
sanctions.js,sanctions/solana.json, OFAC SDN Solana addresses) and throw aSanctionsRefusal(error.code === "SANCTIONS_MATCH") before building anything. Do not work around it. - After the program upgrade the program itself refuses create and keeper close for a wallet flagged by an enforced blocklist (error 17), and every close whose rent would go to a flagged creator (error 22, frozen, not redirected);
chain.jsthrowsBlockedByProgramwith neutral text. Do not work around it either. - Never commit keypairs,
.envfiles, RPC keys or secret keys.
Reference (protocol details)
Everything below is for developers building their own client or keeper. The tools above already handle it.
Network and fee config
- Cluster
- Solana devnet only
- RPC
https://api.devnet.solana.com(this website uses only the public devnet RPC). Agent tools may use their own devnet RPC: see Own RPC.- Devnet genesis
EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG- Program id
GSmXLpt1f6EBMKTBtr3eVQy92FHARv4JKQfV1khUD16H- Legacy create fee
- Loading current fee…
Read live from the on-chain fee configAWNG8sSVtCyBW4tws9NtB6ETgwoUw9BpYuAjoedNasqv. Hard cap1000000000(1 SOL). Charged only by the retired Plaininitialize/begin. Not what an agent pays: gated (version 5) creates pay the fee table (Costs). - Fee config PDA
- Seed
fee_configunder the program id. Create must pass this account. - Treasury
- Taken from the fee config (bootstrap default
GJgPDoCQuiDyKUzqQwYb1TaigdJT6akr72kbFqx8wFRz) - Fee authority
- Dedicated keypair stored in the fee config. Only that signer can update the fee or treasury. Separate from the program upgrade authority, which appoints it at init (after the hardening upgrade only the upgrade authority can run init). Handover is two-step: the current authority proposes a key (opcode 12, pending-handover PDA
["handover", config]), the proposed key signs accept (13) and becomes the authority; the current authority can cancel (14). Nothing in the config changes until accept. Opcode 11 (one-transaction handover) is disabled and fails with error 13. - Rent
- Separate from the fee. Reclaimable by the creator via
close(except an older note created with the retired close-lock). After unlock + grace anyone may close; the closer gets the close tip and the creator gets the rest. - Close tip
- Loading… Paid from the note's lamports to a non-creator closer. Default
10000, hard cap100000lamports (0.0001 SOL). Set by the fee authority. - Close grace
- Loading… Minimum seconds after the unlock time before anyone may close. Default
900(15 min), allowed0to2592000(30 days). Set by the fee authority. After the hardening upgrade, a version 4 note (82-byte header, allocated withsnapshot_account_len) stores the tip and grace from its creation and ignores later changes; older version 3 notes follow the live value. New notes are version 5 (gated) and store them too. - Mainnet
- Not deployed. The modules refuse mainnet URLs and the mainnet genesis hash.
Create on the wire (version 5)
Every new note names a gate. There is no Plain (ungated) create any more: the program refuses the old initialize / begin and a version 5 create with mode PLAIN (error 31, PLAIN_CREATE_RETIRED).
- The same steps run on the website (
gate-create.jscreateGatedNote, thenchain.jswriteGatedAccount):k = HKDF(k1 ‖ k2, note)seals the text,k2is wrapped for the gate, and onlyk1and the wrappedk2go in the link. - New notes are version 5:
110 + ciphertextbytes, with the mode, class, expiry and the close tip and grace in force at create. Plaintext limit 4096 bytes.writeGatedAccountsends up to 619 ciphertext bytes in one transaction and writes longer ciphertext as a 587-byte first chunk then 736-byteappendchunks. The create carries the max fee (create flag0x04, u64 lamports after the IV); the program refuses with error 33FEE_ABOVE_MAXrather than charge more. - Create accounts: note (w, signer), creator (w, signer), fee config PDA, treasury (w), System Program, the creator's blocked PDA
["blocked", creator], the compliance config["compliance", fee config], then the fee table.writeGatedAccountscreens the wallet, reads the blocked PDA and simulates the first transaction before asking for a signature, so a refused create (error 31, a paused or retired gate, a missing fee table) costs nothing. - One transaction for create.
createAccountand the create instruction go in the same transaction, signed by a fresh note keypair (the gated key is bound to its address). The program rejects a create without the note account's signature, and acreateAccountwithout its create is an empty account whose rent nobody can recover. - Old Plain notes (versions 1 to 5 with mode PLAIN) keep working: the reader below opens them with the key in the link, and close, refund and keeper close are unchanged.
close (opcode 1)
A program cannot run on its own, so nothing closes a note automatically. Instead anyone, for example a keeper bot, may close a note once its unlock time plus the grace has passed, and is paid the close tip for it.
- Permissionless close (keeper): accounts note (w), creator (w, not a signer, must equal the creator stored in the note), fee config PDA (read), closer (w, signer), then (on-chain blocklist upgrade) the closer's blocked PDA
["blocked", closer]and the compliance config["compliance", fee config], both read-only, then the creator's blocked PDA["blocked", creator](read-only), then the note's close tombstone PDA["tomb", note](writable);keeperCloseKeysadds them. Every close moves the 0-byte rent minimum (650,240 lamports on devnet) from the note into the tombstone, so a create at that address later fails with error 32TOMBSTONED. The program deployed today ignores the extra accounts; after the upgrade missing ones fail with error 18, a flagged closer with error 17 and a flagged creator (who gets the rest of the rent) with error 22RECIPIENT_SANCTIONED: the lamports stay in the note. Allowed when the Solana clock is at or pastreveal_unix + close_grace_secs(the note's unlock time is its drand round publication time). Opening first is not required. The closer receivesmin(close_tip_lamports, note lamports); every other lamport goes to the creator. Before that time: error 10 (CLOSE_NOT_DUE). - Creator close (program PR 47): accounts note (w), creator (w, signer), then (on-chain blocklist upgrade) the creator's blocked PDA and the compliance config, then the note's tombstone PDA
["tomb", note](writable) (creatorCloseKeys); the creator gets the note's lamports less the tombstone rent; a flagged creator fails with error 22 and the rent stays frozen in the note, and the two-account form fails with error 18 after the upgrade. The program deployed today reads the four-account creator form as a keeper close (IncorrectProgramId), so the page simulates first and falls back to the two accounts until the upgrade. The keeper form with closer = creator behaves the same. Finished version 3/4 note: allowed once the Solana clock is at or pastreveal_unix, opened or not (error 1ROUND_NOT_DUEbefore that), never when an older note is close-locked (error 6). Unfinished version 3/4 write: at any time, even before reveal. All lamports go to the creator, no tip. About 10k CU with the blocklist and tombstone PDAs; the page sets a 40,000 limit. Version 1/2 notes keep their old rules. - Before the PR 47 upgrade the creator close of an unopened finished note fails with error 3 (
NOT_OPEN).creatorCloseSealedAccountsimulates first and then falls back toopen+ close in one transaction (about 196k CU; limit 260,000). - Close-lock is retired for new notes: clients no longer offer it, and the program upgrade that ships this change rejects opcodes 5 and 6 and v5 create flag
0x01(InvalidInstructionData). Older notes that already carry the lock (state bit0x80) still cannot be closed by anyone, keeper and creator included (error 6), and their rent stays on the account. - Unfinished chunked write (
beginwithout the finalappend): the creator may close it at any time (above). Anyone else can close it at or afterreveal_unix + 86400with the two-account form (no tip). With the keeper form the closer gets the tip once bothreveal_unix + 86400andreveal_unix + close_grace_secshave passed. Close-lock does not block this refund. - Closing removes the ciphertext from account state, not from history: the create transactions still carry it, so the full link can still decrypt.
- The create fee is never refunded.
import { fetchCloseParams, keeperCloseSealedAccount } from "./chain.js";
import { keeperCloseState } from "./close-rule.js";
// state = { closable, reason: "locked" | "not-due" | "due", dueUnix, tipLamports }
const state = keeperCloseState(note, nowUnix, await fetchCloseParams({ web3, connection }));
if (state.closable) await keeperCloseSealedAccount({ web3, connection, closer, account });
A dry-run keeper that lists closable notes: node scripts/keeper.mjs in the source repository (add --send --keypair <path> to close them; devnet only).
Humans can do the same on due.html (Due notes): it reads every program account header with getProgramAccounts and an 82-byte dataSlice, fetches full data only for notes past unlock, applies the same keeperCloseState (per-note tip and grace for version 4), lists due notes most overdue first and the ones still in grace, and closes with keeperCloseSealedAccount. Logic without DOM or RPC is in due-core.js (classifyDueNotes).
Close helpers (web/chain.js)
import { creatorCloseSealedAccount, keeperCloseSealedAccount, openSealedAccount } from "./chain.js";
await creatorCloseSealedAccount({ web3, connection, creator, account }); // creator, from unlock (unfinished: any time); { signature, mode }
await keeperCloseSealedAccount({ web3, connection, closer, account }); // anyone, after unlock + grace, earns the tip
await openSealedAccount({ web3, connection, payer, account }); // optional attestation, after the round, anyone
Time lock (drand quicknet)
- Chain hash
52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971- Genesis
- unix
1692803367 - Period
3seconds- Relay
https://api.drand.sh/52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971
round(T) = floor((T - 1692803367 + 2) / 3) + 1 publication(r) = 1692803367 + (r - 1) * 3
- For a requested reveal time
T, the note uses the first round published at or afterT(at most 2 seconds later). The account stores that round and its publication time. - Ciphertext is public on chain before the countdown ends. Anyone can copy it.
- It can only be decrypted after the round's beacon is published, and only with the key from the fragment.
openis not required to decrypt, and (after program PR 47) not required to close: the creator closes by the Solana clock from unlock, and the permissionless close after unlock + grace never needed it.
open (opcode 4)
- On-chain check: the quicknet BLS signature for the stored round verifies against the pinned key, the first age
tlockstanza names that round and the quicknet chain hash, and the Solana clock is at or past the round's publication time. - Anyone can submit it after the round. The creator is not required.
- The caller pays the normal transaction fee only, not the create fee.
- Data: opcode, 48-byte signature, 338-byte hash-to-curve witness (387 bytes). Accounts: note (writable).
- It sets state to opened. It does not decrypt anything. The web UI no longer has an Open button;
openstays available as an on-chain attestation that the round was published.
Known hole
The program does not decrypt the tlock body and cannot prove it was locked to the stored round. A creator can store an undecryptable body and close after the round and take back the rent. This is documented. There is no penalty, because the chain cannot tell it apart from an honest open then close. (The opt-in close-lock that kept an account on chain for good is retired for new notes.)
Reading older Plain notes
Gated notes: use readNote (Read). Gated unlock and expiry are decided by the Solana blockchain clock (Clock sysvar unix_timestamp at SysvarC1ock11111111111111111111111111111111), not the agent's device clock. Read it with getAccountInfo, or retry on a before-unlock refusal (Locked / BeforeUnlock); gates apply no grace. Drand-timelocked plaintext still opens on its drand round, independent of that clock.
import { parseNoteUrl } from "./link.js";
import { connectDevnet, fetchSealedAccount } from "./chain.js";
import { openHybridNote } from "./tlock.js";
const { account, rawKey } = parseNoteUrl(link);
const { connection } = await connectDevnet(web3);
const note = await fetchSealedAccount({ web3, connection, account });
const result = await openHybridNote({
ciphertext: note.ciphertext, iv: note.iv, rawKey,
revealUnix: note.revealUnix, nowUnix: Math.floor(Date.now() / 1000),
});
// result.status is "sealed" (with secondsRemaining) or "revealed" (with plaintext)
- Reading needs no wallet and no fee.
- Version 1 notes use
openSealedNotefromtimer.jsinstead. This reads Plain notes (versions 1 to 4, and version 5 with mode PLAIN), which are no longer created; new notes are version 5 gated notes, read withreadNoteinagent/sdk.mjs(orread.html).
Program errors
| Custom error | Name | Meaning |
|---|---|---|
| 1 | ROUND_NOT_DUE | open, or a creator close of a finished note, before the round's publication time |
| 2 | quicknet signature or witness does not verify | |
| 3 | NOT_OPEN | unsigned two-account close of a finished note before open (before PR 47 also the creator close) |
| 4 | REFUND_NOT_DUE | non-creator close of an unfinished write before reveal_unix + 86400 |
| 5 | IDENTITY_MISMATCH | first tlock stanza does not name the stored round or the quicknet chain hash |
| 6 | CLOSE_LOCKED | close of an older finished note whose close-lock was set (new notes cannot be locked) |
| 7 | FEE_TOO_HIGH | fee config init/update with create fee above 1 SOL (1_000_000_000 lamports) |
| 8 | TIP_TOO_HIGH | close tip above 100_000 lamports |
| 9 | GRACE_TOO_LONG | close grace above 2_592_000 seconds (30 days) |
| 10 | CLOSE_NOT_DUE | permissionless close before reveal_unix + close_grace_secs |
| 11 | CONFIG_NOT_MIGRATED | tip/grace update (opcode 10) on a version 1 fee config |
| 12 | NOT_UPGRADE_AUTHORITY | fee config init (opcode 7) not signed by the program's upgrade authority, or a wrong ProgramData account (hardening upgrade) |
| 13–16 | owner handover | 13 direct handover disabled, 14 nothing pending, 15 not the proposed key, 16 stale proposal (also 14/15/16, and 13 for opcode 22, for the compliance authority handover) |
| 17 | ADDRESS_BLOCKED | create by a creator, or keeper close by a closer, flagged by an enforced blocklist (on-chain blocklist upgrade) |
| 18 | BLOCKLIST_ACCOUNTS_MISSING | create, creator close, keeper close or update_fee_config without the blocked PDA(s) and compliance config, or with wrong ones (old account layout) |
| 19 | NOT_LIST_UPDATER | set blocked (opcode 17) or list source (20) not signed by that list's updater |
| 20 | NOT_COMPLIANCE_AUTHORITY | list change (16) or compliance handover propose/cancel (18, 21) not signed by the compliance authority (the fee authority has no compliance rights once the keys differ) |
| 21 | LIST_IN_USE | removing or renaming a list that still flags wallets |
| 22 | RECIPIENT_SANCTIONED | a close whose rent goes to a creator, or a fee update naming a treasury, flagged by an enforced blocklist; the lamports stay frozen in the note (on-chain blocklist upgrade) |
| 23 | TAKEN_DOWN | open of a note under takedown (pending or final) |
| 24 | TAKEDOWN_FINAL | a change to a note whose takedown is final |
| 25 | GATE_RETIRED | new note in a retired gate mode |
| 26 | GATE_PAUSED | new note while that gate mode is paused |
| 27 | EXPIRY_INVALID | expiry less than 60 s after unlock |
| 28 | EXPIRY_TOO_FAR | expiry later than the tier cap (Tier 1: 48 h after creating) |
| 29 | FEE_TABLE_MISSING | gated create without a priced fee table row |
| 30 | UNLOCK_TOO_FAR | Tier 1 unlock more than 24 h after creating |
| 31 | PLAIN_CREATE_RETIRED | a new note without a gate: the old initialize / begin (opcodes 0 and 2, always Plain) or a version 5 create with mode PLAIN. Every new note names a gate (Arcium, ICP or Lit). Existing Plain notes still read, open, close and expire (Plain-removal upgrade) |
| 32 | TOMBSTONED | create at a note address that was closed before (its ["tomb", note] PDA holds lamports); make a new note |
| 33 | FEE_ABOVE_MAX | gated create with a max fee (flag 0x04) while the fee table charges more for its mode and class (for example a fee raise landed first); no create fee is taken. Quote again |
Opcodes
| Opcode | Instruction | Fee |
|---|---|---|
| 0 | initialize, Plain (retired: error 31 after the Plain-removal upgrade; older transactions only) | create fee |
| 5 | initialize, close locked (retired; older transactions only) | create fee |
| 2 | begin, Plain (retired: error 31 after the Plain-removal upgrade; older transactions only) | create fee |
| 6 | begin, close locked (retired; older transactions only) | create fee |
| 3 | append (creator only) | tx fee only |
| 4 | open (anyone, after the round; optional) | tx fee only |
| 1 | close (creator; or anyone after unlock + grace) | tx fee only; a non-creator closer earns the close tip |
| 7 | init fee config | tx fee only (creates the PDA, version 2; after the hardening upgrade the program's upgrade authority must sign) |
| 8 | update fee config (fee, treasury) | tx fee only (fee authority must sign) |
| 9 | migrate fee config v1 → v2 (optional tip, grace) | tx fee + rent for 16 bytes (fee authority signs and pays) |
| 10 | update close tip and grace | tx fee only (fee authority must sign) |
| 11 | set fee authority (disabled: custom error 13, use 12 + 13) | — |
| 12 | propose fee authority (creates or overwrites the 74-byte handover PDA) | tx fee + handover rent, returned on accept or cancel (current fee authority signs and pays) |
| 13 | accept fee authority (sets it, closes the handover PDA) | tx fee only (proposed key signs; rent back to the proposer) |
| 14 | cancel fee authority proposal | tx fee only (current fee authority signs; rent back to the proposer) |
| 15 | init compliance config (747-byte PDA ["compliance", fee config]; authority = fee authority; slot 0 = manual override) | tx fee + rent (fee authority signs and pays; it becomes the initial compliance authority) |
| 16 | set list (slot, 8-byte id, updater, enabled; empty id removes an unused list) | tx fee only (compliance authority signs) |
| 17 | set blocked (slot, set/clear, wallet, 32-byte reason): sets or clears that list's bit in the 76-byte PDA ["blocked", wallet], closed when no bit is left | tx fee + PDA rent on the first flag, back to the signer on the last clear (list updater signs; the compliance authority may also clear) |
| 18 | propose compliance authority (creates or overwrites the 74-byte PDA ["compliance-handover", compliance config]); accounts compliance config, compliance authority (w, signer), handover PDA (w), system program | tx fee + handover rent, returned on accept or cancel (compliance authority signs and pays; the fee authority cannot) |
| 19 | accept compliance authority; accounts compliance config (w), handover PDA (w), proposed (signer), proposer (w) | tx fee only (proposed key signs; rent back to the proposer) |
| 20 | set list source (slot, 32-byte source hash) | tx fee only (list updater signs) |
| 21 | cancel compliance authority proposal; accounts compliance config, compliance authority (signer), handover PDA (w), proposer (w) | tx fee only (compliance authority signs; rent back to the proposer) |
| 22 | set compliance authority (disabled: custom error 13, use 18 + 19) | — |
Fee config layout (version 2, 89 bytes, little-endian)
| Offset | Field |
|---|---|
| 0 | version 2 (version 1 = 73 bytes, no tip or grace, reads as the defaults) |
| 1 | create fee lamports, u64 |
| 9 | treasury pubkey, 32 bytes |
| 41 | fee authority pubkey, 32 bytes |
| 73 | close tip lamports, u64 |
| 81 | close grace seconds, u64 |
Account layout (version 3 and 4, little-endian)
| Offset | Field |
|---|---|
| 0 | version 3 or 4 |
| 1 | state: 0 finished and sealed, 1 unfinished, 2 opened; bit 0x80 = close-lock (older notes only) |
| 2 | reveal unix (round publication time), i64 |
| 10 | AES IV, 12 bytes |
| 22 | creator pubkey, 32 bytes |
| 54 | ciphertext length, u32 |
| 58 | quicknet round, u64 |
| 66 | version 3: tlock ciphertext (unarmored age file), 16 to 4608 bytes. Version 4: close tip lamports u64 at 66, close grace seconds u64 at 74, ciphertext from 82 |
Human UI
index.htmlin the same folder: create, the creator's Close and get rent back, and Close and claim tip for others after grace.read.html?account=...#k=...: the reader for a note link (no wallet, no wallet actions). Share and Copy buttons use the Web Share API (device share sheet) and the clipboard.reclaim.html?account=...: the creator's reclaim page. No key and no decrypt: countdown, Connect wallet, then one Close and get rent back button from unlock (an unfinished write: at any time); the note history once the account is gone. A#k=fragment is stripped and ignored.due.html(Due notes): notes anyone may close now for the tip, most overdue first, plus the ones still in grace; Close and claim tip per row or Close all (max 5). No keys.- Create and Close need an injected
window.solanawallet (Phantom-style) set to Solana devnet. The page embeds no payer keypair. - Reading a link needs no wallet.
Pages: Create, Read, Due notes, FAQ, Uses, Authority, Upgrade, Compliance, Terms.