Ledger

The append-only local log every balance is derived from: what an entry is, how its author is proven, and how two peers merge theirs. Reconciled against the code on 2026-09-09.


1. Overview

The ledger is an append-only log of every peer message the local user has produced or received. It is persisted in IndexedDB (see Data storage) as the ledger array inside the root state object.

Its two primary purposes are:

  1. Audit trail — a tamper-evident history of all bilateral interactions (friend requests, transactions, trust-limit changes, payment requests).
  2. State recovery — if a user's IndexedDB is wiped, a peer who still holds their ledger entries can replay them chronologically and fully reconstruct the wiped user's view of the shared relationship.

Unlike the outbox (which holds messages pending delivery), the ledger is permanent. Entries are never removed.


2. Entry Structure

See Data storage §4.4 for the full field table. The key semantic distinction between the two timestamp fields:

FieldWho sets itSigned?Purpose
timestampLocal machine at append timeNoOrdering and display
originated_atSender (copied from created_at)YesDigest reconstruction for signature verification

Because timestamp is local it cannot be trusted by a sync peer. originated_at is what was inside the sender's signed inner message; it must be preserved unchanged so the Schnorr signature can be re-verified at any future point.


3. Local Appends

3.1 appendLedgerEntry(state, fields)

The primitive append. Sets timestamp to the current time, normalises through createLedgerEntryModel, and prepends (unshifts) so the array stays newest-first. Callers supply:

id          — matches the originating peer message id
type        — peer message type
fromUserId  — sender npub
toUserId    — recipient npub
payload     — message-type-specific plain object
signature   — Schnorr sig hex from the peer message
originatedAt — the peer message's created_at timestamp

3.2 appendLedgerEntryFromMessage(state, message)

Convenience wrapper for the common case of mirroring an outbound or freshly-applied inbound peer message into the ledger. Copies message.created_at → originatedAt and message.signature → signature.

When to call for outbound messages: at the moment the command is executed (e.g. acceptFriend in commands/friendship.js calls this immediately after building and signing the accept message, before the message leaves the outbox). This ensures the entry is in the ledger even if the peer never acknowledges receipt.

When to call for inbound messages: inside the message handler, after the handler has applied state changes and determined the message is legitimate. The bridge (peer/bridge.js) calls appendLedgerEntryFromMessage for every successfully applied inbound peer message as part of markProcessedPeerMessage.


4. Queries

4.1 getLedgerEntriesForPeer(ledger, myId, peerId)

Returns all entries where (from_user_id === myId && to_user_id === peerId) || (from_user_id === peerId && to_user_id === myId). This is the bilateral slice offered to a friend sync peer — entries involving third parties are never forwarded.

4.2 getAllLedgerEntries(ledger)

Returns a shallow copy of the full ledger. Used by the self-mesh (same-user devices) during initial catch-up and live broadcast (see §9): a self-peer is entitled to the full ledger because it represents another device of the same user.

4.3 getOpenPaymentRequests(ledger, myId)

Derived state rather than a slice: the payment requests still awaiting an answer, grouped by friend. Requests are deliberately not stored on the friend record — the ledger already holds everything needed, and a stored copy could only hold one request and drifted when entries arrived out of order.

A payment_request (keyed by its payload.request_id, falling back to the entry id for old entries) is open until the ledger holds a closing entry for the same id:

The role check means one party cannot close a request on the other's behalf with the wrong kind of entry. Because closure is a set lookup, the result does not depend on arrival order: a cancel synced in before the request it cancels still hides it. Closing entries are recorded even when the request is already closed — if a cancel crosses a payment in flight, the ledger keeps both, and the requester is told money moved.

4.4 Defaults and reactivation

getLatestFriendshipDefaults and getOpenReactivationRequests derive a disabled friendship's notice and its open suggestion the same way. A suggestion is open until a reactivation_response for its request_id is in the ledger. deriveTallyFromLedger rebuilds a friend's tally from transaction_created entries, for a friend whose record was deleted and came back with a suggestion. verifyReactivationHistory vets the history such a suggestion carries, entry by entry. See Defaulting.


5. Authorship Verification

5.1 Why verify?

The envelope that wraps sync messages (sync_data) is encrypted to the transport peer, which proves that peer produced the batch — but not that the individual entries inside it were authored by whoever they claim. A compromised peer could forward forged entries with a fabricated from_user_id. Verifying each entry against its claimed author closes this gap, and is the reason durable records carry their own proof rather than relying on the transport (see Security).

5.2 verifyLedgerEntryAuthorship(entry)

Reconstructs the inner-message shape from the stored entry:

{
  id:           entry.id,
  type:         entry.type,
  from_user_id: entry.from_user_id,
  to_user_id:   entry.to_user_id,
  created_at:   entry.originated_at || entry.timestamp,
  payload:      entry.payload || {},
  signature:    entry.signature,      // legacy bare Schnorr, may be ""
  authorship:   entry.authorship,     // TIP-006 proof, null on old entries
}

Then calls verifyTallyAuthorship(innerShape) from peer/authorship.js — the same check the live envelope path uses, so a record replayed out of the ledger is held to exactly the standard it was held to on arrival. It accepts either proof form:

Both ultimately verify a Schnorr signature against from_user_id's secp256k1 public key over a canonical JSON serialisation (fixed field order, proof fields excluded) hashed with SHA-256. Returns false when neither proof is present or valid — unproven entries are always rejected during sync.


6. Sync Merge

6.1 mergeSyncedLedgerEntries(state, entries)

Merges a batch of entries received from a sync peer:

  1. Builds a set of existing entry ids.
  2. For each entry: normalises, skips duplicates and history the user deleted (isDeletedHistoryEntry, see Defaulting), checks values, runs verifyLedgerEntryAuthorship.
  3. Entries that fail verification are logged and discarded.
  4. New entries are prepended; after the loop the array is sorted descending by timestamp.
  5. Returns the count of entries actually added.

Does not persist. The caller owns persistence so a single saveAppState can cover the ledger update and any concurrent state changes.


7. Recovery via Peer Sync

When a user's IndexedDB is wiped (data loss, device migration, or a deliberate test reset), peers who hold the other side of the ledger can fully reconstruct the wiped user's view of each shared relationship. The mechanism is the sync protocol (fully described in Peer communication §12); this section focuses on what happens at the ledger and handler level.

7.1 Prerequisites

Sync is initiated only after the WebSocket server signals queue_drained, ensuring any server-queued envelopes (normal inbound messages) are delivered first. This prevents re-requesting entries that are about to arrive anyway via the regular inbound path.

7.2 Entry Classification

When sync_data arrives with a batch of ledger entries, each entry (after signature verification) is placed into one of two buckets:

BucketConditionProcessing
inboundLikeentry.to_user_id === myIdApplied via applyInboundPeerMessage — full handler logic runs
outboundOnlyentry.from_user_id === myIdInserted into ledger via addLedgerEntries — no handler, state not changed

Entries addressed to neither side are silently dropped (should not occur for well-behaved peers; getLedgerEntriesForPeer on the sender side prevents this).

outboundOnly entries represent the user's own past outbound messages that were stored in the peer's ledger. They are backfilled into the local ledger for completeness but cannot affect local state (the local user's own actions are already reflected; the peer cannot add new obligations on the user's behalf).

7.3 Chronological Replay

inboundLike entries are sorted ascending by timestamp before replay. This matters for recovery: a tx_created entry can only apply after the friend_accept that re-created the connection. Replaying out of order would cause the transaction handler to fail to find the connection and silently drop the entry.

7.4 The friend_accept Recovery Path

applyFriendAcceptMessage (in peer/handlers.js) handles the case where no connection exists yet at replay time — i.e. the wiped user has no record of the friendship. The handler:

  1. Detects that no userConnection exists (isRecovery = !userConnection).
  2. Calls ensureUserConnection to create the connection with friendship_status: accepted.
  3. Because isRecovery is true, skips the wasAccepted check that would otherwise return null (the shortcut for "friend was already accepted — nothing to do").
  4. Returns a notification object so the caller knows the message was processed.
  5. The caller (routeInboundMessage) calls markProcessedPeerMessage, adding the entry's id to processed_peer_message_ids, ensuring the same friend_accept is not replayed again on the next sync.

Without step 3 the function would return null in recovery (because ensureUserConnection immediately sets friendship_status: accepted, making the subsequent isAcceptedFriendshipStatus check return true). routeInboundMessage treats a null return as an illegal/unhandled message and does not call markProcessedPeerMessage, causing the entry to be re-processed every time the peer reconnects — an infinite recovery loop.

7.5 Trust-Limit Recovery

The friend_accept message payload carries trust_credit_limit_eur — the value the accepter agreed to from the original friend_request. Replaying the friend_accept during recovery restores the established limit without any extra negotiation.

Bob's subsequent trust_limit_update (a friend_trust_limit message) arrives as a separate ledger entry. If it is also in the peer's ledger it will be replayed in chronological order after friend_accept, resulting in the lowered limit.

7.6 Remote Peer Allowance Timer

When a wiped user reconnects, peers who do not yet have a peer_connect signal from the server for the wiped user may still initiate a WebRTC connection via their own peer_connect — because the wiped user looks like a new registrant on the signalling server. The REMOTE_INITIATED_PEER_GRACE_PERIOD_MS = 15000 (15-second) timer in peer/mesh.js keeps such a connection alive even when it is not in the wiped user's current peerIds snapshot (which starts empty after wipe). Without this grace period the connection would be immediately closed by closePeersNotInSet before the sync could complete.


7.7 Self-Authored Seed Entry on Identity Creation

When a new identity is created (a fresh keypair — not when logging in with an existing nsec or ncryptsec), the client appends a self-addressed name_changed ledger entry as part of createUser:

from_user_id = to_user_id = <new user's npub>
payload      = { name: <chosen display name, or npub if empty> }
originated_at = <now>
signature     = Schnorr over the canonical inner-message digest

This seed entry ensures that the first device of a new user ships with the authoritative name already in the ledger. When the user later pairs a second device of the same identity, the self-mesh sync (§8) delivers the entry and the second device can derive the display name from the ledger without a round-trip.

The seed entry MUST NOT be created during login with an existing nsec / ncryptsec. Doing so would insert a fresh name_changed entry timestamped now and addressed from the user to themselves — which applyOwnNameFromLedger (§7.8) would then pick as the most-recent name and overwrite the real name already carried in the user's older ledger entries from their other devices.

7.8 applyOwnNameFromLedger

After any self-mesh sync completes, the client scans the ledger for entries where:

Entries are sorted ascending by originated_at (falling back to timestamp), and the most recent one wins. The resulting name is assigned to state.user.name and propagated to every contact's stored person_name via syncUserNameAcrossContacts so the user's own devices and every friend's copy of the user's display name agree after convergence.

This pass is idempotent: if the derived name already matches state.user.name, nothing is persisted.


8. Multi-Device Live Broadcast

Beyond recovery-after-wipe, the ledger is also the convergence substrate for a single user running multiple devices (laptop + phone, two browsers, etc.). Each device keeps its own IndexedDB record; the ledger is what brings them into agreement.

The mechanism has two halves:

  1. Initial catch-up (on self-peer connection). As soon as a WebRTC data channel opens between two devices of the same user (or, when WebRTC can't connect, as soon as the relay reports the other device online — see the peer communication spec §13.8), they exchange sync_hello / sync_data exactly as friend sync would, except the sender returns all ledger entries whose id is missing from the initiator's known_ids (not the bilateral slice). Entries are verified, classified, and replayed identically to §7.
  2. Live broadcast (on every append). Whenever the local client appends a ledger entry — whether from an outbound action or a freshly-applied inbound peer message — it MUST push that entry to every currently connected self-peer as a sync_data batch containing a single entry (or a small tail).

8.1 Loop Prevention

Without guardrails the live broadcast would echo forever: A broadcasts to B, B appends and broadcasts back to A, A appends and broadcasts again. Two cooperating rules break the cycle:

8.2 No Receipts on Self-Sync

Self-sync inner messages do not generate receipts (see Peer communication §13.7). After convergence, both devices share identical processed_peer_message_ids maps, so an acknowledgement would be redundant; moreover, a receipt from device A to device A' would itself trigger a broadcast, causing unbounded fan-out.

8.3 Authorship Handling

Every self-sync entry still carries its original authorship proof and is verified by the receiver exactly like a friend-sync entry. Reusing the one verification path keeps the merge code DRY and guarantees that even if the self-mesh were somehow spoofed, unproven entries would be rejected before touching local state.


9. Security Properties

PropertyMechanism
Authorship integrityEvery entry carries an authorship proof by from_user_id; verifyLedgerEntryAuthorship rejects entries without a valid one
Non-repudiationThe proof commits to the canonical inner-message digest (id, type, from/to, created_at, payload); neither the transport peer nor the server can produce a valid one for a different from_user_id
No forged historymergeSyncedLedgerEntries and handleSyncData reject unsigned or invalidly-signed entries before they enter the local state
Replay-safe deduplicationprocessed_peer_message_ids prevents the same entry from being applied twice, even across multiple sync cycles
Bilateral isolationgetLedgerEntriesForPeer only shares entries involving both parties; third-party conversations are never forwarded

9.1 Limitations


10. Record Format Evolution

A signed record cannot be edited — not even to modernise its shape — without breaking its proof, and only its author can sign it again. So a format change never rewrites history; it changes how history is read. This is the rule for every future change to a durable record. No change has needed it yet, so none of the machinery below exists in code.

10.1 What versions a record today

10.2 The rule

  1. Version the payload, per type, inside the signature. When a type's payload changes shape, records of the new shape carry payload.v (absent = 1). It is per type, not the app version: app versions change for unrelated reasons, and two peers on different app versions must read the same record the same way — so the version has to travel in the record, signed, not be inferred from when or where it arrived.
  2. Verify the original, then upcast. On arrival a record is verified exactly as signed. Then an upcaster — a pure function per (type, v), chained v1 → v2 → v3 like the storage migrations in migrations.ts — converts the payload to the current shape. Everything downstream (handlers, view, Logs) only ever sees the current shape, so legacy knowledge lives in the upcasters and nowhere else.
  3. Keep the original bytes alongside. The stored entry keeps what was signed, because it is forwarded during sync and the receiver verifies the original, not your conversion of it.

Nobody signs the upcast form. Your own signature over a friend's record would prove only that you vouch for it, which no third party should accept — and locally there is nothing to prove: the record was verified once, on arrival.

10.3 When old code can go

An upcaster can go once no stored entry uses its version. A verifier must stay as long as old records can still be forwarded to someone who needs to verify them — today, forever. TIP-008 (bilateral checkpoints) is what bounds that: once both friends have co-signed a checkpoint past a record, it is never forwarded again.