Data storage

How the Tally client persists everything it knows: one IndexedDB record holding one root state object. Reconciled against the code on 2026-09-09.


1. Overview

The Tally client stores all application state in a single IndexedDB record. There is no remote database; every piece of user data — identity, friends, transactions, and the peer-message ledger — lives exclusively in the browser. Peers synchronise directly with each other over WebRTC; the signalling server is stateless beyond a short delivery queue.

A single user MAY be logged in on several devices at the same time — each browser tab / install gets its own IndexedDB record. Convergence between those records is not a storage concern; it is handled at the peer layer (see Peer communication §13 and the Ledger spec §8). Each device generates its own device_id on first start and persists it in the root state, so it survives reloads, reconnects and relay switches — peers and the relay address a specific device by (user id, device id).

This document describes:


2. IndexedDB Layout

ParameterValue
Database nameiou_client_db
Database version1
Object storeapp_state
Record keyroot_state

There is exactly one record. The store has no index; all access goes through the well-known key.

iou_client_db (v1)
└── app_state (object store)
    └── "root_state"  →  RootState

2.1 Opening and timeouts

Reads time out after 2 seconds and return null on timeout. Writes time out after 4 seconds and throw. If the database connection stalls (version-change event from another tab, open request blocked), the cached promise is reset so the next call reopens cleanly.

window.clearAppState is exposed on the global object as a convenience for developer console use.

2.2 Multi-tab safety for reset

When the user asks to be "removed" from the device, the implementation deletes the entire IndexedDB database (deleteDatabase()), not just the root_state record. A per-record delete is unsafe while other tabs of the same origin may still hold the previous RootState in their in-memory cachedState: any save from such a tab would resurrect a stale record immediately after the reset tab wipes it. Dropping the database forces every tab to go through openDatabase again, and the reset tab's subsequent reload then determines what (if anything) gets written back.

deleteDatabase() closes the local connection first so the deletion is not blocked by its own open handle. onblocked and onerror still resolve the promise — the caller is expected to reload the page immediately after, which drops any other open handle.


3. Root State (RootState)

{
  model_version: number,          // DATA_MODEL_VERSION constant (currently 4)
  device_id: string,              // stable per-device id, generated on first start
  relays: string[],               // *secondary* relay URLs added in settings
  share_my_relays: boolean,       // tell friends which relays we use (TIP-003 §10)
  stun_servers: string[],         // ICE STUN URLs; defaults to DEFAULT_STUN_SERVERS
  user: PersonModel,              // the local user (includes private keys)
  contacts: ContactsMap,          // key → ContactModel (public info only)
  ledger: LedgerEntryModel[],     // append-only authored/received message log
  outbox: PeerMessageModel[],     // messages waiting for delivery
  processed_peer_message_ids: string[], // deduplication set
}

The main relay — the WebSocket on the host that served the app — is not in relays; it is implicit and prepended when the view is built. Tally ships no TURN server, so stun_servers is the whole ICE configuration; see Communication.

model_version is checked on load by the app-state migration runner before normalization. Migrations are versioned by DATA_MODEL_VERSION; after migration, normalizeAppState(state) coerces the record into the current model shape and the migrated state is saved back to IndexedDB. The constant DATA_MODEL_VERSION = 4 is exported from app/js/models/data-model.ts, which is the authoritative definition of every type below — the tables here explain intent and invariants, not field-by-field truth.


4. Data Model Types

All factory functions (createPersonModel, createFriendModel, etc.) normalise inputs defensively: unknown fields are dropped, wrong types coerce to safe defaults, and the model registry is the single source of truth for field shape. Callers must go through these factories rather than constructing shapes inline.

4.1 PersonModel

Represents the local user (stored under user). Contains private key material.

FieldTypeDefaultNotes
idstring""Equals public_key (npub format)
namestring"Anonymous"Display name, trimmed
public_keystring""Nostr npub — used as the canonical peer identifier
public_key_hexstring""Same key in 32-byte hex (x-only secp256k1)
private_keystring""Nostr nsec — used for signing and ECDH
private_key_hexstring""Same key in 32-byte hex; empty when the key lives outside the app
signer_typestring"local"Where the signing key lives: local or nip07. Not secret, so it survives createPublicPersonModel. See Security
friendsFriendModel[][]Deduplicated by person_id. connections remains as a deprecated alias

createPublicPersonModel returns the same shape with private_key and private_key_hex forced to "". Used for contact records.

4.2 FriendModel

Represents one bilateral friendship stored inside user.friends. Named ConnectionModel before the friend/connection rename; that name survives as a type alias (see Friend).

FieldTypeDefaultNotes
person_idstring""Peer's npub; used as the unique key
person_namestring""Display name as last received from the peer
friendship_statusstring"accepted"One of pending_outgoing, pending_incoming, accepted, rejected, disabled (see Defaulting)
debt_eurnumber0Positive = peer owes you; negative = you owe the peer
trust_credit_limit_eurnumber0Agreed maximum credit exposure
pending_credit_limit_eurnumber \nullnullProposed new limit awaiting acceptance; null when none
pending_credit_limit_is_incomingbool \"lowered" \nullnullDirection/kind of pending limit change: true = incoming proposal, false = outgoing proposal, "lowered" = auto-applied lower limit
recent_transactionsTransactionModel[][]Short tail of recent transactions for display
last_synced_atstring""ISO-8601 timestamp of the last successful WebRTC sync with this peer
pending_name_change{oldName, newName} \nullnullDisplayed while waiting for user confirmation

Open payment requests are not stored here. They used to be — as a single pending_payment_request slot, which could only ever hold one — and are now derived from the ledger by getOpenPaymentRequests (Ledger spec §4.3) and attached to each friend by buildView. Old records still carrying the field lose it on the next normalisation.

4.3 TransactionModel

Stored inside FriendModel.recent_transactions.

FieldTypeNotes
idstring
datestringYYYY-MM-DD
amount_eurnumberPositive = received, negative = paid
notestring

4.4 LedgerEntryModel

One entry in the append-only local ledger. See the Ledger spec for full semantics.

FieldTypeDefaultNotes
idstring""Matches the originating peer message id
timestampstring""ISO-8601; set locally when the entry is appended
typestring""Peer message type (e.g. friend_request, tx_created)
from_user_idstring""Sender's npub
to_user_idstring""Recipient's npub
originated_atstring""Sender's created_at from the original peer message; needed to reconstruct the Schnorr digest
signaturestring""Schnorr (BIP-340) signature hex over the canonical inner-message digest
payloadobject{}Message-type-specific data (deep-cloned plain object)

Since TIP-006 an entry also carries authorship: the authorship proof (a signed Nostr event) that establishes who wrote it. The top-level signature field is the legacy bare Schnorr signature, kept only so pre-TIP-006 entries still verify; new code reads authorship.

timestamp is set by the local machine at append time; it is not covered by the signature. originated_at is the sender-asserted timestamp that is covered by the signature; it must be preserved to allow post-hoc verification.

4.5 PeerMessageModel

Entries in the outbox — messages queued for delivery that have not yet been acknowledged.

FieldTypeNotes
idstringUnique identifier
typestringPeer message type
from_user_idstringLocal user's npub
to_user_idstringRecipient's npub
created_atstringISO-8601 timestamp at creation
signaturestringSchnorr signature (may be empty for legacy outbox items)
payloadobjectDeep-cloned

4.6 ContactsMap

A plain object keyed by npub. Values are ContactModel (= createPublicPersonModel output, no private keys).

{
  [npub: string]: {
    id: string,
    name: string,
    public_key: string,       // npub
    public_key_hex: string,   // hex
    private_key: "",          // always empty
    private_key_hex: "",      // always empty
    signer_type: string,      // "local" | "nip07"
    friends: [],              // always empty for contacts
  }
}

Contacts are created when a peer message is first received from someone not yet in the map. They provide the public key material needed for ECDH and signature verification before the friendship is fully established.

4.7 processed_peer_message_ids

A string[] acting as a deduplication set. When an inbound peer message is successfully applied, its id is added here. On subsequent receives the message is silently dropped before processing. The array is normalised (deduplicated, empty strings removed) on every normalizeAppState call.


5. Storage API

All storage access goes through app/js/storage/indexeddb.ts.

ExportSignatureBehaviour
loadAppState() → RootState | nullReads the record; returns null on miss or 2 s timeout
saveAppState(state) → stateOverwrites the record; throws on 4 s timeout
clearAppState() → voidDeletes the root_state record from the object store (single-tab scenarios only)
deleteDatabase() → voidDrops the entire iou_client_db database; used by the "remove user" flow for the multi-tab-safe reset described in §2.2

There is no partial-update API; the entire root state is always written atomically as a single IndexedDB put. Callers load state, mutate in memory, then call saveAppState.


6. State Normalisation

normalizeAppState(state) runs every model field through the corresponding factory function, producing a clean RootState from whatever is loaded from disk. This is the point where unknown fields are stripped and defaults are applied. App-state migrations run before normalization so they can still see old fields that the current factories might otherwise discard. Normalization then re-applies DATA_MODEL_VERSION.

createEmptyAppState(userPerson) builds a minimal valid state for a brand-new user: empty connections, contacts, ledger, outbox, and processed-IDs list.


7. Dev Seed System

The app also seeds three fixed identities from a query parameter — ?seed=alice / bob / carol, applied only when IndexedDB is empty; see Tooling. Separately, during development the app accepts a __IOU_SEED_STATE property on window (injected by the Playwright harness or manually). On startup, if this property is present, the app clears IndexedDB and writes the seed state, then removes the property. This allows E2E tests to inject any fixture state without going through the UI flows.

The URL parameter ?seed=<name> triggers one of the built-in seed builders (alice, bob, carol) but only if no user state already exists. ?removeUser clears IndexedDB so a subsequent seed can apply.