# Open Ledger

The Open Ledger saves a community's history on Monad testnet, as text, so that anyone with the chain and an
RPC can read it back. It does not need an Orb server, an account or a database to do so. The owner decides
whether the saved history is private (encrypted with a key only the owner holds) or public (plain text,
forever). Members change nothing.

The wire format is specified in [docs/orb-ledger-protocol.md](orb-ledger-protocol.md). This page is about what
the feature does, what it promises, and what it does not.

## What is saved

- The community's name, bio, channel list and role names, as a description entry, written first and again when
  it changes (at most once an hour).
- The text messages of the channels the owner chose, in order. GIFs appear as a marked, titled line. Files
  appear as a marked name, never as file bytes.
- Deletions, as tombstones: a message deleted after it was saved is marked removed in every later read.

Not saved, ever: voice, video, file bytes, direct messages, end-to-end encrypted (sealed) channels, reactions,
account identifiers and wallet addresses. Message authors appear as a per-ledger reference plus the display
name; a private ledger also keeps the username handle and a roster.

## Two modes

**Private (recommended).** Every entry is encrypted to a P-256 key that the owner's browser generates. The key
is never sent to Orb. The owner receives a recovery code, shown once, which opens that key from a record kept
on chain. Without the code, a reader sees a list of sealed entries and nothing else. The owner's consent is
signed with their Orb Wallet over readable text that names the ledger, the mode, the scribe and the contract.

**Public.** Entries are plain text on chain and can be read on the block explorer. This is permanent: nothing
written can be taken back. The owner must tick a box to confirm it. Only channels every member can see may be
published: private, gated and hidden channels are refused, and their existence is not announced.

## What Orb promises, and what it does not

Promised:

- Private ledgers are readable only with the recovery code. The operator's scribe key cannot read them back
  from the chain: each entry is sealed to the owner's public key, with a fresh ephemeral key pair per entry.
- Every entry is numbered and hash-chained. A reader can prove that nothing in the middle was dropped or
  swapped, and that the owner's consent verifies against the owner's address.
- The contract accepts writes only from the scribe the owner's consent names, only in the order the chain
  expects, and never on a chain other than Monad testnet (10143).
- Secrets that match common key formats are removed before anything is written. In public ledgers, e-mail
  addresses and phone numbers are removed as well.

Not promised, and worth knowing:

- **The operator can read the text before it is sealed.** The keeper reads the community's messages from
  Orb's database to build each entry, as Orb does for every hosted community today. End-to-end encryption
  between members is a different feature (direct messages and sealed channels). A private ledger protects the
  history from the chain, from explorers, and from every reader without the code; it does not hide it from the
  operator who runs the keeper.
- **Deletion does not erase what was written.** A tombstone tells every reader to hide a message, but the earlier
  entry still holds it. A public ledger keeps that text in plain form for good. Delete before the entry is
  written, or do not publish a channel you may need to clean up later.
- **Redaction is a safety net.** It recognises common key formats and contact details. It will miss things, and
  it will sometimes remove text that was not secret.
- **No forward secrecy.** Whoever obtains the private key or the recovery code can read the whole private
  history, past and future. Rotating a key means a new ledger.
- **A lost recovery code cannot be recovered.** Orb never stores it. The owner keeps the only copy.
- **Testnet only.** The contract has no upgrade path and no funds. It is an experiment in a public record, not a
  production archive.

## Cost and allowance

Every byte written to Monad costs the operator gas, so writing is metered rather than promised without limit.
Each ledger has a daily write allowance (256 KiB by default, `ORB_LEDGER_DAILY_BYTES`), and the operator's
scribe account has a daily fee ceiling (`ORB_LEDGER_DAILY_BUDGET_MON`). A community whose owner holds an active
Holder Pro license (100 ORB locked for 30 days, see [ORB token](orb-token-testnet.md)) gets four times the daily
allowance. The keeper reads the license from the chain on each run; a license it cannot read counts as absent,
so the allowance never rises on a guess. The panel shows the allowance in force.

Every ledger costs the operator gas, so how many can run at once is capped as well: one owner may save up to
three communities at a time (`ORB_LEDGER_MAX_PER_OWNER`) and an Orb up to a hundred (`ORB_LEDGER_MAX_LIVE`). Without a
cap, empty ledgers from many accounts could use up the scribe's whole daily fee ceiling before a real community
is written. A refused owner is told to stop one first.

### Measured

`npm run ledger:bench` builds a community on a local chain (chain id 10143) and a real SQLite file, saves its whole
history through the keeper and reads it back. The run below used `BENCH_MESSAGES=100000`: 100,000 messages in
one channel and 5,001 members, on a single laptop.

| What | Result |
| --- | --- |
| Open the ledger and write the description (private roster of 5,001 members) | 0.9 s, 8.6 KB sealed |
| Plan the next batch with 100,000 messages waiting (dry run) | 158 ms for 268 messages, 17.8 KB sealed |
| Write the whole backlog | 386 entries in 144 s (694 messages/s on a local chain) |
| On chain for those 100,000 messages | 6.65 MB, 67 bytes per message |
| Gas per entry | about 407,000 for 17.8 KB, at a local chain's prices |
| A routine pass with 100,000 messages already saved (finds 1 new, 1 deleted) | 221 ms; writing both takes 0.4 s |
| The owner's view of the ledger | 23 ms |
| Read back all 388 entries with the recovery code | 10.2 s on a local node (26 ms per entry) |

Three things to take from it. The time to write is the chain's, not Orb's: on Monad each entry waits for at
least a block. The bodies in this fixture are random hexadecimal, which does not compress, so ordinary
text takes fewer bytes than 67 per message. And Monad prices calldata differently from the local chain, so
`npm run ledger:keeper` (a dry run) is the way to see the real gas for a batch.

The consequence worth knowing is the allowance. At the default 256 KiB a day a ledger writes about 3,900
messages of this size, so a 100,000-message history takes about 26 days to reach the chain, or about a week
for a Holder Pro community. The owner's panel shows how many messages are waiting and how long that takes at
the allowance in force. Most communities are far smaller.

## Turning it on

Owners find the panel in the community profile settings, under the portability panel.

1. Choose private or public, and the channels to save. Channels that the mode cannot carry are shown and cannot
   be ticked.
2. Continue. For a private ledger the browser creates the key and shows the recovery code. Save it. Tick the
   box. A recovery kit can be downloaded as a text file.
3. Sign the consent with the Orb Wallet. The signature is checked by the server and again by the contract.
4. The keeper opens the ledger on chain, then writes a description and the first messages. Messages wait ten
   minutes by default, so their authors can still remove them, and are written in batches.

Owners can change the channel choice, pause and resume, and stop. Stopping is signed too. What was already
written stays on chain; nothing is rewritten.

## Reading it back, and sharing it

The reader is the independent client's **Open Ledger** tab (`/independent?tab=ledger`), or `npm run ledger:read`. It
asks only an RPC for three things (a call and two log queries), so no Orb server is involved, and it shows what it
checked before it shows a single message:

- **Complete and intact**: every entry is present and each one follows from the one before it. If anything was
  dropped or swapped, the page says which entry and does not pretend otherwise.
- **Owner consent verified**: the owner's signature over the opening text matches the owner's address.
- **The contract matches the published build**, when the published artifact is part of the client.

Then it lays the community out like the chat it came from: channels with counts, days, authors, replies, a search box,
the messages a member removed shown as removed, and downloads of the transcript and of a JSON proof (the entries,
their blocks and transactions). A private ledger opens with the recovery code, typed into the page and used in the
browser; without it the reader still proves the chain is intact and says the messages are sealed. **What the owner signed**
opens the exact text the owner's wallet was asked to sign, rebuilt from what the chain holds, with the signature next to it.

A link such as `/independent?tab=ledger&contract=0x…&ledger=0x…` opens straight onto a community and reads it, so a
saved community can be shared like a page. A member of a saved community sees a **Saved on Monad** chip in the
channel header that opens exactly that link. It says "Saving" until the first entry is on chain, and says nothing for
a community that is not saved.

The reader also has a **Read the demo community** button: one click reads the public demo ledger described below, with no
account and no wallet, so a visitor with no ledger of their own can see the whole thing work.

## Bringing a Discord community in

A community that is moving can import its history first: see [Bringing a Discord community into Orb](orb-discord-import.md).
Imported channels are ordinary read-only channels, so they are saved, filtered and read back like any other, with the
same safety filter and the same limits.

## Where it is deployed

The contract is live on Monad testnet (10143) at [`0xA6DE00A98b0f3a191992D1099d48a62905b64eD0`](https://testnet.monadscan.com/address/0xA6DE00A98b0f3a191992D1099d48a62905b64eD0), deployed in
transaction [`0x5a53e754…`](https://testnet.monadscan.com/tx/0x5a53e754cdac52f7945d50380785a2a4dc4551222d9fbeaec9b306aa1cbfcdff) from the testnet deployer account
`0x64Cb2066e60d5B9d88Ced9AAE98c89C18a0ad8CE`, which is its admin and, for now, its only scribe. The record is
`public/ledger/deployment.monad-testnet.json`, and the independent client embeds the address.

One thing to decide before real communities use it. A scribe is the key that writes entries, and an owner's signed consent
names it. While the scribe is the same account as the Arena relayer, the web server (which holds the relayer key for the Arena)
could sign ledger entries too, so "the web server never holds the scribe key" would not be true of that deployment. What
it could not do is read a private ledger: entries are sealed to the owner's key, and the scribe never has it. To keep the
promise, give the keeper a dedicated key: fund a new account, have the admin call `setScribe(newScribe, true)`, point
`ORB_LEDGER_SCRIBE` (web) and `ORB_LEDGER_KEY` (keeper only) at it, and optionally `setScribe(oldScribe, false)`. Do this before
the first community turns the ledger on, because consents name the scribe that was configured when they were signed.

### The public demo ledger

`Orb Builders (demo)` is the invented community above (three channels, 28 messages) saved publicly on the real contract, plus one
message written live while the demo video was recorded, with the deployer wallet `0x64Cb2066e60d5B9d88Ced9AAE98c89C18a0ad8CE` as
the owner who signed the consent. It is left open and is the community the reader's demo button and the judge page's sample link
read. The record is `public/ledger/demo.json`; `npm run ledger:demo` made the first two entries (a dry run unless given `--publish`),
and the recorder of the demo video (`scripts/record-orb-demo-v3.mjs --live`) wrote the third.

| Step | Transaction | Block |
| --- | --- | --- |
| Open the ledger | [`0x4dee34c7…7dd74a`](https://testnet.monadscan.com/tx/0x4dee34c7ef3f31ea2a922edeeb146f7952f8dae4504bf0580f359a25257dd74a) | 69601265 |
| Entry 1: the community's description | [`0x06431210…24218b`](https://testnet.monadscan.com/tx/0x06431210ffa1ae9f50548c76b70864c17d71b649a86b6ce3481bd0355f24218b) | 69601273 |
| Entry 2: 28 messages | [`0x1a0251e6…1fcf93`](https://testnet.monadscan.com/tx/0x1a0251e66814a185421b5535c51d52de926e05f60563fded97999185ba1fcf93) | 69601279 |
| Entry 3: 1 message, written live in the demo video (259 bytes) | [`0xd5ac3e0e…2a496b`](https://testnet.monadscan.com/tx/0xd5ac3e0eea926a6feb7d38052da7b51d67a91b509d6777aa1551afea312a496b) | 69602865 |

The ledger id is `0xe6e2ea5420d807803cb2b5ebdd08786d324d1b95916ffcf6505b183c4eae3028`. Read it with `npm run ledger:read -- --ledger 0xe6e2ea5420d807803cb2b5ebdd08786d324d1b95916ffcf6505b183c4eae3028`.

**A first demo ledger was replaced the same day.** The first one (`0x039589f9047c2da39e2fddd5e984a52fef54308f38f6bb88440f793c7e05abe8`, made with the same command: [`0xdd16bd6b…e84450`](https://testnet.monadscan.com/tx/0xdd16bd6b34c76b34b62fd567e6a71a96b5b57d539b68f811e43bec6d48e84450), [`0x210620f1…2960db`](https://testnet.monadscan.com/tx/0x210620f1ed88d6cf5c7c2599874a91f26ce95059356ff46cc7f2cd525d2960db), [`0xda4138c4…84a24c`](https://testnet.monadscan.com/tx/0xda4138c4c910f747cfffcd95307661c9c9f977a5230272f5a9554172ec84a24c)) also took a
message written live by the recorder (entry 3, [`0x6d872ad3…cfddba`](https://testnet.monadscan.com/tx/0x6d872ad3c1ff585d552bbfa60994bc3ec3e385348d1775366f065c1ee7cfddba), block 69600588), and the reader then showed that message under
a second `#general` tab. The recorder's disposable copy of the community had given its channels new ids, and a ledger tells channels apart by
id, so a message from the copy could not join the original channel. The recorder now gives the copy the ledger's own channel ids before it
writes anything, the ledger above was made after that fix, and the first one was stopped with the owner's signature ([`0x4175cd4f…5c27ba`](https://testnet.monadscan.com/tx/0x4175cd4ffffa3ae3e8d246f28b104871a26bf6af8089146eacee9bc1285c27ba), block 69605116). Everything on a chain stays readable, so
the first ledger can still be read; the same slip is also why the recorder can now resume a take, recording a scene again without
sending a second transaction.

### A rehearsal on the real chain

On 9 October 2026 an invented community, "Orb Builders (sample)" (three channels and 28 messages from `npm run import:sample`,
brought in through the [Discord importer](orb-discord-import.md)), was saved publicly by the real keeper, read back from the
chain, and stopped:

| Step | Transaction | Block |
| --- | --- | --- |
| Open the ledger | [`0x4da5bf07…3240da`](https://testnet.monadscan.com/tx/0x4da5bf0729be1bd2da795d6d16ba677fbab921f2ab31734b65ab497f093240da) | 69583932 |
| Entry 1: the community's description (865 bytes) | [`0x7dc97393…8877fb`](https://testnet.monadscan.com/tx/0x7dc973937e273908dfe24d88e680d92f0de11e476db04fe74e1609268a8877fb) | 69583941 |
| Entry 2: 28 messages (4,695 bytes) | [`0xf748124b…5e1db2`](https://testnet.monadscan.com/tx/0xf748124bf8c1c3f7eff076c4d4323112aabc6490010a1b96cb6ff1020d5e1db2) | 69583947 |
| Stop it, with the owner's signature | [`0xae8bd997…428ff3`](https://testnet.monadscan.com/tx/0xae8bd9974823bf7977575fa091a12b4e9ae47156247335c1faee49efcf428ff3) | 69583963 |

The ledger id is `0x2a352421431a1282e48aaceaf0ab5fb2f0d62c0123bbd2ad30d018aaec30faf7`. Read it back with
`npm run ledger:read -- --ledger 0x2a352421431a1282e48aaceaf0ab5fb2f0d62c0123bbd2ad30d018aaec30faf7` (it needs no configuration: the command finds the contract in the published
deployment record), or open `/independent?tab=ledger&contract=0xA6DE00A98b0f3a191992D1099d48a62905b64eD0&ledger=0x2a352421431a1282e48aaceaf0ab5fb2f0d62c0123bbd2ad30d018aaec30faf7` on a build that includes the
reader. The reader reports it complete, with the owner's consent verified and the contract matching the published build.
The whole run, from deploy to stop, cost the deployer account about 0.25 test MON at 102 gwei (0.17 for the contract,
0.07 for the four transactions above).

What it shows, and what it does not. It shows the pipeline working unchanged on Monad: one keeper pass opened the ledger and
wrote the description and the messages, the entries are hash-chained, and a reader with only the public RPC (which allows
`eth_getLogs` over a hundred blocks at most) verifies them. It was a small run, so it says nothing about rate limits or
daily budgets under load. The owner of that ledger is a throwaway key made for the rehearsal, and no real person's text is in it.

## Operating it (Orb operator)

| Step | Command | Notes |
| --- | --- | --- |
| Build the contract artifact | `npm run ledger:compile` | Writes `public/ledger/OrbCommunityLedger.json`. |
| Check the tests | `npm run ledger:test` | Contract and reader against a local EVM, then the keeper end to end, then the archive page in a browser. |
| Estimate the deployment | `npm run ledger:deploy:check` | Dry run. Nothing is sent. |
| Deploy to Monad testnet | `npm run ledger:deploy:testnet` | Uses `ORB_LEDGER_KEY`, or the testnet deployer `ORB_ARENA_RELAYER_KEY` when that is not set. Writes the deployment record and the two public env values to `.env.arena.local`. Done on 9 October 2026. |
| Keeper, dry run | `npm run ledger:keeper` | Reports what it would send. |
| Keeper, for real | `npm run ledger:keeper:publish` | Run as its own process, with `ORB_LEDGER_KEY`. Add `ORB_LEDGER_KEEPER_SECONDS` for a loop. |
| Read a ledger back | `npm run ledger:read -- --ledger 0x…` | Set `ORB_LEDGER_RECOVERY_CODE` for a private ledger. The independent client reads one in the browser too (Open Ledger tab, `/independent?tab=ledger`), through the RPC its user chooses. |

The web server never holds the scribe key. It needs only `ORB_LEDGER_CONTRACT` and `ORB_LEDGER_SCRIBE`, which are
public. The budgets are set in `.env.example`: a per-ledger daily byte allowance and a daily fee ceiling for the
scribe account. When either is reached, the keeper waits and says so; it does not write past them.

After pulling this change, run the new migrations (`0053_orb_ledger.sql` and, for tips and the Discord importer, `0055` and `0056`) and `npm install` as usual.
