1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373 | # ardegazu-wallet-kit
The wallet layer of the [ardegazu.ro](https://ardegazu.ro) suite. Any identity
can found a bank and issue its own currency (that is `banca`'s job); any app can
embed this kit to hold balances and pay — **without replicating a bank's log**.
This package is the **artifact core** and nothing else: the four signed shapes,
the ledger fold, the deep link. No transport, no UI, no stakes, no trust graph.
Those ship on top of it, and none of them can change what a signature covers.
## Receipt-first
A wallet holds **settled receipts** and **pending signed orders**, and a balance
is a fold over receipts.
Paying is signing an ORDER and delivering it. The bank settles it later and
issues a RECEIPT. Until that receipt exists nothing has moved — and the API is
shaped so the difference cannot be blurred:
```
balances(nowMs?) -> { "<cur>": { settled, held, available,
settledExact, heldExact, availableExact,
exact } }
```
`settled` folds receipts. `held` is what LIVE outgoing pending orders have
committed. `available = settled - held`. An order is a promise in flight; it
never moves a balance, and an order past its signed `exp` stops being held —
no honest bank can settle it any more, so it must stop costing the user money
nobody can take. The release happens at **`exp + SKEW_MS`**, not at `exp`:
admission tolerates a bank ±`SKEW_MS` in both directions, so a bank a
millisecond behind still settles an order `exp` alone would have released, and
`available` would have overstated for that window. `expiredOrders(nowMs?)` uses
the same margin, so an order is never in both lists and never in neither.
Nothing drops the paper either way.
**`balances()` cannot be `JSON.stringify`d.** The `*Exact` fields are `bigint`
and `JSON.stringify` throws `TypeError` on one, by specification and with no way
round it — those fields exist to hold values no double can, and a serializer
whose number type *is* a double has nothing to write. Convert at the boundary:
`String(b.settledExact)` for anything durable, `b.settled` for a UI that has
already checked `b.exact`. `exportJson()` and the persisted blob are unaffected;
both carry receipts, and the fold is redone on the other side.
**The fold is BigInt.** One amount may be 2^50 and doubles are exact on integers
only to 2^53, so eight maximal receipts is the whole headroom — and past it a
double fold is not merely approximate, it is *order-dependent*: eight receipts of
2^50 plus two of 1 total 2^53+2 folded small-first and 2^53 folded big-first, so
two devices holding identical evidence would disagree. The `*Exact` fields carry
the exact total, the three Numbers are `Number()` of them for the UI, and `exact`
says whether they are the same value or a rounding. Nothing here ever returns a
rounded total dressed as an exact one.
## The five artifacts
Every signature is Ed25519 over `"<domain>|v1|" + canon(<artifact minus its
signature field>)`, where `canon` is social-kit's canonical JSON — sorted keys,
no whitespace, throws on `undefined`. So the **signature** is order-independent,
while the **transmitted bytes** are `JSON.stringify` in insertion order. Both
facts are golden-vectored.
| | what | signed by | domain |
|---|---|---|---|
| `wpr` | payment request | the PAYEE | `wpay-req` |
| `wpo` | payment order | the PAYER | `wpay-ord` |
| `wrc` | settlement receipt | the BANK | `wpay-rcp` |
| `wrj` | decline | the BANK | `wpay-rcp` |
| `wri` | issuance receipt | the BANK | `wpay-iss` |
```json
{"v":1,"t":"wpr","cur":"<bankerPub>.<CODE>","amt":100,"to":"<payeeIdPub>","tox":"<payeeXPub>","ctx":"","memo":"","exp":0,"sig":"…"}
{"v":1,"t":"wpo","id":"<16 random bytes b64url>","cur":"…","amt":100,"seq":7,"from":"<payerIdPub>","to":"<payeeIdPub>","ctx":"","memo":"","ts":0,"exp":0,"sig":"…"}
{"v":1,"t":"wrc","po":{…},"seq":"<bank log entry ref>","ts":0,"bank":"<bankerIdPub>","bsig":"…"}
{"v":1,"t":"wrj","po":{…},"why":"insufficient","ts":0,"bank":"<bankerIdPub>","bsig":"…"}
{"v":1,"t":"wri","cur":"<bankerPub>.<CODE>","seq":7,"to":"<recipientIdPub>","amt":100,"h":"<mint entry log hash>","ts":0,"bank":"<bankerIdPub>","bsig":"…"}
```
- **Amounts are integer minor units.** No floats anywhere, `1 ≤ amt ≤ 2^50` —
a precision ceiling on ONE amount, not a policy one, and not a fold budget:
the fold is BigInt (above), so 2^50 only has to survive the wire and canon().
- **`ctx`** binds an order to an external cause (a game stake, an invoice) and is
carried verbatim into the receipt.
- **`seq`** in `wpo` is a NUMBER: the payer's per-bank sequence, which the bank's
fold uses to bind an order to exactly one slot. `seq` in `wrc` is a STRING: the
bank's own log reference. Same word, different things.
- **`why`** is a closed set: `insufficient | unknown-payer | expired | cur | seq`.
- **`wri` gives a MINT finality.** A bank's fold pins an issuance to a slot
the moment the bank acks it — but a `wrc`'s `bsig` covers a *payment*
preimage, and a mint has no payment inside it, so an acked mint had nothing
signed for the recipient to hold and a banker could withhold a back-dated
second mint on the same issuance slot and displace the first: the one way a
banker could empty a customer's account. The `wri` is the signable preimage
that closes that window. Its `seq` is the **bank's own issuance slot
number** (a third meaning of the word: `wpo.seq` is the payer's number,
`wrc.seq` is the bank's log-ref string); `h` is the mint entry's log hash,
**opaque** to this kit — a non-empty control-free string of at most
`CTX_MAX` code points, compared only for equality, no base pinned; and
`bank` must equal the banker half of `cur`, the same binding a settlement
enforces. Dedup is `issuanceKey = sha256B64url(canon({v,t:"wri",bank,h}))`:
`(bank, h)` *is* the fact being made final, so a receipt re-issued with a
fresh `ts` folds once, and a bank signing two amounts for one `h` has
signed a contradiction the ledger folds first-wins rather than
double-credits.
**Mint only, by decision.** A burn debits the banker's own account and
names no recipient, so displacing one moves only the money of the party
doing the displacing — the self-inflicted case needs no receipt, and the
confiscation window cannot be opened through a burn. A burn-`wri` would be
signed content with no natural meaning (a `to` that is really a `from`);
burns stay unpinnable, and anything that ever pins them gets its own `t`
and domain.
- **Exact key sets.** Every `verify*` checks the key COUNT and rejects anything
else. Without it a stray key rides along in the JSON but not in the rebuilt
preimage, so someone could staple data onto a valid signature.
- **Canonical base64url.** An 86-character encoding of a 64-byte signature has
four padding bits, so sixteen strings decode to one signature. The shape
regexes pin those bits to zero. A second implementation must too.
## Currency ids
`"<bankerIdPub>.<CODE>"`, `CODE` matching `^[A-Z]{3,8}$`. The issuing identity is
the namespace, so two banks may both mint `LEI` and nobody needs a registry.
`"~.GAZ"` is the reserved neutral unit of account. `~` is not a base64url
character, so no identity can hold it and nobody can mint it. It parses (a
pricing layer needs that), but `isPayableCurrency` is false and **every build
and every verify rejects it** — all four artifacts are instruments of payment.
## `LedgerStore`
Persists under `localStorage` key `wal:<idPub>` — namespaced by IDENTITY, not by
app. Money follows the person; the game that took a stake and the hub that shows
the balance read the same ledger.
### One ledger, many tabs
Two apps open at once are two stores over one blob, so that is the ordinary case
and not an edge. Every write is a **read-modify-write**: re-read the blob,
re-shape it, union it into memory, write the union. Union-merge is sound here
because every record is keyed by a digest of its own content, so two copies of a
record are one record and there is nothing to reconcile.
**A read-modify-write is not atomic by itself**, and the merge alone does not
make it so: between the read and the write sit a parse, a merge, a reindex and a
stringify, and a sibling that completes its own write inside that window is
overwritten — a receipt lost with `storageError` still null. What serializes it
is a **compare-and-swap, not a lock**:
1. read the bytes, remember them, merge and serialize;
2. **re-read immediately before writing** — different means a writer landed in
the window, so discard the attempt and start again from what they left;
3. write;
4. **read back** — not our bytes means someone wrote over us, so go round again
and re-merge, which restores what they dropped.
Bounded retries. What that guarantees exactly: *no writer's records are lost to a
merge taken from a stale read*, and a clobbered write is detected and repaired.
What it does not: true mutual exclusion. The residue is the gap between the
verifying read and the write — two adjacent statements with no yield point, so no
agent in this document can run there and only a genuinely concurrent one can.
`navigator.locks` is deliberately **not** used: it is async-only, `prune` is
synchronous by contract, and a lock one writer skips guarantees nothing — the CAS
would still have to carry the guarantee, so the lock would be decoration over the
mechanism that works, on an API not every target has.
A `storage` listener converges this store when **another tab** writes. It can
never fire for a store in the **same document** as the writer — the DOM does not
fire it on the window that wrote — and one document holding two stores is
ordinary here, since the key is the identity. So stores over one key also find
each other in process and notify their siblings directly. A store holding records
disk has never seen (a failed write) *unions* on convergence rather than
reloading, because memory is the only copy of that receipt. `close()` releases
the listener and the registry, and stops the store writing.
Deletion is the one thing a union cannot express, so `prune` passes its removed
keys through as a drop set — and remains a *local* deletion, which is what the
listener and the registry are for.
Nothing a consumer must call, except:
- **`reserveSeq(cur)` instead of `nextSeq(cur)` before signing an order.** `seq`
is the bank's double-spend defence: its fold binds an order to exactly one
slot, so two signed orders on one slot means at most one settles and the payer
cannot tell which. `nextSeq` reads a mark and is advisory — two calls in one
tick return the same number. `reserveSeq` *takes* one, persisted through the
same merge, so a second tab, a reload and a `prune` all see it gone.
`addOrder` refuses a second order on a slot that is already taken.
- `close()` when a store outlives its page view.
- `acknowledgeUnreadable()` if `unreadable` is ever non-null (below).
### Unreadable bytes are not an empty ledger
Storage that will not parse, or carries a `v` this build does not know, sets
`storageError`, fires `onStorageError`, and hands the raw bytes over as
`store.unreadable` — and **nothing is written** until `acknowledgeUnreadable()`.
The session works in memory throughout. Treating unreadable bytes as a fresh
ledger would overwrite whatever wrote them, the reserved `stakes` section
included. An unreadable `seqs` map counts as unreadable bytes for the whole blob,
where a corrupt *record* is merely dropped: a dropped record loses evidence the
user can see is missing, while a dropped sequence high-water mark silently
re-offers a slot the bank has already bound.
Any top-level section this version has never heard of is carried through every
write verbatim, for the same reason — but **forward compatibility is a courtesy
with a budget**, not an immortality guarantee. What is carried is what disk *has*
(a section deleted on disk stays deleted; the old accumulate-only carry made a
junk section unremovable by any means), and the unknown sections together may
occupy at most 64 KiB of JSON. Past that they are not carried, so the next write
drops them: a ledger cannot let an opaque blob it can neither read nor validate
eat the quota its receipts need. A future version needing more room ships its own
key here and stops being unknown.
### No retention caps
Nothing here caps, evicts, ages out or drop-oldests anything. A receipt is the
only evidence that money moved and the balance is a fold over all of them, so an
evicted receipt is not a forgotten game — it is money that silently ceases to
exist. History is unbounded by default; `prune(keep)` takes the user's own
predicate and nothing in this kit calls it.
A write that fails (`QuotaExceededError`, private mode) leaves the in-memory
ledger intact and correct, sets `storageError` and calls `onStorageError`. It
never discards a record to make room.
The three mutators return promises, because their dedup keys are SHA-256 digests
and SubtleCrypto is async. Every reader is synchronous: the keys are persisted
beside their records and re-indexed on load.
`exportJson()` carries `seqs` alongside the records. It has to: after a `prune`
the mark is deliberately higher than the records justify, and an export without
it hands the other device a ledger that offers slot 1 for a slot this one already
spent. Imports merge marks by **max**, never last-wins.
A corrupt or hand-edited store degrades to **fewer records**. Fewer records is
not a smaller version of the truth — the balance is a fold, so a dropped receipt
is a wrong total, and dropping the only receipt in a currency makes that whole
row `undefined`. What the re-shape buys is that nothing is ever folded that no
`verify*` would have produced: a corrupt store cannot invent money. It can lose
some. Export before editing anything, and read a vanished row as evidence.
`applyIssuance(wri)` folds minted money into this ledger, so a mint is visible
outside banca. It credits `wri.to` and never debits anyone; it is rejected
outright unless `to` is this store's identity (a `wri` has exactly one
beneficiary, unlike a settlement, which can touch a wallet from either side);
it is idempotent by `issuanceKey`; and `issuances()`, the export, the import
and `prune` (kind `"wri"`) treat the records exactly as they treat
settlements. The `issuance` storage section is additive — `v` stays 1, a
pre-`wri` build carries it through as an unknown section inside its 64 KiB
courtesy budget.
The store re-checks STRUCTURE on everything, including its own storage, but does
not verify signatures — call `verifyOrder` / `verifySettlement` /
`verifyIssuanceReceipt` first, as with
social-kit's `ReceiptSet.add`. The one exception is `importJson`, which verifies
every signature, because an exported file is untrusted however it arrived.
## Pay links
```
https://banca.ardegazu.ro/#pay=<b64url(JSON.stringify(wpr))>
```
Fragment-only, so the request never reaches a server. Nothing in it is a
capability to spend: `parsePayFragment` verifies before it returns, and deciding
to pay produces a separate signed artifact.
Canonical base64url only, in both directions. `payRequestUrl` rejects a request
that is not structurally valid and encodes the rebuilt one, so a link always
carries wire key order; `parsePayFragment` refuses standard base64 and
percent-encoding even though a lenient decoder would recover the same request.
One request, one link — anything else makes "have I seen this?" a question with
three answers.
## Clocks
Signature verdicts never depend on a clock: no preimage carries a `now`.
ADMISSION does, in two places — `ts` more than `SKEW_MS` in the reader's future
is rejected as *malformed*, and a `wpr`'s `exp` is bounded by the reader's now.
So two peers with skewed clocks can disagree about one signature-valid artifact,
and a wallet showing "rejected" should suspect its own clock before the peer's.
Which is why **every admission check takes an explicit clock**, and may be told
to use none:
```
orderShape(raw) // this reader's wall clock — the default
orderShape(raw, nowMs) // that instant, so a verdict is reproducible
orderShape(raw, null) // NO CLOCK: a deterministic verdict
```
Same trailing parameter on `payRequestShape`, `settlementShape`,
`verifyPayRequest(raw, nowMs?)`, `verifyOrder(raw, nowMs?)` and
`verifySettlement(raw, expectedBankPub?, nowMs?)`.
**A REPLICATED FOLD MUST PASS `null`.** A bank ledger that folds signed orders
out of a log has to reach the same state on every replica; with a wall clock
inside the shape check, two replicas three minutes apart disagree about whether
one entry is a valid order, and their balances diverge. That is a partition bug
in a money ledger, and it is the reason this parameter exists — so no consumer
has to transcribe a clock-free copy of these rules into its own repo, which is
the same rule in two places drifting apart.
The clock-free form is **deterministic, not weaker**. On a `wpo` it drops exactly
one bound — `ts` no further ahead than `SKEW_MS` — and still enforces `exp > ts`,
`exp <= ts + MAX_TTL_MS`, `ts` a non-negative integer, and every structural rule;
on a settlement it additionally keeps `ts >= po.ts - SKEW_MS`. A `wpr` carries no
`ts`, so `null` there drops its only clock-relative bound and keeps `exp` a
positive integer — the one place it is genuinely more permissive.
Anything that is neither a finite number nor `null` — a string, `NaN`,
`Infinity`, an object — reads as *omitted*. An admission check must not have a
most-permissive setting a typo can select.
`expired(a, nowMs)` has **no** clock-free form: liveness *is* the clock question,
so `null` there means the wall clock. `balances(nowMs)` takes an explicit clock
for the same reproducibility reason. `LedgerStore` reads its own storage
clock-free (those artifacts were admitted when they entered; a device whose clock
jumped backwards must not silently drop its own history) and admits new artifacts
against the wall clock. Details in `consts.cljs` and `shapes.cljs`.
## Exports
- `.` — everything: constants, shapes, `LedgerStore`, and the link codec.
- `./paylink` — the link codec alone, for a page that only renders an incoming
request and should not pull `LedgerStore` and a storage fold in with it.
- `./selftest` — `runWalletKitSelfTest()`, dev-only.
Peer dependencies are `ardegazu-id-kit` and `ardegazu-social-kit`, and that is
the whole list. **There is no libp2p in this kit**; it never opens a socket.
Both are ClojureScript libraries and are consumed as SOURCE — their `src/` is on
this kit's classpath (`deps.edn`) and compiles into the build, which is why
`dist/` imports nothing at all. The npm sha pins stay the dependency mechanism;
they now pin the bytes that get compiled rather than a dist that gets linked.
`canon` is deliberately social-kit's, never reimplemented — every signature in
the suite is taken over its output, and a second, DIFFERENT canon would be a
fork of the wire format that no test could see. Compiling social-kit's own
source is not that: it is the one canon, from the one pinned source, in this
build.
## Building
ClojureScript (`src/ardegazu/wallet/*.cljs`), per the suite's CLJS canon in
`dev/docs/CLJS.md`. Consumers see compiled, deterministic ESM in `dist/` plus
hand-authored `types/*.d.ts`.
```
npm run check # compile with warnings promoted to failures, + tsc on the types
npm run build # cold, deterministic release build into dist/
npm test # cljfmt, then the node suites against dist/
```
`deploy/check-dist.sh` asserts the committed `dist/` is exactly what a fresh
build produces and carries no local filesystem paths.
## Golden vectors
`test/vectors/` is the wire contract, and it was derived **without this kit**:
canonical JSON re-implemented from its spec sentence, every preimage also
written out as a literal template a reader can check by eye, Ed25519 from
`@noble/curves`, SHA-256 from `node:crypto` (`test/vectors/independent.mjs`). A
vector generated by the implementation it pins proves nothing.
They cover the five artifacts byte-for-byte, the key order, order-independence
pairs, the currency grammar, every boundary of every validated field, the dedup
key derivations with their exact hash inputs, the pay-link codec, a
hand-computed balance fold, and a tamper table — flipped signature bytes,
mutated fields, swapped `from`/`to`, stapled keys, non-canonical base64 padding,
a receipt replayed under a different bank — every row of which must verify to
`null`.
Add rows. Never regenerate them to make a failing assertion pass.
|