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#
- 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.
- Keys are 64 lowercase HEX characters, never npub. The API refuses npub with HTTP 400. Do not print or accept npub in your answers.
- 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.
- 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.
- Verify before you say. Event id, signature, the chain by
prevdigest, the witnesses' quorumqs, the genesis pin, the circle id. Silence from a relay is not an empty answer. - A relay answers at most 500 events per REQ. Page with
untilinclusive and de-duplicate by id. Same-second crowds need a tag filter instead of paging. - Never trust
verified: truealone. It means "this directory's collector checked the chain", not "you checked". - 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.
- Say what is decided, proposed or not built. Definitions are a draft.
- Never write. Nothing in this guide requires publishing to any relay.
1. The data model on one page#
1.1 Objects#
| Object | What it is | Kind | Identified by |
|---|---|---|---|
| Circle | A community with one currency and one ledger | circle id = the event id of its found act (also called the birth number in the directory); 64 HEX | |
| Found act | The founding charter, signed by the founder. Its first tag is ["c",""], so it cannot contain its own id | 43916 (t = found) | embedded in block 1 |
| Genesis digest | The digest of block 1; the circle's second anchor, with the circle id | 64 HEX | |
| Circle key | A key that only stamps the circle record (30910). Replaceable by a Heart act | HEX | |
| Account | One line per key in the ledger. A person's key is the account number (BSA ID) | key | |
| System accounts | reserve, world, world-step, origin, and one wallet per project. Keyless: nobody can sign for them | derived from `sha256("lb-system/3 | |
| Note | A payment, signed by the payer | 43910 | event id; lives inside a block |
| Act | A signed deed: join, leave, vote, propose, exclude, found ... | 43916 | event id; lives inside a block |
| Block | A numbered page of the ledger: entries and the account lines they touched | 43911 | digest (not the event id) |
| Day seal | A daily checkpoint after the morning, with roots over the day and the state | 43917 | digest, and day |
| Snapshot chunk | A part of the full public state at a seal | 43918 | m = <scope16>:<date>, i = <scope16>:<date>:<index> |
| Head | A pointer to the newest seal, for directories | 30915 (addressable, d = circle id) | |
| Circle record | The circle as the directory lists it: name, currency, Heart, witnesses, relays | 30910 (addressable, d = circle id) | |
| Member home | "This key belongs to this circle since block h", attested by a witness | 30916 (addressable, d = letsbalance) | author key |
| Reference | A good-standing letter issued by the ledger when a member leaves | 43921 | event id |
| Rate quote | A currency rate for a payment between circles, valid for quote_ttl_s | 43919 | event id |
| Statement | A witness or verifier says: checked, error, or restore test | 43924 | event id |
| Profile | Name and picture of a person (plain Nostr) | 0 | author 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]
| Field | Meaning |
|---|---|
index | the position of the account in the block's p tags (in a snapshot line this field is replaced by the key) |
balance | minor units, may be negative (down to minus the wave) |
wave | minor units; grows by what the account receives, up to cap |
cap | the highest the wave may grow; fixed at joining from the circle's wave_cap (default 100000000 = 1,000,000.00) |
carry_micro | millionths of a minor unit carried by gravity (0 to 999,999) |
debt | what gravity wanted but could not take; at most the wave |
aseq | the account's own gap-free entry counter |
last_h | height of the previous block that touched the account |
note_n, note_head | the payer's note counter and last booked note id |
last_gift_day, first_receipt_day | local dates that drive gravity's quiet-day switch |
status | member, frozen, excluded, left, closed, system, or a project's status |
rules_v | version 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):
| Prefix | Content | Key |
|---|---|---|
s3|<hex>|… | the account line of a key | s3|<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, own | o3|<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 keys | i3|<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.
contentof 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=15on 200) - machine-readable: directories.json, openapi.json
2.1 REST API#
| Route | Answer |
|---|---|
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):
| Field | Meaning |
|---|---|
birth | the circle id |
name, code, currency, tz, morning | from the circle record (currency: EUR, USD or GBP; morning: local hour of the daily seal) |
genesis, founder, circleKey | digest 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, heartRequired | Circle Heart keys and k (how many must agree) |
witnesses, quorum, node | [{hex, operator, sinceBlock}], {q, n}, the proposing witness |
relays, archives, api | the circle's relays [{url, operator}]; archive relay URLs; the circle's own API URL or '' |
mirrorOf, record | the ledger height the record mirrors; {id, createdAt} of the record |
verified, verifiedAt, verifiedHeight | the collector verified the chain from block 1, when, and up to which height |
recordVerified | the record shown is the one the collector checked against the chain |
latest | the newest head or seal {kind, id, day, n, seal, height, block, stateRoot, lines, createdAt} or null before the first seal |
members, membersUnchecked | keys 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)#
| Request | Status | Body |
|---|---|---|
GET /v1/people/not-a-key | 400 | {"error":"a person is named by the HEX key: 64 lowercase HEX characters (never npub)"} |
GET /v1/circles/zz | 400 | {"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=a | 400 | {"error":"name is 2 to 64 characters"} |
GET /v1/nothing | 404 | {"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.
| Kind | What | Accepted by the relay when |
|---|---|---|
| 30910 | circle record, d = circle id | version 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 |
| 30915 | circle head, d = circle id | the relay holds the circle record; signed by a current witness; a valid quorum; it extends the head held |
| 30916 | member home, d = letsbalance, c = circle id | attested by a witness in office at the join height |
| 43917 | day seal | as a head; made on its own day; only the newest 7 days; never two seals for one day |
| 43918 | snapshot chunk | from a current witness, for a held seal day; count equals the seal's snap count |
| 0 | profile | anyone; 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#
- Event id.
id == sha256(JSON.stringify([0, pubkey, created_at, kind, tags, content])), and the BIP-340 Schnorr signaturesigverifies forpubkeyoverid. - Completeness. The heights
hrun 1…N with no gap. A missing height: look at other relays; if it exists nowhere, stop and report the height. Never guess. - Digest and chain.
digest = sha256(JSON.stringify([0, pubkey, created_at, 43911, tags without "qs", content])). Block h carriesprev= the digest of block h−1 (empty at height 1) andgenesis= the digest of block 1 (empty in block 1 itself). - 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
genesisentry: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 thegtag of the circle record). A thief cannot found a "false circle" with the same name: another found act has another id. - The circle key. The genesis entry's
circle_sigis the circle key's BIP-340 signature oversha256("lb-found/3|" + the 32 bytes of the found act id). - The witnesses' quorum. A block counts only with at least
qvalidqstags, each["qs", witnessHex, sig]wheresigsignssha256("lb-block/3|" + the 32 digest bytes)(seals uselb-seal/3|). Witnesses are those of the found act (signerandquorumtags), changed only byrolesentries, which apply from the next block on. A block without the quorum is not the circle's and is ignored. - 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.
- Roots inside the block. The tag
["root", entries, lines, state]:entriesis an RFC 6962 Merkle root over the canonical JSON bytes of each entry inseqorder;linesis an RFC 6962 root over the leaves of the block's account lines (section 4.1);stateis the sparse Merkle root of the whole state after the block. - Time. Booking times
atnever decrease; created_at of a block is theatof 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. - 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)#
- Read the newest seal 43917 from at least two relays and check its
qsquorum (the witnesses of the office after its range). - Read all its snapshot chunks 43918 (
#m=<scope16>:<date>; the seal'ssnaptag names the count). Check that the chunks' RFC 6962 root equalssnap's root and the lines they carry rebuild the seal'sstateroot and sum to 0. - Find the line
s3|<hex>|…of the account: that is its state at the end of the seal's range. - 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
stateroot 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 hashsha256(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 = 100000000is 1,000,000.00. The start of a new circle is100000= 1,000.00, moved fromorigininto thereservein 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 movesgrow = min(amount, cap − wave)from the receiver to the reserve and adds it to the receiver's wave; thensettled = 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.
originis negative by the start (and what it later grants),reserveand 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,closedaccounts exist and still appear in lines. Aleftaccount 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 neitherlast_gift_daynorfirst_receipt_day, elsemax(0, D − the later of the two that are set).in_transition=first_receipt_dayis set andD − first_receipt_day < transition_days.resting=rest_after_days > 0andquiet ≥ rest_after_days.excess=band_ppm_dayif in transition, elseexcess_ppm_day.band=excessif resting, elseband_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#
| Who | What they can prove | What they cannot |
|---|---|---|
| The author of an event | "this key signed this event" (BIP-340 signature) | that the content is true |
| A witness | that it recomputed a block or seal itself and signed the digest | anything 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 height | that the circle is honest about the outside world (there is none: no bank data enters) |
| A relay | nothing: it stores and serves | it can hide or be late; it cannot forge; it can be silent |
| A directory | that it received signed events and checked them; that its collector checked a circle's chain from block 1 | anything 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#
- 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).
- Find the circle.
GET /v1/people/{hex}: noteinCircle,circle.birth,ledgerChecked,profileSource. IfinCircleis false, they may have a profile but no wallet; say so. - Fetch the circle.
GET /v1/circles/{birth}: relays, genesis digest, witnesses, quorum, latest seal. - Read at least two of the circle's relays. Verify the chain (section 3.2), or run
verify-circle-chain.mjs. - 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).
- Interpret with integers. balance, wave, spendable = balance + wave, status, cap. Convert minor units to a currency only for display, with the circle's currency.
- 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").
- Say what you did not verify (for example "signatures were not checked"; "single witness, not decentralised"; "relay 3 did not answer").
- Never invent: no name from nowhere (the ledger has none; a name comes from the profile), no bank, no identity claims, no future dates.
- 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.2has no place here. Use BigInt on strings. - Trusting
verified: true, the API'smemberscount, or the directory'slatestas the truth. - Treating silence as zero or as "no".
- Ignoring the 500 cap. A list of exactly 500 is almost certainly truncated. Page with
untilinclusive and de-duplicate; if a second is full, narrow with tags. - Paging with
sinceor exclusiveuntiland 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
qssignatures. - Applying the wrong witness set.
rolesentries change witnesses from the next block. - Forgetting the
tzdatapin. 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.
| File | What |
|---|---|
| /llms.txt | a short map of the site for language models, with links |
| /llms-full.txt | this guide, the two layers and the kinds in one plain-text file |
| /directories.json | the registry of known directories (the first is Balance directory 1) |
| /kinds.json | all BSA kinds of definitions 0.3.0: layer, replaceability, publishers, tags |
| /openapi.json | OpenAPI 3.1 of the directory REST API with real example answers |
| /sitemap.xml, /robots.txt | for 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#
- Two layers with the comparison table and which kind belongs where
- Kinds reference with tags, validation rules and signed examples
- The directory and how to run your own
- The full texts: ledger and its technical specification, governance, relays, BSA ID
- Glossary