# Orb Passport — cross-surface attestation on Monad

> Current status reviewed September 29, 2026. See [the release index](README.md) for shipped versus planned features, network, wallet signing and deployment boundaries. Older dated experiments below are retained as history.

One wallet, one verifiable record of what a member did across Orb. Voice rooms, Arena matches,
feed posts and community messages all write the same kind of evidence; each batch is hashed into a
Merkle root per epoch and appended to `OrbAttestationRegistry` on Monad testnet. Every epoch also
stores the previous epoch's root, so the sequence is tamper-evident: a sealed window cannot be
replaced without every later epoch showing the break.

## Why this exists

Rewards, league reputation, arena prizes and channel access are all decided from Orb's own
database. Before this, "this member won that match" was an assertion by the server, and the record
that decided a past outcome could be edited afterwards with nothing observable about it.

Because Orb is all-in-one, the interesting property is not a log of one surface — it is that
activity on one surface can be spent on another. A voice room can require evidence earned in the
Arena, and a stranger cannot grant themselves that evidence by editing a row.

## Canonical leaf

A leaf commits every field a proof must be bound to, so nothing can be moved, reattributed or
replayed:

```
leaf = keccak256(abi.encode(
  keccak256("orb:attest:leaf:v1"),  // domain tag
  chainId, epoch, index,            // which chain, which window, which position
  account, kind,                    // the subject's address, the activity
  scope, subject, at                // where it happened, what it touched, the second
));
```

`scope` is the community (or `feed`), `subject` the room, match, post or message id. All three enter
as hashes; the raw ids stay in the database. A message is evidence that a member spoke in a
community, never what they said — an end-to-end encrypted message records the same single hash.

`kind` is a small enum: `room-join 1`, `room-leave 2`, `mic-on 3`, `mic-off 4`, `arena-win 5`,
`feed-post 6`, `message 7`, `league-win 8`. `arena-win` is earned in the practice Arena; `league-win`
is only recorded after Orb reads a finished Community League round from its exact deployed testnet
contract and verifies the bound wallet joined the winning team and played at least one move. Each
member/round can record this once. The member must still belong to that community, and the event
waits for the next Passport epoch to be anchored before a role gate or independent verifier accepts
it. The onchain round and trophy remain the primary source for the result; the Passport commits a
wallet-bound participation claim. The contract declares `MAX_KIND` and **never enforces it**, and that is
what lets a new Orb surface appear without deploying a second registry — which would strand every
epoch already anchored under the first address. `npm run attestation:test` therefore checks the
invariant that matters instead of comparing constants: it anchors a root carrying every kind the app
emits and verifies each one on chain, so a registry that ever starts bounding kinds fails there
rather than quietly refusing a member's newest surface.

Kinds a member can repeat at will (`message`, `mic-on`, `mic-off`) are counted at most
`ATTESTATION_DAILY_CAP` times per person, per community, per UTC day, and later ones simply do not
enter the ledger. Without that, "three messages makes you a Member" is a five-second job and the
anchored record stops meaning participation. The member's message itself is never affected.

The tree is positional and zero-padded to the next power of two, with
`parent = keccak256(abi.encodePacked(left, right))`. Depth is derivable from the event count alone,
so a verifier needs only `(root, count, index, leaf, path)`.

`lib/attestation-shared.ts` (the prover) and `contracts/OrbAttestationRegistry.sol` (the verifier)
implement this independently. `npm run attestation:test` asserts they produce identical hashes —
that agreement is the whole basis for trusting a proof the browser checks locally.

## Who attests

Each surface seals with its own key, and the registry lists every approved attestor. A root is
therefore attributable: `EpochSealed` records which service published it, and the panel names the
sealer instead of presenting Orb as one anonymous authority. Ownership moves in two steps
(`proposeOwner`/`acceptOwner`) so a mistyped address cannot strand the registry.

## What is proven, and what is not

Proven:

- Orb committed a specific set of events, in a specific order, at a specific block.
- A given event is a member of that set (Merkle path, verified in the browser and by the contract).
- The sequence cannot be silently rewritten: sealing is write-once per id and each epoch links to
  its predecessor's root.

Not proven:

- That the events are true. The attestor chooses what enters a root; a root is a commitment, not a
  consensus about reality.
- That a person was physically present. Voice presence cannot be self-attested without trusted
  hardware, and a member running a modified client can generate activity they did not perform.

The panel and the verifier page both state this in-product — the same discipline Live Leagues uses,
where settlement is documented as "the latest observation in a bounded window".

## Consuming the record: the entry gate

A voice channel can require one evidence kind (`rooms.attest_gate`, set in channel settings by
anyone with `manage_channels`, shown as a badge in the channel list). `call_join` then refuses
until the member's address has that kind anchored in that community.

The gate is deliberately paranoid about its own database:

- Only voice entry enforces a gate, and `channelAction` drops a gate stored on any other channel
  kind, so configuration cannot promise enforcement that does not exist.
- An unknown kind is a 400 on save and a 500 at the door — never an open door.
- A member without a bound wallet cannot pass (403), because there is no address to attest about.
- Evidence that is sealed but not yet published is refused (403) as "not anchored yet".
- The epoch root is then read back from the registry (`epochRootOnchain`, cached 5 minutes). If the
  chain does not have that epoch, or its root differs, entry is refused with 503. Marking a row as
  anchored therefore buys nothing: the ledger's own flags are checked against the contract.
- Finally the epoch is **rebuilt**: every row that claims to belong to it is re-hashed into leaves at
  its stated position and the tree is computed. That root must come back identical, or the rows here
  are not the contents of the root they cite. This is the half the chain cannot see, and it is what
  kills the two edits a database write *can* make: appending a fabricated member to a real epoch to
  borrow its authentic root, and rewriting a committed row's address, kind, scope or subject. The
  same rule now guards every proof the server hands out (`buildProof`), so an unverifiable claim is
  missing from a passport rather than being asserted from a table.

`scripts/test-attestation-gate.mjs` walks those refusals over the real HTTP API, including the
tampered-row case, and `scripts/test-attestation-gate-ui.mjs` drives the same requirement through
the controls a moderator uses: the picker, the shield in the channel row, the notice in the room,
and clearing it again. Both run under `npm run attestation:test:ui`.

## Reading the record back: the community board

The Passport surface can rank one community by how much anchored evidence its members hold, broken
down per attested surface. It is the second consumer of the ledger, and it is deliberately narrower
than it looks:

- `attestationBoard(person, server)` calls `access()` first, so a non-member gets the ordinary
  membership refusal rather than a filtered view.
- Only anchored epochs count. Pending evidence ranks nobody.
- Evidence earned inside a community by someone who is not in its roster is listed by address,
  never by name. Naming them would be a disclosure Orb makes from its own database, and the chain
  never made it — on Monad there are only addresses and hashes.
- The panel says so in the same breath as the ranking, because a board is an Orb readout, not a
  property of the network.

## On a member's profile

A record only its owner can see is a form, not a feature, so the same anchored evidence appears
where people actually look: the profile dialog carries a headline — total proofs, and a chip per
attested surface — next to the Arena trophies and trade milestones.

`attestationEvidenceSummary(account)` is deliberately the cheapest reader in this codebase: one
grouped query over anchored epochs, no Merkle paths built, and no call to Monad. It is a *count*, and
the profile says so in the same breath, pointing at the thing that does verify — the portable passport
link the holder chooses to hand over. `attestationPassport` stays the surface for claims; the profile
only shows that a record exists and how wide it is. A person with no bound wallet, or with evidence
that never reached an anchored epoch, gets no section at all rather than a zero.

## Granting a role from the record

A community can declare a standing rule: hold this token, hold this NFT, or carry this much
anchored Orb evidence here — and receive this role. `orb_community_gates` and its admin screen
existed before this, but nothing ever read the table, so saving a rule changed nothing. The rules
now decide membership, and this is the first consumer that gives an ordinary member something to
gain rather than something to inspect.

`lib/community-gates.ts` evaluates a rule into a tri-state — holds, does not hold, or could not be
checked — and four rules keep the automation from becoming an escalation path:

- A rule only ever fills an **empty** role slot. A role a moderator assigned by hand is never
  overwritten, and never lifted, because only rows in `orb_role_grants` are treated as automatic.
- A rule only manages roles **below the moderation tier** (`ROLE_GATE_CEILING`, 50, in
  `lib/permissions.ts` — the level where `legacyPermissions` starts handing out kick, ban and audit
  powers). A rule aimed higher is filtered out of every read, and the admin screen does not offer
  that tier as a choice, so no one can store a rule that grants an administrator.
- A member who already holds a rule's role answers to **that rule alone**, so a second rule cannot
  move them sideways. Deleting a rule leaves its holders as they are: removing a requirement is not
  a mass demotion, and a moderator decides.
- Nothing is decided on an unreadable chain. `anchoredEvidenceCount` rebuilds every intact epoch
  from its positioned leaves and compares the result with the root read from the registry. A row
  changed after sealing cannot earn a role even when the number of rows stays the same. A count it
  cannot confirm yields `null`, not `false` — so the member is skipped,
  keeps their role, and the review reports them as undecided. That verdict is **per member**, not
  per community: one forged row parked in a fabricated anchored epoch would otherwise be enough to
  freeze every grant in that community forever.
- A forged row and a lost row are not the same accident. An epoch holding **more** positioned rows
  than it committed was appended to, so the whole count for that address is refused. An epoch
  holding **fewer** has lost leaves, and no proof can be rebuilt from it: those rows stop counting,
  while the intact epochs the same member earned keep deciding — and if nothing intact is left, the
  answer stays undecided instead of dropping to zero, because losing data must not cost anyone a
  role either.

A missing wallet is the opposite case: it is a fact Orb holds about its own database, not a failed
read, so it is `false` — and unlinking a wallet really does drop the role that evidence earned.

Four places run the rules, and none of them is "someone remembers to":

- `join_server` queues a review for the newcomer after the response, so a member who already has
  the evidence gets their role on the way in.
- The Passport surface shows each member the rules of one community, what each asks for, and how
  much of it their anchored evidence covers (`community_gate_status`), with a button that applies
  the rules to them alone (`community_gate_review`). Reading the card grants nothing; the grant is
  a write, and it is signed in `audit` by whoever asked.
- An admin can review the whole community from the same screen, which is the only path that
  requires `manage_roles`.
- The keeper re-decides every gated community right after it publishes a root, because that is the
  moment evidence that was invisible a minute ago becomes verifiable. A dry run publishes nothing
  and therefore changes nobody's role.

The member card says "liberado" only when the contract said yes, and shows `0/9` progress rather
than a guess when the rule is not met — `scripts/test-attestation-gates-ui.mjs` asserts both
against the live testnet registry, reusing epochs earlier runs anchored, so the suite itself never
signs a transaction. `scripts/test-attestation-ledger.mjs` covers what a browser cannot reach: the
moderation-tier filter, the spare-a-manual-assignment rule, a role lifted when its evidence leaves
the chain and restored when it returns, and a registry that goes dark mid-review.

Two lines keep that payoff from being abstract. When the member holds a role, the card counts the
private channels it opens — counted, not named, because a channel the member cannot enter is not
theirs to learn the name of; the same refresh that puts the number there already lists the channels
themselves. And while evidence has not reached Monad, the card says so with the ledger's own numbers
(`pending` events and sealed-but-unpublished epochs) instead of leaving a rule stuck at `0/1` with no
explanation. Granting a role dispatches `orb:role-granted`, which re-reads the shell snapshot, so the
member does not have to navigate anywhere to find out what they can now see.

## Marking a claim in the feed

A feed post can carry one of its author's anchored claims. It stores a pointer — `attestation`
holding `<epoch>:<index>` — and nothing else: no label, no verdict, no copy of the evidence. The
reader resolves the pointer at read time, and the mark it renders links to `watch.html`, where the
chain decides.

- `attestationProofForRef(person, ref)` is the whole gate: it returns a proof only when the claim
  belongs to that person and sits in an anchored epoch. `POST /api/orb/feed` refuses anything else,
  so nobody can mark a post with someone else's Arena win or with a position that holds nothing.
- The pointer is resolved on every read. If the ledger stops backing it — the epoch was unpublished,
  or the rows behind an anchored root were lost — the post simply loses its mark. A stale pointer
  never keeps asserting something Orb can no longer produce.
- The mark's own wording is deliberately modest: "anchored in the ledger · verify on Monad". The
  anchor claim is ours; the verification is the reader's, and the label does not pretend otherwise.

## Privacy

- Only wallet-bound members appear. A member without a bound wallet is not represented at all,
  which beats attributing them to a proxy address.
- Communities, rooms, matches and posts enter the anchor as hashes; the raw ids stay in the database.
- The contract publishes an epoch root, event count, time window and attestor. It does not publish
  individual wallet addresses, activity kinds or timestamps from the leaves. Those fields become
  visible to a recipient when a member shares a proof, and Orb's own member-only board shows
  community activity counts. A public proof cannot be made private again.
- Attestation rows deliberately have no `ON DELETE CASCADE`: if a member is removed, the rows that
  reproduce an already-committed root must survive, or the commitment stops being verifiable.
  That is not just a comment — each anchored epoch reports the member rows still held against the
  count its root committed, and the panel labels a shortfall "rows lost: proofs unservable" rather
  than showing "matches chain". Root and count can both agree with the chain while the rows behind
  them are gone, which is exactly the state that would otherwise pass unnoticed.
- Epochs are compared by id, and two histories of a ledger both number their first epoch 1. When a
  database restarts its chain — a restore, a fresh install pointed at a registry that already has
  anchors — its epoch 1 shares a number with the anchored epoch 1 and nothing else. The panel then
  says **"another ledger"** and shows the window each side holds, not "DIVERGED": a divergence
  claims Orb changed a proof the chain already has, which is a much worse thing to say and is not
  what happened. `lib/attestation-epoch-state.ts` holds that decision, and `scripts/test-attestation-ledger.mjs`
  covers all seven states.

## Operations

```
npm run attestation:compile          # solc -> public/attestation/, plus the static verifier bundle
npm run attestation:test             # contract + ledger suites, loopback ganache only
npm run attestation:test:ui          # gate over HTTP, the passport panel, the role screens and the verifier page
npm run attestation:deploy:check     # dry run against Monad testnet, publishes nothing
npm run attestation:deploy:testnet   # real deploy, writes ORB_ATTESTATION_CONTRACT to .env.arena.local
npm run attestation:keeper           # reports which epochs would be published, changes no roles
npm run attestation:keeper:publish   # sends seal() for each pending epoch, oldest first, then re-decides role gates
```

The instance in `ORB_ATTESTATION_CONTRACT` today is `0x6dd77E89D08eA8bbb951560685bB2a9eF756E5cb` on
Monad testnet 10143. The deploying key is both owner and first attestor, and every epoch it holds
can be audited with one `eth_call` to `audit(1, 999999)` without asking Orb anything.

Nothing loads `.env` files into these tests, no testnet funds are used and no real user key is ever
recorded; the chain side runs against loopback ganache on chainId 10143 with throwaway dev accounts.

Epochs are sealed by whoever opens the Passport surface, so a demo needs no scheduler; the keeper
only moves already-sealed roots on chain. It refuses to publish when local ids and the registry head
disagree, which is how a second publisher gets detected rather than silently forked.

Two details keep the ledger from wedging. An abandoned claim (a row inserted with an empty root by a
crashed seal) is recovered before anything else runs, so events cannot be stranded behind it. And
because the frontier can advance faster than the chain clock when events are bunched, sealing defers
rather than minting a window that ends in the future, which the registry would reject forever.

Gates stay closed until `ORB_ATTESTATION_CONTRACT` points at a deployed registry: with no contract
there is no anchored epoch, so no gate can be satisfied. That is intentional — a gate that opened
without a chain would be theatre.

## Independent verification

`public/attestation/` is a portable static directory. `watch.html` receives the proof in the URL
fragment — which browsers never transmit to a server — reads the registry directly over JSON-RPC,
recomputes the leaf, walks the path to the root stored on chain, then asks the contract to confirm
the same proof. It calls no Orb API; the automated suite asserts that. Pointed at a local chain it
prints "LOCAL EVM DEMO — not Monad network performance" rather than implying a public result.

`passport.html` carries a whole passport the same way: an `orb-attestation-bundle-v1` in the
fragment, holding up to `ATTESTATION_BUNDLE_LIMIT` proofs for one address. The panel's "take this
passport elsewhere" action builds it from the member's own anchored proofs. A forwarded link is
untrusted input, so `decodeAttestationBundle` rejects an unknown format, a foreign chain id, a
malformed subject address, an unknown kind, a broken path, or a proof belonging to anyone other
than the bundle's subject — and the page then recomputes every surviving claim rather than
believing any of them. `lib/attestation-browser.ts` holds the RPC policy, the connect handshake and
the chunked audit shared by both pages, so they cannot drift into disagreeing about what "verified"
means.

The limit worth stating to any recipient: a carried passport is a *selection*. It proves the listed
claims are real and says nothing about evidence the holder left out. Only the registry can say what
a whole epoch contained, which is why the page reports the chain history beside the claims it was
handed.

`OrbAttestationRegistry.audit(from, to)` walks every stored link in one call and returns the first
broken epoch, so a stranger can test the whole history with a single `eth_call` and no proof at all.
The read is bounded to a rolling window of `ATTESTATION_AUDIT_WINDOW` epochs and the UI says which
range it covered — a claim about "the last 500 epochs" is honest, an unbounded "all history" label
would not be. The page exposes it as "Audit entire history" (chunked) and the panel as a standing
banner. `seal()` always derives `prevRoot` from the stored head, so a break cannot be produced
through the contract API; the audit exists to confirm that from the outside.

## Known limits and next steps

- The public RPC is a trust dependency for readers who do not run their own node.
- Attestor keys are single addresses per surface. A multisig or threshold signer would make "who
  chose this root" as auditable as "what was in it".
- Epochs are sealed by wall-clock windows, not by client count, so a quiet community still produces
  one root per window.
- Evidence expires nowhere: a gate accepts an arena win from any past epoch in that community. A
  "within the last N days" bound belongs to the gate, not the ledger.
- Two things enforce the record: voice entry (`rooms.attest_gate`) and community role gates
  (`orb_community_gates`, which assign and lift roles). The board and the feed mark still display
  it without gating anything. League seats, prize eligibility and feed ranking are the obvious
  next consumers, and the passport already carries what they would need.
- A role granted for evidence is only lifted when a rule that placed it stops holding, so an
  inactive member keeps their role until the chain says otherwise. A decay window belongs to the
  rule, not the ledger — the same gap as the unbounded arena win above.

## Demo path

Against the preview instance (`node work/focus-preview.mjs`, registry deployed, keeper run once):

1. Open **Passport** — a live block, a measured load, the audit banner reading the chain in one
   call, and any epoch whose member rows we no longer hold labelled as such.
2. Pick a community in **Passport in one community** — who holds anchored evidence there, and the
   statement that this is Orb's readout rather than the network's.
3. Stay on that community and read **What your proofs unlock here** — the rules it declared, how
   much of each the member's anchored evidence covers, and *Apply the rules to me now*. The role
   lands in the member list, the audit history shows why, and a channel that only that role can
   see becomes readable in the same refresh.
4. Post in the **feed** with a proof attached from the composer's shield picker, then open the
   mark: `watch.html` recomputes it with no Orb API call.
5. Set **Prova de entrada** on a voice channel as a moderator, join as a member whose evidence is
   anchored (in), and as one whose evidence is not (out, with the reason).
6. Hand the **passport link** to a second browser: it verifies the whole selection against Monad
   and works from a copy of `public/attestation/` on disk.
