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:
- Audit trail — a tamper-evident history of all bilateral interactions (friend requests, transactions, trust-limit changes, payment requests).
- 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:
| Field | Who sets it | Signed? | Purpose |
|---|---|---|---|
timestamp | Local machine at append time | No | Ordering and display |
originated_at | Sender (copied from created_at) | Yes | Digest 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:
- a
payment_request_responseauthored by the request's recipient (paid or declined), or - a
payment_request_cancelauthored by its requester.
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:
tally-nostr-event-v1(TIP-006, current) — a signed Nostr event (kind 177700) whose content commits to the canonical inner message.tally-canonical-schnorr-v1— the legacy bare BIP-340 signature over the canonical digest, wrapped into the proof shape so pre-TIP-006 entries still verify.
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:
- Builds a set of existing entry ids.
- For each entry: normalises, skips duplicates and history the user deleted (
isDeletedHistoryEntry, see Defaulting), checks values, runsverifyLedgerEntryAuthorship. - Entries that fail verification are logged and discarded.
- New entries are prepended; after the loop the array is sorted descending by
timestamp. - 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:
| Bucket | Condition | Processing |
|---|---|---|
inboundLike | entry.to_user_id === myId | Applied via applyInboundPeerMessage — full handler logic runs |
outboundOnly | entry.from_user_id === myId | Inserted 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:
- Detects that no
userConnectionexists (isRecovery = !userConnection). - Calls
ensureUserConnectionto create the connection withfriendship_status: accepted. - Because
isRecoveryistrue, skips thewasAcceptedcheck that would otherwise returnnull(the shortcut for "friend was already accepted — nothing to do"). - Returns a notification object so the caller knows the message was processed.
- The caller (
routeInboundMessage) callsmarkProcessedPeerMessage, adding the entry's id toprocessed_peer_message_ids, ensuring the samefriend_acceptis 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:
type === "name_changed"from_user_id === myIdpayload.nameis a non-empty trimmed string
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:
- 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_dataexactly as friend sync would, except the sender returns all ledger entries whose id is missing from the initiator'sknown_ids(not the bilateral slice). Entries are verified, classified, and replayed identically to §7. - 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_databatch 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:
- The sender maintains a
broadcastedLedgerIdsset and skips any entry already in it. - On receiving
sync_datavia the self-mesh, the receiver seeds this set with each inbound id before applying the entries. The resulting data-change tick therefore sees the id as "already broadcast" and the entry is not echoed back.
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
| Property | Mechanism |
|---|---|
| Authorship integrity | Every entry carries an authorship proof by from_user_id; verifyLedgerEntryAuthorship rejects entries without a valid one |
| Non-repudiation | The 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 history | mergeSyncedLedgerEntries and handleSyncData reject unsigned or invalidly-signed entries before they enter the local state |
| Replay-safe deduplication | processed_peer_message_ids prevents the same entry from being applied twice, even across multiple sync cycles |
| Bilateral isolation | getLedgerEntriesForPeer only shares entries involving both parties; third-party conversations are never forwarded |
9.1 Limitations
- Key compromise — if a user's private key is stolen, an attacker can produce valid signatures for arbitrary messages. There is currently no key rotation mechanism.
- Timestamp forgery —
originated_atis sender-asserted and covered by the signature, so it cannot be altered after signing; however, a malicious sender can choose any timestamp at signing time. - Selective disclosure — a peer can withhold entries from
sync_data. There is no way for the recovering peer to know that entries are missing. Full recovery depends on peers being cooperative.
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
typesays what a record is (transaction_created,payment_request, …). It is not a version.authorship.schemesays how it is signed (tally-nostr-event-v1, or the legacytally-canonical-schnorr-v1), and the signed content names its framing (tally.signed_message.v1).- Nothing yet says which payload shape a given type uses — every payload is implicitly version 1.
10.2 The rule
- 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. - 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 inmigrations.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. - 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.