DRAFT 0.3.0 · definitions of the BSA kinds are a draft (round 5); two pilot circles run on test infrastructure · last checked 27 Oct 2026
On this page

Guide for AI agents and developers#

This guide is for an AI agent or a developer who must understand balanced wallets and pull their data correctly. It is written to be quoted: each section is self-contained, numbers are exact, and every statement says whether it is decided, a proposal or not built. The whole guide is also available as plain text in llms-full.txt.

Status. Definitions 0.3.0 are a DRAFT (round 5, 23 Oct 2026), not yet published as a Nostr event. The directory described here is live (pilot) and read-only for you. Answers shown as "real" were captured from it on the day of checking, 27 Oct 2026, and may have changed since. Where this guide and the definitions differ, the definitions win (kinds.json, kinds page).

0. The ten rules, in one screen#

  1. A balance is a line in a circle's own ledger. The only authority about it is that circle's chain of blocks on its own relays. A directory is an index, not an authority.
  2. Keys are 64 lowercase HEX characters, never npub. The API refuses npub with HTTP 400. Do not print or accept npub in your answers.
  3. Amounts are integers in minor units (cents), carried as base-10 strings of at most 15 digits. Rates are whole ppm. Times are unix seconds. Use BigInt. Never use floating point.
  4. Two layers. Directory (kinds 0, 30910, 30915, 30916, and copies of 43917/43918) finds things. The circle ledger (43910, 43911, 43916, 43917, 43918, 43919, 43921, 43924) holds the truth. See two layers.
  5. Verify before you say. Event id, signature, the chain by prev digest, the witnesses' quorum qs, the genesis pin, the circle id. Silence from a relay is not an empty answer.
  6. A relay answers at most 500 events per REQ. Page with until inclusive and de-duplicate by id. Same-second crowds need a tag filter instead of paging.
  7. Never trust verified: true alone. It means "this directory's collector checked the chain", not "you checked".
  8. State your freshness. Name the block height, the seal day and the time you read. A directory answer is at most about 15 seconds old plus the collector's round.
  9. Say what is decided, proposed or not built. Definitions are a draft.
  10. Never write. Nothing in this guide requires publishing to any relay.

1. The data model on one page#

1.1 Objects#

ObjectWhat it isKindIdentified by
CircleA community with one currency and one ledgercircle id = the event id of its found act (also called the birth number in the directory); 64 HEX
Found actThe founding charter, signed by the founder. Its first tag is ["c",""], so it cannot contain its own id43916 (t = found)embedded in block 1
Genesis digestThe digest of block 1; the circle's second anchor, with the circle id64 HEX
Circle keyA key that only stamps the circle record (30910). Replaceable by a Heart actHEX
AccountOne line per key in the ledger. A person's key is the account number (BSA ID)key
System accountsreserve, world, world-step, origin, and one wallet per project. Keyless: nobody can sign for themderived from `sha256("lb-system/3
NoteA payment, signed by the payer43910event id; lives inside a block
ActA signed deed: join, leave, vote, propose, exclude, found ...43916event id; lives inside a block
BlockA numbered page of the ledger: entries and the account lines they touched43911digest (not the event id)
Day sealA daily checkpoint after the morning, with roots over the day and the state43917digest, and day
Snapshot chunkA part of the full public state at a seal43918m = <scope16>:<date>, i = <scope16>:<date>:<index>
HeadA pointer to the newest seal, for directories30915 (addressable, d = circle id)
Circle recordThe circle as the directory lists it: name, currency, Heart, witnesses, relays30910 (addressable, d = circle id)
Member home"This key belongs to this circle since block h", attested by a witness30916 (addressable, d = letsbalance)author key
ReferenceA good-standing letter issued by the ledger when a member leaves43921event id
Rate quoteA currency rate for a payment between circles, valid for quote_ttl_s43919event id
StatementA witness or verifier says: checked, error, or restore test43924event id
ProfileName and picture of a person (plain Nostr)0author key

scope16 = the first 16 hex characters of the circle id. It scopes the tag values h, m, i so they are unique across circles.

1.2 The account line#

Every block ends with a line for each account it touched; every seal's snapshot holds a line for every account. The line (kind 43911, line_format) has these fields, all strings:

[index, balance, wave, cap, carry_micro, debt, aseq, last_h, note_n, note_head, last_gift_day, first_receipt_day, status, rules_v]

FieldMeaning
indexthe position of the account in the block's p tags (in a snapshot line this field is replaced by the key)
balanceminor units, may be negative (down to minus the wave)
waveminor units; grows by what the account receives, up to cap
capthe highest the wave may grow; fixed at joining from the circle's wave_cap (default 100000000 = 1,000,000.00)
carry_micromillionths of a minor unit carried by gravity (0 to 999,999)
debtwhat gravity wanted but could not take; at most the wave
aseqthe account's own gap-free entry counter
last_hheight of the previous block that touched the account
note_n, note_headthe payer's note counter and last booked note id
last_gift_day, first_receipt_daylocal dates that drive gravity's quiet-day switch
statusmember, frozen, excluded, left, closed, system, or a project's status
rules_vversion of the circle's own rules text the member accepted

The spendable amount is balance + wave. A plus is a positive balance; gravity only takes from a plus.

1.3 State lines and the state root#

The full decision state of a circle is a set of text lines, sorted by key (UTF-8 bytes):

PrefixContentKey
s3|<hex>|…the account line of a keys3|<hex>
g3|<name>|…governance and settings: name, currency, tz, tzdata, morning, circle_key, signer:<hex>, steward:<hex>, stewards_required, quorum, relay:<url>, param:<name>, params_h, mirror_h, start …g3|<name>
o3|<type>|…open items: project, motion, vote, hold, request, change, join, grant, invite, exclude, leaving, reference, close, owno3|<type>|<id> (vote has two ids)
a3|<id>|<expires>every booked act or note until it expires (duplicates are judged from the state)a3|<id>
i3|<payer>|<idem>|<expires>idempotency keysi3|<payer>|<idem>
u3|…every reference this circle granted a step to (decision E-13: never accepted twice)

The state root is the root of a sparse Merkle tree of depth 256 over these lines (section 4.3). A block's tag ["root", entries root, lines root, state root] and a seal's tag ["state", root, line count] commit to it.

1.4 Number and text rules (all kinds)#

  • Amounts: base-10 strings, minor units, at most 15 digits; aggregates over many accounts up to 20 digits; carry in micro-units; rates in whole ppm; times in unix seconds.
  • content of ledger events is canonical JSON (RFC 8785) with strings only: no JSON numbers, booleans or null.
  • Events carry no expiration (NIP-40) and no - (NIP-70) tag. The last tag is ["v","3"]. Tag order is fixed per kind; any other order makes the event invalid.
  • An event is at most 100,000 bytes and 1,000 tags; a tag value at most 2,048 bytes.
  • No ledger event carries a person's name or bank data. Free text is one line, with the limits given in the kinds reference.
  • A digest is sha256(JSON.stringify([0, pubkey, created_at, kind, tags without the "qs" tags, content])). Blocks and seals are named by digest because an event id changes with the set and order of witness signatures.

2. Finding circles and people#

Use the directory to find, then verify against the circle's own chain (section 3). The first directory:

  • relay: wss://directory.letsbalance.world/relay (read open)
  • API: https://directory.letsbalance.world/v1 (GET only, CORS *, Cache-Control: public, max-age=15 on 200)
  • machine-readable: directories.json, openapi.json

2.1 REST API#

RouteAnswer
GET /v1/circles{ indexBuiltAt, incompleteKinds?, count, circles: [CircleSummary] }. Verified circles only. ?unverified=1 adds circles the collector has not verified (with verified: false)
GET /v1/circles/{birth}{ indexBuiltAt, circle: CircleDetail }. 400 for a malformed birth number, 404 when unknown
GET /v1/people/{hex}{ indexBuiltAt, person: Person }. 400 for anything but 64 lowercase HEX (npub is refused). 404 when neither the directory nor the Lana relays hold a profile or a home of that key
GET /v1/search?name={ indexBuiltAt, query, people: [{hex, name, circle}], circles: [{birth, name, currency}] }. name is 2 to 64 characters, every word must match, accents and case ignored, at most 50 results each. People only if their profile or home is on the directory
GET /v1/health{ ok, ready, directoryRelay, index, indexError, collector, presentDays }. 503 while not ready

Other methods answer 405; an unknown route 404. After a restart every route answers 503 until the collector's first round has finished. When a kind could not be read completely, answers carry incompleteKinds: ["0", …] and everything else is still served. The /v1 shapes change only by adding fields.

Fields. Times: indexBuiltAt, verifiedAt are ISO 8601 UTC; createdAt is unix seconds; day and since are YYYY-MM-DD. Keys and ids are 64 lowercase HEX.

CircleSummary (every field always present):

FieldMeaning
birththe circle id
name, code, currency, tz, morningfrom the circle record (currency: EUR, USD or GBP; morning: local hour of the daily seal)
genesis, founder, circleKeydigest of block 1; founder key; circle key
parent{birth, genesis, checked} or null; checked = this directory verified the parent's chain with that first block
heart, heartRequiredCircle Heart keys and k (how many must agree)
witnesses, quorum, node[{hex, operator, sinceBlock}], {q, n}, the proposing witness
relays, archives, apithe circle's relays [{url, operator}]; archive relay URLs; the circle's own API URL or ''
mirrorOf, recordthe ledger height the record mirrors; {id, createdAt} of the record
verified, verifiedAt, verifiedHeightthe collector verified the chain from block 1, when, and up to which height
recordVerifiedthe record shown is the one the collector checked against the chain
latestthe newest head or seal {kind, id, day, n, seal, height, block, stateRoot, lines, createdAt} or null before the first seal
members, membersUncheckedkeys whose home the circle's own ledger confirms; valid homes without a ledger verdict yet (not members until confirmed)

CircleDetail = CircleSummary plus seals (the day seals of the newest 7 days, each with chunks, chunksExpected, snapshotVerified) and memberList ([{hex, name, joinedAtBlock, since, ledgerChecked}]).

Person: { hex, profile, profileSource, inCircle, circle, note }. profile is null or {eventId, createdAt} plus, when present as strings, name, display_name, about, picture, banner, website, nip05, lud16 (each cut to 2,000 characters; all other fields are left out, read the event for them). profileSource is "directory", "lana-relays" (read-only fallback, nothing stored) or null. circle is {birth, name, currency, joinedAtBlock, since, home:{id, createdAt}, ledgerChecked} or null. inCircle: false with a profile means "has a profile, no circle yet".

Real answers (captured 27 Oct 2026)#

GET /v1/circles:

{
  "indexBuiltAt": "2026-10-27T08:06:05.132Z",
  "count": 1,
  "circles": [
    {
      "birth": "5be0a734c3020470204d6907f34906771d81971cca22fdead3f550eda87f3f27",
      "name": "Mejmo Se Fajn",
      "code": "mejmo-se-fajn",
      "currency": "EUR",
      "tz": "Europe/Ljubljana",
      "morning": 4,
      "genesis": "ba5b8a67510893b3c46e7a8dbf2d43ddcf5709aa7d177a283ee9bb6495f565b0",
      "founder": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
      "circleKey": "6f68e576441b3bb5d9b9e235b7b3d2b29be9a0a346ea7a9633d40fe1bd15bc28",
      "parent": null,
      "heart": [
        "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
        "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3",
        "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
        "ce896dcbf1568709c3d89086279d7dbf69e95dea9d22af110be78febc4731602"
      ],
      "heartRequired": 3,
      "witnesses": [
        {
          "hex": "15062dde27ccde9b8ef24c4cc39e0ad3be93b3ec94249087ea0b47b862e32122",
          "operator": "balance.mejmosefajn.org",
          "sinceBlock": 1
        }
      ],
      "quorum": {
        "q": 1,
        "n": 1
      },
      "node": "15062dde27ccde9b8ef24c4cc39e0ad3be93b3ec94249087ea0b47b862e32122",
      "relays": [
        {
          "url": "wss://balance.mejmosefajn.org/relay",
          "operator": "balance.mejmosefajn.org"
        },
        {
          "url": "wss://relay2.balance.mejmosefajn.org",
          "operator": "placeholder-2.balance.mejmosefajn.org"
        },
        {
          "url": "wss://relay3.balance.mejmosefajn.org",
          "operator": "placeholder-3.balance.mejmosefajn.org"
        },
        {
          "url": "wss://relay4.balance.mejmosefajn.org",
          "operator": "placeholder-3.balance.mejmosefajn.org"
        }
      ],
      "archives": [],
      "api": "https://balance.mejmosefajn.org",
      "mirrorOf": 1,
      "record": {
        "id": "b00614b9bf6712a42f27063abafecb1f2a276c709f2d256e8c0234c3249ed26d",
        "createdAt": 1793086389
      },
      "verified": true,
      "verifiedAt": "2026-10-27T08:06:01.640Z",
      "verifiedHeight": 3,
      "recordVerified": true,
      "latest": null,
      "members": 1,
      "membersUnchecked": 0
    }
  ]
}

GET /v1/circles/5be0a734c3020470204d6907f34906771d81971cca22fdead3f550eda87f3f27 (the seals array is empty because the circle has no daily seal yet):

{
  "indexBuiltAt": "2026-10-27T08:06:05.132Z",
  "circle": {
    "birth": "5be0a734c3020470204d6907f34906771d81971cca22fdead3f550eda87f3f27",
    "name": "Mejmo Se Fajn",
    "code": "mejmo-se-fajn",
    "currency": "EUR",
    "tz": "Europe/Ljubljana",
    "morning": 4,
    "genesis": "ba5b8a67510893b3c46e7a8dbf2d43ddcf5709aa7d177a283ee9bb6495f565b0",
    "founder": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
    "circleKey": "6f68e576441b3bb5d9b9e235b7b3d2b29be9a0a346ea7a9633d40fe1bd15bc28",
    "parent": null,
    "heart": [
      "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
      "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3",
      "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
      "ce896dcbf1568709c3d89086279d7dbf69e95dea9d22af110be78febc4731602"
    ],
    "heartRequired": 3,
    "witnesses": [
      {
        "hex": "15062dde27ccde9b8ef24c4cc39e0ad3be93b3ec94249087ea0b47b862e32122",
        "operator": "balance.mejmosefajn.org",
        "sinceBlock": 1
      }
    ],
    "quorum": {
      "q": 1,
      "n": 1
    },
    "node": "15062dde27ccde9b8ef24c4cc39e0ad3be93b3ec94249087ea0b47b862e32122",
    "relays": [
      {
        "url": "wss://balance.mejmosefajn.org/relay",
        "operator": "balance.mejmosefajn.org"
      },
      {
        "url": "wss://relay2.balance.mejmosefajn.org",
        "operator": "placeholder-2.balance.mejmosefajn.org"
      },
      {
        "url": "wss://relay3.balance.mejmosefajn.org",
        "operator": "placeholder-3.balance.mejmosefajn.org"
      },
      {
        "url": "wss://relay4.balance.mejmosefajn.org",
        "operator": "placeholder-3.balance.mejmosefajn.org"
      }
    ],
    "archives": [],
    "api": "https://balance.mejmosefajn.org",
    "mirrorOf": 1,
    "record": {
      "id": "b00614b9bf6712a42f27063abafecb1f2a276c709f2d256e8c0234c3249ed26d",
      "createdAt": 1793086389
    },
    "verified": true,
    "verifiedAt": "2026-10-27T08:06:01.640Z",
    "verifiedHeight": 3,
    "recordVerified": true,
    "latest": null,
    "members": 1,
    "membersUnchecked": 0,
    "seals": [],
    "memberList": [
      {
        "hex": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
        "name": "",
        "joinedAtBlock": 2,
        "since": "2026-10-27",
        "ledgerChecked": true
      }
    ]
  }
}

GET /v1/people/b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2 (a member with no profile on the directory):

{
  "indexBuiltAt": "2026-10-27T08:06:05.132Z",
  "person": {
    "hex": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
    "profile": null,
    "profileSource": null,
    "inCircle": true,
    "circle": {
      "birth": "5be0a734c3020470204d6907f34906771d81971cca22fdead3f550eda87f3f27",
      "name": "Mejmo Se Fajn",
      "currency": "EUR",
      "joinedAtBlock": 2,
      "since": "2026-10-27",
      "home": {
        "id": "1947849c2b872f9751484fc0107a3a18c280173610bcd56a5fbf822626ab3fc5",
        "createdAt": 1793087353
      },
      "ledgerChecked": true
    },
    "note": null
  }
}

GET /v1/search?name=mejmo:

{
  "indexBuiltAt": "2026-10-27T08:06:05.132Z",
  "query": "mejmo",
  "people": [],
  "circles": [
    {
      "birth": "5be0a734c3020470204d6907f34906771d81971cca22fdead3f550eda87f3f27",
      "name": "Mejmo Se Fajn",
      "currency": "EUR"
    }
  ]
}

Error cases (real)#

RequestStatusBody
GET /v1/people/not-a-key400{"error":"a person is named by the HEX key: 64 lowercase HEX characters (never npub)"}
GET /v1/circles/zz400{"error":"a circle is named by its birth number: 64 lowercase HEX characters"}
GET /v1/circles/aaaaaaaa… (64 × a)404{"error":"the directory holds no circle record for this birth number"}
GET /v1/people/aaaaaaaa… (64 × a)404{"error":"neither the directory nor the Lana relays hold a profile or a member home of this key"}
GET /v1/search?name=a400{"error":"name is 2 to 64 characters"}
GET /v1/nothing404{"error":"not found"}

A 404 for a person means "not found in the places this directory looked", not "does not exist".

2.2 Nostr REQ on the directory relay#

The relay is a standard Nostr relay (NIP-01). It accepts only the kinds below; everything else, KIND 5 (deletion) and KIND 62 (vanish), is refused with a message that starts with blocked:. You only read.

KindWhatAccepted by the relay when
30910circle record, d = circle idversion 3; signed by the circle key it names; the founder's signature in g verifies; the circle is admitted and the record matches a mirror of its verified chain
30915circle head, d = circle idthe relay holds the circle record; signed by a current witness; a valid quorum; it extends the head held
30916member home, d = letsbalance, c = circle idattested by a witness in office at the join height
43917day sealas a head; made on its own day; only the newest 7 days; never two seals for one day
43918snapshot chunkfrom a current witness, for a held seal day; count equals the seal's snap count
0profileanyone; JSON object at most 16 KB; at most 64 tags; no expiration/- tag; 10 per author and 600 per sender address an hour; at most 100 with the same created_at

Filters that work (tested against the live relay):

["REQ","c",{"kinds":[30910]}]                                         every circle record
["REQ","c",{"kinds":[30910],"#d":["<circle id>"]}]                      one circle's record
["REQ","h",{"kinds":[30915],"#d":["<circle id>"]}]                      the circle's newest head
["REQ","s",{"kinds":[43917],"#c":["<circle id>"]}]                      the newest day seals
["REQ","k",{"kinds":[43918],"#c":["<circle id>"],"#m":["<scope16>:<YYYY-MM-DD>"]}]   one day's snapshot chunks
["REQ","m",{"kinds":[30916],"#c":["<circle id>"]}]                      the homes naming a circle
["REQ","p",{"kinds":[30916,0],"authors":["<hex>"]}]                     one person's homes and profile

A real circle record from the relay (kind 30910, abridged tags shown in full in the sample):

{
  "content": "",
  "created_at": 1793086389,
  "id": "b00614b9bf6712a42f27063abafecb1f2a276c709f2d256e8c0234c3249ed26d",
  "kind": 30910,
  "pubkey": "6f68e576441b3bb5d9b9e235b7b3d2b29be9a0a346ea7a9633d40fe1bd15bc28",
  "sig": "171e4a3dd7434929bb50d7d58e08a57f0f0c2d8d466946d53b9a2170875c8b79d0c8eca6c5e5c2688f093ce8bff5ebf9571b7eb4aa7d1a157d6952abe16bedb6",
  "tags": [
    [
      "d",
      "5be0a734c3020470204d6907f34906771d81971cca22fdead3f550eda87f3f27"
    ],
    [
      "code",
      "mejmo-se-fajn"
    ],
    [
      "name",
      "Mejmo Se Fajn"
    ],
    [
      "currency",
      "EUR"
    ],
    [
      "tz",
      "Europe/Ljubljana"
    ],
    [
      "tzdata",
      "2026e"
    ],
    [
      "morning",
      "4"
    ],
    [
      "g",
      "ba5b8a67510893b3c46e7a8dbf2d43ddcf5709aa7d177a283ee9bb6495f565b0",
      "9b4a2170ba6e4317abcbc3dac504aa887a8e5d7fc1998241cbdc6ce31f340affc2945df8d0bab00ed29aa84140118c7a7373c65a42888be2444cf4305570d84b"
    ],
    [
      "mirror_of",
      "1"
    ],
    [
      "founder",
      "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
    ],
    [
      "circle_key",
      "6f68e576441b3bb5d9b9e235b7b3d2b29be9a0a346ea7a9633d40fe1bd15bc28"
    ],
    [
      "signer",
      "15062dde27ccde9b8ef24c4cc39e0ad3be93b3ec94249087ea0b47b862e32122",
      "balance.mejmosefajn.org",
      "1",
      ""
    ],
    [
      "quorum",
      "1",
      "1"
    ],
    [
      "proposer",
      "15062dde27ccde9b8ef24c4cc39e0ad3be93b3ec94249087ea0b47b862e32122",
      "1"
    ],
    [
      "steward",
      "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
    ],
    [
      "steward",
      "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3"
    ],
    [
      "steward",
      "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4"
    ],
    [
      "steward",
      "ce896dcbf1568709c3d89086279d7dbf69e95dea9d22af110be78febc4731602"
    ],
    [
      "stewards_required",
      "3"
    ],
    [
      "relay",
      "wss://balance.mejmosefajn.org/relay",
      "balance.mejmosefajn.org"
    ],
    [
      "relay",
      "wss://relay2.balance.mejmosefajn.org",
      "placeholder-2.balance.mejmosefajn.org"
    ],
    [
      "relay",
      "wss://relay3.balance.mejmosefajn.org",
      "placeholder-3.balance.mejmosefajn.org"
    ],
    [
      "relay",
      "wss://relay4.balance.mejmosefajn.org",
      "placeholder-3.balance.mejmosefajn.org"
    ],
    [
      "rate_source",
      "ecb-daily",
      "The euro foreign exchange reference rates of the European Central Bank (base EUR; USD and GBP used), published once per TARGET working day and read by the node once a day; the source time is the reference date at 00:00 UTC; the app shows the rate date"
    ],
    [
      "api",
      "https://balance.mejmosefajn.org"
    ],
    [
      "definitions",
      "0.3.0"
    ],
    [
      "v",
      "3"
    ]
  ]
}

A real member home (kind 30916):

{
  "content": "",
  "created_at": 1793087353,
  "id": "1947849c2b872f9751484fc0107a3a18c280173610bcd56a5fbf822626ab3fc5",
  "kind": 30916,
  "pubkey": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
  "sig": "f4a863e2a6beb4a39ff5f1cd703b5f0be9ee405febcb36dc944bdc6fb909613f98ee9fb94003168921145cf5e6b8cf339d5047484ba5332afa090e1257fee4a1",
  "tags": [
    [
      "d",
      "letsbalance"
    ],
    [
      "c",
      "5be0a734c3020470204d6907f34906771d81971cca22fdead3f550eda87f3f27"
    ],
    [
      "g",
      "ba5b8a67510893b3c46e7a8dbf2d43ddcf5709aa7d177a283ee9bb6495f565b0"
    ],
    [
      "join",
      "2"
    ],
    [
      "attest",
      "15062dde27ccde9b8ef24c4cc39e0ad3be93b3ec94249087ea0b47b862e32122",
      "2026-10-27",
      "0e73cc5d970877a92051a066855d9e33e37418f710cbe8c5901ebfa39289bebbe704d2394b450c026ee09835f6cff61ecc3716ed75ad3efed9d3d41f21eb4798"
    ],
    [
      "v",
      "3"
    ]
  ]
}

What a relay reader must check itself (the JSON API does this for you, but you can do it too): the event id and signature; for a 30910, the founder's BIP-340 signature g over lb-genesis/3|<circle id>|<first block> and that the author is the circle_key tag; for a 30915 or 43917, at least q valid qs signatures of the record's current witnesses over lb-seal/3|<digest>; for a 30916, the attest signature over lb-home/3|<member>|<circle id>|<first block>|<join>|<since>. What the relay serves beyond the API: the whole signed event, so every field of a profile.

Paging. At most 500 events answer one REQ (with any limit). To read more, repeat the REQ with until = the oldest created_at you got, inclusive, and de-duplicate by id. If a page returns no new id, the whole second is full: use a narrower tag filter instead. The directory limits profiles to 100 with one created_at so that this always terminates.

2.3 Script#

examples/read-directory.mjs lists the circles by API and by relay (Node 22+, read only):

node read-directory.mjs                 # circles
node read-directory.mjs <64-hex key>    # one person

3. Reading a circle's own ledger#

The ledger of a circle lives on its own relays: at least four relays of at least three operators, the first one the circle's box relay. Get their URLs from relays[] in the circle summary, or from the relay tags of the circle record, or from the circle's link or QR code. Read from at least two relays at once. "Not found" counts only when all you asked have said they are done.

3.1 Filters on a circle's relay#

["REQ","b",{"kinds":[43911],"#c":["<circle id>"]}]                       every block (page it)
["REQ","b",{"kinds":[43911],"#h":["<scope16>:<height>"]}]                one block by height
["REQ","a",{"kinds":[43911],"#c":["<circle id>"],"#p":["<member hex>"]}] blocks that touched an account
["REQ","s",{"kinds":[43917],"#c":["<circle id>"]}]                       day seals
["REQ","k",{"kinds":[43918],"#m":["<scope16>:<YYYY-MM-DD>"]}]            a day's snapshot chunks
["REQ","r",{"kinds":[43921],"#p":["<member hex>"]}]                       a member's reference
["REQ","q",{"kinds":[43919],"#c":["<circle id>"]}]                       rate quotes

Pitfalls specific to relays: a relay answers at most 500 events per REQ; paging uses until inclusive (the oldest second is returned again, so de-duplicate by id); blocks may share a created_at second, so for one block use #h; a silent relay is not an empty answer.

3.2 What to verify, in this order#

  1. Event id. id == sha256(JSON.stringify([0, pubkey, created_at, kind, tags, content])), and the BIP-340 Schnorr signature sig verifies for pubkey over id.
  2. Completeness. The heights h run 1…N with no gap. A missing height: look at other relays; if it exists nowhere, stop and report the height. Never guess.
  3. Digest and chain. digest = sha256(JSON.stringify([0, pubkey, created_at, 43911, tags without "qs", content])). Block h carries prev = the digest of block h−1 (empty at height 1) and genesis = the digest of block 1 (empty in block 1 itself).
  4. The genesis pin. The circle id is the id of the found act embedded in block 1. Rebuild the found act from block 1's genesis entry: pubkey = found.k, created_at = found.t, kind 43916, tags = [["c",""], ...found.tags, ["v","3"]], content = found.c; its event id must equal the circle id. Also compare the digest of block 1 with the genesis digest you were given (from a link, or the g tag of the circle record). A thief cannot found a "false circle" with the same name: another found act has another id.
  5. The circle key. The genesis entry's circle_sig is the circle key's BIP-340 signature over sha256("lb-found/3|" + the 32 bytes of the found act id).
  6. The witnesses' quorum. A block counts only with at least q valid qs tags, each ["qs", witnessHex, sig] where sig signs sha256("lb-block/3|" + the 32 digest bytes) (seals use lb-seal/3|). Witnesses are those of the found act (signer and quorum tags), changed only by roles entries, which apply from the next block on. A block without the quorum is not the circle's and is ignored.
  7. Forks. Two different digests at one height, each with a valid quorum, or two valid seals for one day, are a fork alarm: stop and report.
  8. Roots inside the block. The tag ["root", entries, lines, state]: entries is an RFC 6962 Merkle root over the canonical JSON bytes of each entry in seq order; lines is an RFC 6962 root over the leaves of the block's account lines (section 4.1); state is the sparse Merkle root of the whole state after the block.
  9. Time. Booking times at never decrease; created_at of a block is the at of its last entry. A circle's mornings are computed with the time-zone database version pinned in its found act (tzdata); with another version a boundary-dependent result is unverifiable, never a guess.
  10. Reconcile with the directory. circleKey, genesis, witnesses and relays shown by the directory must equal what the chain says; the chain wins.

3.3 Script#

examples/verify-circle-chain.mjs does steps 1 to 6 (Node 22+; npm i @noble/curves for the signatures). Run against the live pilot circle:

node verify-circle-chain.mjs wss://balance.mejmosefajn.org/relay \
  5be0a734c3020470204d6907f34906771d81971cca22fdead3f550eda87f3f27 \
  ba5b8a67510893b3c46e7a8dbf2d43ddcf5709aa7d177a283ee9bb6495f565b0

Real output on 27 Oct 2026:

read 3 block events from wss://balance.mejmosefajn.org/relay
ok   heights 1..3 found, no gap
ok   circle id = id of the found act in block 1
ok   genesis digest equals the pinned one
ok   the circle key signed the found act (circle_sig)
ok   prev/genesis chain intact; every block has 1 valid witness signature(s)
tip: height 3, digest 4db4cfa189ccb430a77630a47079891086545ebb0f62db6b1c0e2d7a18dafc69

The script uses the witnesses of the found act only. A full verifier also applies each roles entry (witness, Heart, key and relay changes) from the next block on, and restores from the newest seal rather than from block 1 for a long chain.

3.4 What a block looks like#

A real block (height 2 of the pilot circle: a member joined). Block content is {"e": [entries], "l": [lines]}; an entry is [seq, type, at, body]:

tags: c, h ("5be0a734c3020470:2"), prev, genesis, seq ["seq","3","4"], at ["at","1793087354","1793087354"],
      root [entries root, lines root, state root], qs [witness, signature], v "3"
entries: ["3","act","1793087354",{"act":{"c":"","k":"<member>","sig":"…","t":"1793087353","tags":[["t","join"],["expires","1793692153"]]}}]
         ["4","rule","1793087354",{"in":["<join act id>"],"party":"0","rule":"member_join"}]
lines:   [["0","0","0","100000000","0","0","1","","0","","","","member",""]]

Entry types: genesis, pay, give, xout, xin, xdone, xundo, act, rule, roles, grav, rej. A rej is a refusal written in public with its reason. Replaying all entries in seq order from the previous state must give exactly the block's lines and roots; that is what every witness checks before signing.

4. Computing and verifying a balance#

4.1 From the newest block that touched the account#

The simplest honest answer for "what is X's balance now": take the newest block that names X in a p tag; the account line of X in it is X's line after that block. (Its last_h is the previous block that touched the account.) The block commits to its lines through ["root", …, lines root, …], so the line can be checked: the leaf is

s3|<hex>|balance|wave|cap|carry_micro|debt|aseq|last_h|note_n|note_head|last_gift_day|first_receipt_day|status|rules_v

for each party in p order (the line of that party with its full HEX key; last_h is the value from before the block), and the lines root is the RFC 6962 Merkle tree hash over those leaves (leaf = sha256(0x00 ‖ leaf), node = sha256(0x01 ‖ left ‖ right), split at the largest power of two below n). examples/balance-of.mjs does this. Real run on the pilot circle for the key above:

newest block that touched this account: height 3
{ index: '0', balance: '0', wave: '0', cap: '100000000', carry_micro: '0', debt: '0', aseq: '3',
  last_h: '2', note_n: '0', note_head: '', last_gift_day: '', first_receipt_day: '',
  status: 'left', rules_v: '' }
balance + wave (what the member can still pay): 0 minor units
lines root recomputed: bff4f77269c1a3e6b57d0daa0fcf4d7adce25224e6ee2bf31c895dea5846d53d
lines root in block  : bff4f77269c1a3e6b57d0daa0fcf4d7adce25224e6ee2bf31c895dea5846d53d (match)

This only shows the line is the one the block committed to. It is true if the block itself is part of a valid chain (section 3.2). It is as of that block: say the height and time.

4.2 From a day seal and its snapshot (no chain replay)#

  1. Read the newest seal 43917 from at least two relays and check its qs quorum (the witnesses of the office after its range).
  2. Read all its snapshot chunks 43918 (#m = <scope16>:<date>; the seal's snap tag names the count). Check that the chunks' RFC 6962 root equals snap's root and the lines they carry rebuild the seal's state root and sum to 0.
  3. Find the line s3|<hex>|… of the account: that is its state at the end of the seal's range.
  4. Prove it: the inclusion proof is the list of non-empty siblings on the key's path in the sparse Merkle tree (4.3). A bank or a light client can verify it against the seal's state root without any server of the circle.

4.3 The sparse Merkle tree of the state#

  • Depth 256. The path of a line is sha256(key) read bit by bit from the most significant bit; the key is the line's key fields joined by | (s3|<hex>, g3|<name>, o3|<type>|<id>, …; table in 1.3).
  • Leaf hash sha256(0x00 ‖ utf8(line)). Node hash sha256(0x01 ‖ left ‖ right). An empty subtree is 32 zero bytes; a node over two empty subtrees is empty too.
  • An inclusion proof is [(depth, siblingHex), …] for every depth where the sibling subtree is not empty.

examples/state-root.mjs computes a root, builds and verifies proofs, and checks the published test vector (76 lines, the whole state of an example circle after block 4):

$ node state-root.mjs --test
lines           76
computed root   df7981e5ec0191d848b06ca3ff2ce66f27a2385ef7ad39547689374b92efb359
published root  df7981e5ec0191d848b06ca3ff2ce66f27a2385ef7ad39547689374b92efb359
root matches    true
proof for       s3|5731510299edc4f75f7014023e739f351b3f9e461bc8dd3da07bcd0c798d7407 with 7 siblings
proof verifies  true

The test vector is TEST data from the definitions (test-vector-state.json).

4.4 Meaning of the numbers#

  • Units. Minor units of the circle's currency: for EUR, cents. wave_cap = 100000000 is 1,000,000.00. The start of a new circle is 100000 = 1,000.00, moved from origin into the reserve in block 1.
  • Balance may be negative: a payer's balance falls when they pay and cover is amount ≤ balance + wave. A payment to a member below the wave cap moves grow = min(amount, cap − wave) from the receiver to the reserve and adds it to the receiver's wave; then settled = min(debt, max(0, balance)) also moves to the reserve. So for a receiver below the cap the balance does not rise; the wave does.
  • Plus = a positive balance. Only a plus is subject to gravity.
  • Carry is the sub-cent remainder (micro-units). Debt is what gravity wanted but could not take, capped at the wave.
  • Sum. The sum of all balances in a circle (accounts, system accounts and project wallets) is exactly 0 after every block. origin is negative by the start (and what it later grants), reserve and project wallets are positive.
  • Integer arithmetic only. Parse strings to BigInt. Round with the rule stated for each formula. Never divide as floats.
  • Status. frozen, excluded, left, closed accounts exist and still appear in lines. A left account has a frozen history; do not call it "empty".

5. How gravity is computed#

Source: the ledger document, section 7.3 (definitions 0.3.0). Gravity is a public rule over public data in whole numbers, booked per account in morning blocks as a grav entry with the result, so phones never recompute it. A verifier can.

Inputs for one account and one morning D: the window [start, end) between the previous and this morning's boundary (unix seconds, from the circle's morning hour in its pinned time zone); the account's balance, wave, carry_micro, debt, last_gift_day, first_receipt_day at the cut-off (after its last booking before the boundary); the balance moves inside the window; and the circle's parameters band_ppm_day, excess_ppm_day, transition_days, rest_after_days.

The rule:

S       = Σ balance_i × seconds_i                over the window of W seconds (BigInt)
avg     = floor((2·S + W) / (2·W))               half rounds up;  avg ≤ 0  →  the rule is 0
rule_µ  = min(avg, wave)·band + max(0, avg − wave)·excess        (millionths of a minor unit)
total_µ = rule_µ + carry_µ;   cents = total_µ div 10^6;   carry_µ' = total_µ mod 10^6
wanted  = cents + debt;   room = max(0, balance at the cut-off)
deduct  = min(wanted, room);   debt' = min(wanted − deduct, wave)

The quiet-day switch (which band and excess apply). With D the morning's local date:

  • quiet = 0 if the line has neither last_gift_day nor first_receipt_day, else max(0, D − the later of the two that are set).
  • in_transition = first_receipt_day is set and D − first_receipt_day < transition_days.
  • resting = rest_after_days > 0 and quiet ≥ rest_after_days.
  • excess = band_ppm_day if in transition, else excess_ppm_day.
  • band = excess if resting, else band_ppm_day.

Start values of a circle (also the maximum: gravity can never be made stronger): band_ppm_day = 330 (about 1 % a month), excess_ppm_day = 22840 (half in about 30 days), transition_days = 60, rest_after_days = 183.

Whole seconds, and where a deduct counts. at, boundaries and windows are whole unix seconds. In the window that ends at the next boundary, a grav entry of morning D changes the balance from D's boundary, not from the entry's own time. A window may be 86,400 s, or 90,000 or 82,800 s on a clock-change day in a zone with daylight saving time.

Order of the morning. Payments never wait for the morning. "Visit before touch": before any entry after the boundary touches an account whose grav for D is pending, that account's grav is booked first, from the cut-off state, so the order of payments never changes anyone's gravity. After an outage the missed mornings are booked in date order, each reading the results of the earlier ones.

Worked example 1: a plus above the wave#

Wave 1,000.00 (100000), steady balance 1,500.00 (150000) all day, carry 250,000 µ, no debt; last gift long ago but not 183 days, first receipt more than 60 days ago.

avg     = 150000
rule_µ  = min(150000, 100000)·330 + (150000 − 100000)·22840
        = 33,000,000 + 1,142,000,000 = 1,175,000,000
total_µ = 1,175,000,000 + 250,000 = 1,175,250,000
cents   = 1175        carry' = 250,000
deduct  = min(1175 + 0, 150000) = 1175      →  11.75 flows to the reserve

Worked example 2: an average over a payment#

Same wave. The balance is 1,200.00 until noon, then a payment of 600.00 leaves, so 600.00 until the next boundary; carry 0.

S      = 120000·43200 + 60000·43200
avg    = floor((2S + 86400) / (2·86400)) = 90000        (below the wave)
rule_µ = min(90000, 100000)·330 + 0 = 29,700,000
cents  = 29       carry' = 700,000      deduct = 29      →  0.29

Both examples are produced and checked by examples/gravity.mjs and were cross-checked against the reference implementation of the definitions:

$ node gravity.mjs
… OK: both worked examples match the guide

6. The trust model#

WhoWhat they can proveWhat they cannot
The author of an event"this key signed this event" (BIP-340 signature)that the content is true
A witnessthat it recomputed a block or seal itself and signed the digestanything about a circle it is not a witness of; two blocks at one height would leave public proof against it
The circle (witnesses' quorum, ≥ q)that a block is the circle's block at that heightthat the circle is honest about the outside world (there is none: no bank data enters)
A relaynothing: it stores and servesit can hide or be late; it cannot forge; it can be silent
A directorythat it received signed events and checked them; that its collector checked a circle's chain from block 1anything beyond signatures it verified; it can omit or be late, and it may list only the circles its operator admitted

What verified: true means in the API. CircleSummary.verified = this directory's collector read the circle's relays and verified the chain from block 1 (signatures, witnesses, first block). recordVerified = the record shown matches a mirror of that chain. Person.circle.ledgerChecked / memberList[].ledgerChecked = the circle's own ledger confirms this home is current. snapshotVerified = the chunks rebuild the sealed state. None of this is your verification. It tells you that someone checked, with code you cannot see.

Rule: never trust a directory alone. Use the directory to find relays and anchors, then verify against the circle's own chain (section 3). When the directory and the chain disagree, the chain wins and the directory is stale or wrong.

Also true: a relay that does not answer is not "no record"; never answer "balance 0" from silence. A directory cannot see a circle it was not told about; "not listed" is not "not a circle". Two keys of one person are two accounts. The pilot circles run on test infrastructure with a single witness, publicly labelled.

7. Checklist: how to answer questions about a person's wallet#

  1. Normalise the key. Exactly 64 lowercase HEX. If given npub, say you cannot use npub and ask for the HEX (the Lana apps show it).
  2. Find the circle. GET /v1/people/{hex}: note inCircle, circle.birth, ledgerChecked, profileSource. If inCircle is false, they may have a profile but no wallet; say so.
  3. Fetch the circle. GET /v1/circles/{birth}: relays, genesis digest, witnesses, quorum, latest seal.
  4. Read at least two of the circle's relays. Verify the chain (section 3.2), or run verify-circle-chain.mjs.
  5. Get the account line from the newest block that names the key (4.1), or from the newest seal's snapshot with a Merkle proof (4.2, 4.3).
  6. Interpret with integers. balance, wave, spendable = balance + wave, status, cap. Convert minor units to a currency only for display, with the circle's currency.
  7. State the freshness: circle id (first 12 characters), block height, digest of the tip, seal day, the time you read, and which relays answered ("2 of 4").
  8. Say what you did not verify (for example "signatures were not checked"; "single witness, not decentralised"; "relay 3 did not answer").
  9. Never invent: no name from nowhere (the ledger has none; a name comes from the profile), no bank, no identity claims, no future dates.
  10. Be honest about status: definitions are a draft; pilot circles are test infrastructure.

8. Pitfalls and anti-patterns#

  • Reading npub or printing npub. Use HEX everywhere.
  • Floats. 0.1 + 0.2 has no place here. Use BigInt on strings.
  • Trusting verified: true, the API's members count, or the directory's latest as the truth.
  • Treating silence as zero or as "no".
  • Ignoring the 500 cap. A list of exactly 500 is almost certainly truncated. Page with until inclusive and de-duplicate; if a second is full, narrow with tags.
  • Paging with since or exclusive until and losing events that share a second.
  • Using the event id as a block's name. Blocks are named by digest; the event id changes with the set of qs signatures.
  • Applying the wrong witness set. roles entries change witnesses from the next block.
  • Forgetting the tzdata pin. A boundary computed with another time-zone release can differ on a clock-change day.
  • Believing a 30910 because it names a circle id. Anyone can publish one; only the one signed by the circle key the ledger names, matching its mirror and pinned genesis, counts. The first-seen rule is only extra safety.
  • Reading a copy as the original. The directory holds the newest seals for 7 days only; it is an index of the present, not an archive.
  • Mixing the layers. Asking a directory for a transaction history, or asking a circle relay for the people of the world.
  • Putting names, bank details or documents anywhere near the ledger. The ledger is public for ever.
  • Calling a draft a standard. Definitions 0.3.0 are a draft and still change.
  • Writing. You do not need to publish anything. Do not send events to directories or relays to "test" them.

9. Machine-readable aids#

All are static files on this site.

FileWhat
/llms.txta short map of the site for language models, with links
/llms-full.txtthis guide, the two layers and the kinds in one plain-text file
/directories.jsonthe registry of known directories (the first is Balance directory 1)
/kinds.jsonall BSA kinds of definitions 0.3.0: layer, replaceability, publishers, tags
/openapi.jsonOpenAPI 3.1 of the directory REST API with real example answers
/sitemap.xml, /robots.txtfor crawlers
/examples/five scripts, run against the live pilot: read-directory.mjs, verify-circle-chain.mjs, balance-of.mjs, state-root.mjs, gravity.mjs, and the test vector

Scripts need Node 22 or newer (global WebSocket and fetch). Only verify-circle-chain.mjs optionally uses the npm package @noble/curves for Schnorr signatures. None of them writes anything.

10. Where to read more#