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
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460 | ;; The wallet's ledger: settled receipts, bank declines, pending orders, and a
;; balance that is a FOLD over receipts and nothing else.
;;
;; Storage is one localStorage key, `wal:<idPub>` — namespaced by IDENTITY, not
;; by app. This is the one thing in the suite that deliberately breaks the
;; per-app `ns:` convention the social layer uses: money follows the person, so
;; the game that just took a stake and the hub that shows the balance read the
;; same ledger, and neither owns it.
;;
;; ============================ NO RETENTION CAPS =============================
;; Nothing here caps, evicts, ages out or drop-oldests anything. There is no
;; receipt cap, no ledger cap, no ring buffer — deliberately, and it is a SUITE
;; RULE, not a local preference. social-kit's ReceiptSet keeps 300 leaderboard
;; receipts and forgets the rest, which is correct for a scoreboard and would be
;; catastrophic here: a receipt is the ONLY evidence that money moved, the
;; balance is a fold over all of them, and an evicted receipt is not a forgotten
;; game, it is money that silently ceases to exist. History is unbounded by
;; default. Pruning is an explicit user action and nothing else — `prune` takes
;; the user's own predicate, and no code path in this kit calls it.
;;
;; The failure mode this creates is a full quota, and it is handled by SAYING
;; SO, never by discarding: a write that throws (QuotaExceededError, private
;; mode, a disabled store) leaves the in-memory ledger intact and correct for
;; the session, sets `storageError`, and calls `onStorageError`. A consumer that
;; ignores both gets a session that works and a warning it chose not to read —
;; which is the right trade against losing a receipt.
;; ============================================================================
;;
;; ========================= ONE LEDGER, MANY TABS ============================
;; The key is the IDENTITY, so two tabs of two different apps are two
;; LedgerStores over one blob, and that is the NORMAL case here, not an edge:
;; the hub shows the balance while the game takes the stake. A store that
;; serialized its own memory and setItem'd it would therefore be a
;; last-writer-wins race whose loser is a receipt — the same "money that
;; silently ceases to exist" the retention rule above exists to forbid, arriving
;; by a different door and with `storageError` still null.
;;
;; So every write is a READ-MODIFY-WRITE. `persist!` re-reads what is on disk,
;; re-shapes it, UNIONS it into memory and writes the union. Union-merge is
;; sound here and nowhere else in the suite for one reason: every record is
;; keyed by a digest OF ITS OWN CONTENT — orderId over canon(order minus sig),
;; receiptKey over canon({v,t,bank,oid}) — so two tabs that hold the same record
;; hold the same key, and two records with the same key ARE the same record.
;; There is no field to reconcile and no ordering to agree on.
;;
;; A READ-MODIFY-WRITE IS NOT ATOMIC BY ITSELF, and saying "we merge" is not the
;; same as saying "nothing is lost". Between the getItem and the setItem sits a
;; parse, a re-shape, a merge, a reindex and a stringify, and a sibling that
;; completes its own write inside that window is simply overwritten — the exact
;; loss the merge exists to prevent, with `storageError` still null. An earlier
;; draft of this header claimed the merge closed that window. It narrows it; it
;; does not close it. What closes it is below, and this file now says what it
;; actually guarantees rather than what would be nice.
;;
;; THE SERIALIZATION IS A COMPARE-AND-SWAP, not a mutex. `persist!` loops:
;;
;; 1. read the raw bytes, remember them, parse/absorb/reindex/serialize;
;; 2. re-read the raw bytes IMMEDIATELY BEFORE writing. Different from step
;; 1 means a writer landed in the window, so throw the attempt away and
;; start over from what they wrote — nothing is merged from a stale read;
;; 3. setItem;
;; 4. read back. Not our bytes means someone wrote over us between 3 and 4,
;; so go round again and re-merge, which restores whatever they dropped.
;;
;; Bounded at MAX-CAS-TRIES attempts, after which the last write stands. What
;; this guarantees, exactly: NO WRITER'S RECORDS ARE LOST TO A MERGE TAKEN FROM
;; A STALE READ, and a clobbered write is detected and repaired on the next
;; pass. What it does not guarantee: true mutual exclusion. The residue is the
;; interval between the verifying getItem in step 2 and the setItem in step 3 —
;; two adjacent statements with no yield point between them, so no other agent
;; in this document can run there at all, and only a genuinely concurrent agent
;; (a second tab in a second process) can. That interval is as small as this API
;; permits: localStorage is synchronous and has no CAS of its own.
;;
;; `navigator.locks` was the obvious instrument and is deliberately NOT used.
;; It is async-only, `prune` is synchronous by contract, and a lock that one
;; writer skips protects nothing — the CAS would still have to carry the
;; guarantee, so the lock would be decoration over the mechanism that actually
;; works, on an API not every target has. One mechanism, everywhere, is the
;; smaller claim and the true one.
;;
;; After a merge the in-memory state is exactly what a fresh `load()` would
;; produce, because both go through the same three steps: parse, absorb,
;; reindex. Everything derived — which orders are answered, which (payer, bank,
;; seq) slots are taken, the sequence high-water marks — is rebuilt in
;; `reindex!` from the records rather than mutated in place, so there is one
;; definition of "consistent" and both paths reach it.
;;
;; TWO STORES IN ONE DOCUMENT ARE NOT TWO TABS. The DOM `storage` event never
;; fires on the window that wrote, so the listener below cannot reach a sibling
;; store in the SAME document — and a hub that renders the balance beside a game
;; that takes the stake is exactly that. Without help, `S1.prune()` followed by
;; `S2.addOrder()` puts the pruned record straight back, for ever. So stores
;; register in a per-key in-process REGISTRY and notify their same-document
;; siblings after every successful write; the registry is what `close()`
;; releases, alongside the listener.
;;
;; A notified sibling REFRESHES rather than reloading when it is holding records
;; disk has never seen (a failed write): a reload replaces memory with disk, and
;; memory is the only copy of a receipt whose write failed. Clean stores reload,
;; dirty ones union.
;;
;; The one thing a union cannot express is a DELETION, and there is exactly one:
;; `prune`. That call passes the keys it removed to `persist!` as a drop set, so
;; the disk copies do not come straight back. It is a LOCAL deletion — another
;; tab still holding the record in memory would restore it on its next write —
;; which is what the storage listener and the registry are for: that store
;; converges first, and a converged store no longer holds the record.
;; ============================================================================
;;
;; ===================== A WRITE THAT DID NOT HAPPEN SAYS SO ==================
;; The mutators used to return `true` for "recorded" whether or not a single
;; byte reached disk — under `unreadable` they returned `true` with nothing
;; written at all — so a caller could not tell a REJECTION from a BLOCKED write.
;; They now return a small integer:
;;
;; 0 rejected. Malformed, a duplicate, an order the bank has answered, or a
;; (payer, bank, seq) slot already taken. Nothing changed.
;; 1 recorded AND on disk.
;; 2 recorded in memory, NOT on disk. `storageError` says why. The session is
;; correct and the fold is right; only durability is missing.
;;
;; Chosen over a string union or an object because falsiness has to keep meaning
;; what it meant: `if (await store.addOrder(o))` is still exactly "was it
;; recorded", since 0 is the only falsy member. `=== true` stops matching, which
;; is a LOUD break on a success path rather than a silent one — an object would
;; have made every rejection truthy.
;;
;; `reserveSeq` is stricter still: it REJECTS when the allocation did not reach
;; disk. A sequence slot whose allocation was not persisted is a slot that will
;; be handed out again, and two signed orders on one slot is the precise failure
;; `seq` exists to prevent. There is no honest number to return.
;; ============================================================================
;;
;; ====================== UNREADABLE BYTES ARE NOT EMPTY ======================
;; Storage that will not parse, or carries a version this build does not know,
;; is NOT a fresh ledger. Treating it as one used to be silent twice over: no
;; error was signalled, and the next mutation overwrote the original bytes —
;; including the reserved `stakes` section a newer version would have written.
;; Now the read path matches the write path: `storageError` is set,
;; `onStorageError` fires, and `persist!` REFUSES to write until the consumer
;; has seen the bytes (`store.unreadable`) and called `acknowledgeUnreadable()`.
;; The session still works; only durability is on hold.
;; ============================================================================
;;
;; Two things the API refuses to blur:
;;
;; PENDING IS NOT SETTLED. `balances()` reports {settled, held, available}.
;; `settled` folds receipts. `held` is what outgoing pending orders have
;; committed. `available` is the difference. An order is a signed promise in
;; flight; nothing in it moves a balance.
;;
;; THE STORE DOES NOT VERIFY SIGNATURES — except on import. addOrder,
;; applyReceipt and applyDecline re-check STRUCTURE (they never trust
;; localStorage or a caller's object shape) but assume the artifact already
;; came through verifyOrder / verifySettlement, exactly as social-kit's
;; ReceiptSet.add assumes verifyReceipt. `importJson` is the exception: an
;; exported file is untrusted input, so every signature in it is checked AND
;; the blob has to be this identity's own.
;;
;; The three mutators return PROMISES, and so do `reserveSeq` and `importJson`.
;; Their dedup keys are SHA-256 digests and SubtleCrypto is async; there is no
;; synchronous version of that to have. The readers — balances, receipts,
;; declines, pendingOrders, expiredOrders, nextSeq, exportJson — are all
;; synchronous, because the keys are persisted beside their records and
;; re-indexed on load.
;;
;; Every mutator is TOTAL: `.catch` is attached to the whole `awaits` ladder, as
;; the macro's docstring asks, so a hostile artifact off the network resolves
;; `0` rather than becoming an unhandled rejection in a caller that was promised
;; a number. `reserveSeq` is the deliberate exception and rejects — see below.
;;
;; `balances()` CANNOT BE JSON.stringify'd. The `*Exact` fields are BigInt and
;; `JSON.stringify` throws TypeError on one, by specification and with no option
;; to change it. That is a property of the answer being exact, not an oversight:
;; the whole point of those fields is that they are values no double can hold,
;; 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 the receipts, and the fold is redone on the
;; other side.
;;
;; JS objects only, no ClojureScript collections: same +19 KB cliff social-kit
;; measured, and every record here is handed to app code and a hand-authored
;; .d.ts anyway.
(ns ardegazu.wallet.ledger
(:require [ardegazu.wallet.consts :as consts]
[ardegazu.wallet.shapes :as shapes]
[shadow.cljs.modern :refer (defclass)])
(:require-macros [ardegazu.wallet.macros :refer [awaits obj oget truthy?]]))
(declare store-load! attach-storage-listener! register! refresh!)
;; ---- storage envelope ---------------------------------------------------------
;;
;; {"v":1,
;; "receipts":[{"k":<receiptKey>,"o":<orderId>,"a":<wrc>}, …],
;; "declines":[{"k":<receiptKey>,"o":<orderId>,"a":<wrj>}, …],
;; "pending" :[{"k":<orderId>, "a":<wpo>}, …],
;; "issuance":[{"k":<issuanceKey>, "a":<wri>}, …],
;; "seqs" :{"<bankerIdPub>":<highest sequence slot this wallet has taken>},
;; "stakes" :[]}
;;
;; `issuance` is ADDITIVE and `v` stays 1: a build that predates it reads the
;; rest of the blob exactly as before and carries the section through its own
;; writes as an unknown one — inside the EXTRA-MAX-BYTES courtesy budget below,
;; which is the one cost of not bumping `v` (an old writer sharing the key with
;; a very large issuance history would stop carrying it). Bumping `v` would
;; make the whole blob unreadable to every deployed wallet instead.
;;
;; The keys are stored beside their records rather than recomputed, which is
;; what keeps `load` synchronous — see this file's header. They are re-validated
;; as digests on the way in, and every record is re-shaped, so a corrupted or
;; hand-edited store degrades to FEWER RECORDS.
;;
;; Fewer records is not a smaller version of the truth, and an earlier draft of
;; this comment said "never a wrong balance", which was wrong. The balance is a
;; FOLD; dropping a receipt drops a term, so the total is wrong by that term,
;; and dropping the only receipt in a currency deletes the whole row — `settled`
;; for that currency goes from a number to `undefined`, with `storageError`
;; null. What the re-shape actually buys is that nothing is ever folded that no
;; verify* would have produced: a corrupt store cannot INVENT money or move a
;; balance in the attacker's favour. It can lose some. `exportJson` before
;; editing anything, and treat a row that vanished as evidence, not as zero.
;;
;; The `seqs` map is the exception, and deliberately: a dropped record loses
;; money, but a dropped HIGH-WATER MARK re-offers a spent sequence slot, and a
;; re-offered slot is two signed orders the payer can neither reconcile nor
;; cancel. So an unreadable `seqs` entry is not dropped — it makes the whole
;; blob UNREADABLE, exactly like bad JSON, and writing stops until the consumer
;; has seen the bytes.
;;
;; `seqs` is the sequence high-water mark per bank, raised by every order this
;; wallet records AND by `reserveSeq`. It is what makes an allocation survive a
;; reload, a second tab and a `prune` — a spent slot is never re-offered. It
;; rides the export too: without it a device that imports a pruned ledger offers
;; slot 1 for a slot the exporting device already spent.
;;
;; `stakes` is reserved for the stake-agreement layer that ships next. This
;; package never reads its contents and carries it through every write verbatim,
;; so an older wallet cannot destroy a newer one's data. Any OTHER top-level key
;; is carried through the same way, for the same reason, without needing this
;; version to have heard of it — 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 code accumulated keys and could only ever restore one,
;; which made a junk section unremovable by any means;
;; * the unknown sections together may occupy at most EXTRA-MAX-BYTES. Past
;; that they are not carried, which means our next write drops them. A
;; ledger cannot let an opaque blob it can neither read nor validate eat the
;; quota its receipts need — that is a permanent denial of service on the
;; money layer, and a receipt is worth more than a section this build has
;; never heard of. A future version that needs more room than that ships its
;; own key here and stops being unknown.
(def ^:private KNOWN-SECTIONS #js ["v" "receipts" "declines" "pending" "issuance" "seqs" "stakes"])
(defn- known-section? [k] (>= (.indexOf KNOWN-SECTIONS k) 0))
;; How much of the blob this version may spend carrying sections it cannot read.
;; 64 KiB of JSON is orders of magnitude more than a version marker or a small
;; reserved section needs and a small fraction of a 5 MB origin quota, which is
;; the trade: generous to a future version, bounded against a section that would
;; otherwise crowd out receipts for ever. See the envelope note above.
(def ^:private EXTRA-MAX-BYTES 65536)
;; How many times `persist!` will re-run its read-modify-write when the stored
;; bytes moved under it. Each retry is a fresh merge from what the other writer
;; actually left, so retrying is progress and not a spin: contention has to be
;; genuinely concurrent to happen once, and eight times in a row is not a case
;; worth an unbounded loop in a money path.
(def ^:private MAX-CAS-TRIES 8)
(defn- js-object?
"A non-null, non-array JavaScript object. Local copy of shapes' predicate —
three lines, and importing a private is not a thing."
[v]
(and (some? v)
(identical? (js* "typeof ~{}" v) "object")
(not (js/Array.isArray v))))
(defn- digest? [s] (and (string? s) (.test consts/DIGEST-RE s)))
;; ---- reading one stored blob --------------------------------------------------
(defn- read-settlements!
"Load one settlement section. A record is kept only when its stored key, its
stored order id AND its artifact all survive re-checking — which is what keeps
`_oidOf` total, so a later write can always put `o` back.
`o` IS TAKEN ON TRUST, checked as a digest and not recomputed, and that is a
deliberate trade rather than an oversight. Recomputing it means hashing, which
is async, which would make `load` async — and every reader in this file is
synchronous precisely because the keys are stored beside their records. What a
wrong `o` buys an attacker is: an order id that is not the id of the order
beside it, so a settled order might be re-addable as pending, or a live one
suppressed. What it costs the attacker is WRITE ACCESS TO THIS ORIGIN'S
localStorage — at which point they can write any record they like, including
a self-consistent one, and nothing recomputed here would notice. The check
adds no capability the attacker does not already have."
[raw field want-t out oid-of]
(let [arr (oget raw field)]
(when (js/Array.isArray arr)
(dotimes [i (.-length ^js arr)]
(let [e (aget arr i)
k (when (some? e) (oget e "k"))
o (when (some? e) (oget e "o"))
;; NO CLOCK (see shapes.cljs). These artifacts were admitted by a
;; verify* when they entered the ledger; re-reading them is a
;; question about STRUCTURE, and answering it against the wall
;; clock would make a fold depend on when it was loaded — a device
;; whose clock jumped backwards would silently drop its own
;; history. Clock-free keeps every ts-relative bound.
a (when (some? e) (shapes/settlement-shape (oget e "a") nil))]
(when (and (digest? k) (digest? o) (some? a)
(identical? (oget a "t") want-t))
(.set ^js out k a)
(.set ^js oid-of k o)))))))
(defn- read-pending! [raw out]
(let [arr (oget raw "pending")]
(when (js/Array.isArray arr)
(dotimes [i (.-length ^js arr)]
(let [e (aget arr i)
k (when (some? e) (oget e "k"))
a (when (some? e) (shapes/order-shape (oget e "a") nil))]
(when (and (digest? k) (some? a))
(.set ^js out k a)))))))
(defn- read-issuance!
"Load the issuance receipts — read-pending!'s shape over issuance-shape,
clock-free for the same reason: these were admitted by verify* when they
entered, and re-reading them is a question about structure. An ABSENT section
is fine: every blob written before wri existed has none."
[raw out]
(let [arr (oget raw "issuance")]
(when (js/Array.isArray arr)
(dotimes [i (.-length ^js arr)]
(let [e (aget arr i)
k (when (some? e) (oget e "k"))
a (when (some? e) (shapes/issuance-shape (oget e "a") nil))]
(when (and (digest? k) (some? a))
(.set ^js out k a)))))))
(defn- seq-mark?
"A banker id (or the neutral banker) against a slot number inside the same
exact-integer bound a `wpo.seq` lives in."
[k n]
(and (string? k)
(or (.test consts/PUB-RE k) (identical? k consts/NEUTRAL-BANKER))
(number? n) (js/Number.isInteger n) (>= n 0) (<= n consts/AMT-MAX)))
(defn- read-seqs!
"Load the sequence high-water marks. FALSE means the section is unreadable,
and unlike every other section here that is fatal to the whole blob rather
than a dropped record.
The asymmetry is the point. Dropping a record loses evidence and the user can
see the hole. Dropping a high-water mark RE-OFFERS A SPENT SLOT — `nextSeq`
quietly returns to 1 and the wallet signs a second order on a slot the bank
has already bound, which is the exact double-spend `seq` exists to prevent,
and it does it with `storageError` null. So a mark that will not read is bad
bytes, and bad bytes stop writing until the consumer has seen them.
An ABSENT section is fine: blobs written before `seqs` existed have none."
[raw out]
(let [m (oget raw "seqs")]
(cond
(identical? m js/undefined) true
(not (js-object? m)) false
:else
(let [ks (js/Object.keys m)]
(loop [i 0]
(if (>= i (.-length ^js ks))
true
(let [k (aget ks i)
n (unchecked-get m k)]
(if (seq-mark? k n)
(do (unchecked-set out k n) (recur (inc i)))
false))))))))
(defn- json-size
"How much room a value's JSON takes, in characters, or a number past any
budget when it will not serialize at all. Characters rather than UTF-8 bytes:
this is a budget for a section nobody here can read, not a wire limit."
[v]
(try (let [s (js/JSON.stringify v)]
(if (string? s) (.-length s) js/Number.MAX_SAFE_INTEGER))
(catch :default _ js/Number.MAX_SAFE_INTEGER)))
(defn- read-extra!
"Every top-level key this version does not know, carried verbatim — the
`stakes` promise generalized, so a newer wallet's sections survive an older
one's write without the older one having to have heard of them.
WHAT DISK HAS, AND NO MORE THAN EXTRA-MAX-BYTES OF IT. This reads into a fresh
object that REPLACES the one in memory, so a section deleted on disk is not
restored by our next write; and it stops carrying once the budget is spent, so
an opaque section cannot grow without limit inside a blob whose real contents
are receipts. Both halves are in the envelope note above."
[raw out]
(let [ks (js/Object.keys raw)
budget (js-obj "left" EXTRA-MAX-BYTES)]
(dotimes [i (.-length ^js ks)]
(let [k (aget ks i)]
(when-not (known-section? k)
(let [v (unchecked-get raw k)
;; the key, its quotes, the colon and the comma, plus the value
cost (+ (json-size v) (.-length k) 4)]
(when (<= cost (oget budget "left"))
(unchecked-set budget "left" (- (oget budget "left") cost))
(unchecked-set out k v))))))))
(defn- snapshot-of
"Index one parsed blob, or nil when it cannot be read at all — which today
means only an unreadable `seqs` map; see `read-seqs!`."
[raw]
(let [snap (obj "receipts" (js/Map.) "declines" (js/Map.) "pending" (js/Map.)
"issuance" (js/Map.)
"oidOf" (js/Map.) "seqs" (js-obj) "stakes" nil "extra" (js-obj))]
(when (read-seqs! raw (oget snap "seqs"))
(read-settlements! raw "receipts" "wrc" (oget snap "receipts") (oget snap "oidOf"))
(read-settlements! raw "declines" "wrj" (oget snap "declines") (oget snap "oidOf"))
(read-pending! raw (oget snap "pending"))
(read-issuance! raw (oget snap "issuance"))
(read-extra! raw (oget snap "extra"))
(let [st (oget raw "stakes")]
(when (js/Array.isArray st) (unchecked-set snap "stakes" st)))
snap)))
(defn- read-stored
"Read and index what is on disk RIGHT NOW. `state` is one of:
\"absent\" nothing stored — a genuinely fresh ledger, not an error;
\"ok\" parsed and indexed, `snap` holds it;
\"bad\" bytes exist that this version cannot read (unparseable, not an
object, an unknown `v`, or an unreadable `seqs` map). `raw` holds
them and they are never overwritten — see this file's header;
\"off\" localStorage itself refused to be read."
[self]
(let [box (obj "state" "absent" "raw" nil "snap" nil)]
(try
(let [raw (js/localStorage.getItem (oget self "_key"))]
(when (string? raw)
(unchecked-set box "raw" raw)
(let [parsed (js/JSON.parse raw)
snap (when (and (js-object? parsed) (identical? 1 (oget parsed "v")))
(snapshot-of parsed))]
(if (some? snap)
(do (unchecked-set box "state" "ok")
(unchecked-set box "snap" snap))
(unchecked-set box "state" "bad")))))
(catch :default _
(unchecked-set box "state" (if (string? (oget box "raw")) "bad" "off"))))
box))
;; ---- derived state ------------------------------------------------------------
;;
;; Nothing below is stored: it is rebuilt from the three record maps on every
;; load and every persist, which is what makes a merged store and a freshly
;; loaded one the same store.
(defn- slot-key
"The bank's double-spend slot: (payer, bank, sequence). `seq` is the payer's
own counter for one bank, and the bank's fold binds an order to exactly one of
them — so two signed orders on one slot means at most one can ever settle and
the payer cannot tell which. This wallet refuses to hold both."
[po]
(str (oget po "from") "|" (shapes/banker-of (oget po "cur")) "|" (oget po "seq")))
(defn- mark-slot! [po slots seqs me]
(.add ^js slots (slot-key po))
(when (identical? (oget po "from") me)
(let [b (shapes/banker-of (oget po "cur"))
n (oget po "seq")
have (unchecked-get seqs b)]
(when (and (some? b) (> n (if (number? have) have 0)))
(unchecked-set seqs b n))))
js/undefined)
(defn- mark-settlements! [m oid-of oids slots seqs me]
(let [ks (js/Array.from (.keys ^js m))]
(dotimes [i (.-length ^js ks)]
(let [k (aget ks i)
o (.get ^js oid-of k)]
(when (digest? o) (.add ^js oids o))
(mark-slot! (oget (.get ^js m k) "po") slots seqs me)))))
(defn- reindex!
"Rebuild everything derived, and apply the one rule that is derived rather
than stored: an order the bank has ANSWERED is no longer in flight, whoever
recorded the answer. That is what lets a receipt folded in one tab release the
hold a second tab is still showing.
`_seqs` is RAISED here, never rebuilt: a high-water mark that fell back to the
records would re-offer a slot after a prune, and `nextSeq`'s own docstring
promises it will not."
[self]
(let [oids (js/Set.)
slots (js/Set.)
seqs (oget self "_seqs")
me (oget self "idPub")
oid-of (oget self "_oidOf")
pending (oget self "_pending")]
(mark-settlements! (oget self "_receipts") oid-of oids slots seqs me)
(mark-settlements! (oget self "_declines") oid-of oids slots seqs me)
(let [ks (js/Array.from (.keys ^js pending))]
(dotimes [i (.-length ^js ks)]
(let [k (aget ks i)]
(if (.has ^js oids k)
(.delete ^js pending k)
(mark-slot! (.get ^js pending k) slots seqs me)))))
(unchecked-set self "_oids" oids)
(unchecked-set self "_slots" slots))
js/undefined)
;; ---- merge --------------------------------------------------------------------
(defn- absorb-records! [into from oid-into oid-from prefix drop]
(let [ks (js/Array.from (.keys ^js from))]
(dotimes [i (.-length ^js ks)]
(let [k (aget ks i)]
(when-not (or (.has ^js into k)
(and (some? drop) (.has ^js drop (str prefix k))))
(.set ^js into k (.get ^js from k))
(when (and (some? oid-into) (.has ^js oid-from k))
(.set ^js oid-into k (.get ^js oid-from k))))))))
(defn- absorb!
"Union one on-disk snapshot into memory. Records are keyed by a digest of
their own content, so \"already have this key\" IS \"already have this
record\" and a union can never invent, alter or double-count one.
Sections this version does not understand (`stakes` and anything newer) are
TAKEN FROM DISK, replacing what memory held rather than merging into it. They
are opaque here, so the writer that understands them is the one whose copy
should survive — and \"replace\" is what makes a section deleted on disk stay
deleted instead of being restored by our next write. Under a `drop` set,
memory has just deleted records deliberately; the opaque sections are not
ours to have an opinion about either way."
[self snap drop]
(absorb-records! (oget self "_receipts") (oget snap "receipts")
(oget self "_oidOf") (oget snap "oidOf") "wrc|" drop)
(absorb-records! (oget self "_declines") (oget snap "declines")
(oget self "_oidOf") (oget snap "oidOf") "wrj|" drop)
(absorb-records! (oget self "_pending") (oget snap "pending") nil nil "pending|" drop)
(absorb-records! (oget self "_issuance") (oget snap "issuance") nil nil "wri|" drop)
(let [seqs (oget self "_seqs")
from (oget snap "seqs")
ks (js/Object.keys from)]
(dotimes [i (.-length ^js ks)]
(let [k (aget ks i)
n (unchecked-get from k)
have (unchecked-get seqs k)]
(when (> n (if (number? have) have 0)) (unchecked-set seqs k n)))))
(let [st (oget snap "stakes")]
(unchecked-set self "_stakes" (if (js/Array.isArray st) st #js [])))
(unchecked-set self "_extra" (oget snap "extra"))
js/undefined)
;; ---- the store ----------------------------------------------------------------
(defclass LedgerStore
(constructor [this id-pub]
(unchecked-set this "idPub" id-pub)
(unchecked-set this "_key" (str consts/LEDGER-KEY-PREFIX id-pub))
;; Fires after every mutation (UI re-render).
(unchecked-set this "onChange" (fn [] js/undefined))
;; Fires when a write to localStorage fails, and when what is stored cannot
;; be read — see the header. The in-memory ledger is still correct; what
;; failed is only durability.
(unchecked-set this "onStorageError" (fn [_] js/undefined))
(unchecked-set this "storageError" nil)
;; The stored bytes this build could not read, held so a consumer can save
;; them before deciding to overwrite. null when storage is readable.
(unchecked-set this "unreadable" nil)
(unchecked-set this "_onStorage" nil)
;; memory holds records disk has never seen — see `refresh!`
(unchecked-set this "_dirty" false)
;; a sibling has written and this store has not looked yet — see `sync!`
(unchecked-set this "_stale" false)
;; closed stores do not write; see `store-close`
(unchecked-set this "_closed" false)
(store-load! this)
(attach-storage-listener! this)
(register! this)))
(defn- fail-storage! [self err-name message]
(let [err (obj "name" err-name "message" message "ts" (js/Date.now))]
(unchecked-set self "storageError" err)
(try ((oget self "onStorageError") err) (catch :default _ nil))
false))
(def ^:private UNREADABLE-MSG
(str "stored ledger could not be read; its bytes are in `unreadable` and will "
"not be overwritten until acknowledgeUnreadable()"))
(def ^:private CLOSED-MSG
"this LedgerStore has been closed; it no longer writes to localStorage")
;; ---- the same-document registry -----------------------------------------------
;;
;; The DOM `storage` event fires on every window of the origin EXCEPT the one
;; that wrote, so it cannot carry news between two LedgerStores in one document —
;; and one document holding two is the ordinary case here, since the key is the
;; identity and any view may open its own. Left alone, `S1.prune()` followed by
;; `S2.addOrder()` restores the pruned record from S2's stale memory, and no
;; number of retries fixes it because S2 is never told.
;;
;; So stores over one key find each other in process and notify each other after
;; a successful write. Membership lasts exactly as long as the `storage`
;; listener's does — `close()` releases both, and a store that is never closed
;; is retained by both, which is why `close()` is documented as required when a
;; store outlives its view.
(def ^:private REGISTRY (js/Map.))
(defn- register! [self]
(let [k (oget self "_key")
s (or (.get REGISTRY k) (let [fresh (js/Set.)] (.set REGISTRY k fresh) fresh))]
(.add ^js s self))
js/undefined)
(defn- unregister! [self]
(let [k (oget self "_key")
s (.get REGISTRY k)]
(when (some? s)
(.delete ^js s self)
(when (zero? (.-size ^js s)) (.delete REGISTRY k))))
js/undefined)
(defn- notify-siblings!
"Tell every OTHER store over this key, in this document, that disk moved.
MARKS THEM STALE; it does not converge them. Convergence is a full re-parse
and re-shape of the whole blob, and history here is unbounded, so doing it
eagerly for every registered store on every write is quadratic in the number
of stores a page has ever opened — and a page that opens a store per view and
never closes it opens a lot. `sync!` does the work at the top of whatever
reads next, so a store nobody is looking at costs a boolean. `onChange` still
fires immediately, because a UI that re-renders is exactly the reader that
will do the converging.
One sibling's failure is not this write's failure: each is wrapped."
[self]
(let [s (.get REGISTRY (oget self "_key"))]
(when (some? s)
(let [others (js/Array.from (.values ^js s))]
(dotimes [i (.-length ^js others)]
(let [o (aget others i)]
(when-not (identical? o self)
(unchecked-set o "_stale" true)
(try ((oget o "onChange")) (catch :default _ nil))))))))
js/undefined)
(defn- store-load! [self]
(unchecked-set self "_receipts" (js/Map.))
(unchecked-set self "_declines" (js/Map.))
(unchecked-set self "_pending" (js/Map.))
(unchecked-set self "_issuance" (js/Map.))
(unchecked-set self "_oidOf" (js/Map.))
(unchecked-set self "_oids" (js/Set.))
(unchecked-set self "_slots" (js/Set.))
(unchecked-set self "_seqs" (js-obj))
(unchecked-set self "_stakes" #js [])
(unchecked-set self "_extra" (js-obj))
(unchecked-set self "unreadable" nil)
;; memory is about to be exactly disk, so nothing is held back from it
(unchecked-set self "_dirty" false)
(let [box (read-stored self)
state (oget box "state")]
(if (identical? state "ok")
(do (absorb! self (oget box "snap") nil)
(unchecked-set self "storageError" nil))
(if (identical? state "bad")
(do (unchecked-set self "unreadable" (oget box "raw"))
(fail-storage! self "UnreadableStorageError" UNREADABLE-MSG))
(if (identical? state "off")
(fail-storage! self "StorageUnavailableError" "localStorage could not be read")
(unchecked-set self "storageError" nil)))))
(reindex! self)
js/undefined)
(defn- refresh!
"Converge on what another writer put on disk.
A CLEAN store reloads: memory holds nothing disk does not, so replacing it is
the cheapest way to be exactly right, deletions included.
A DIRTY store — one whose last write failed, so memory holds records disk has
never seen — UNIONS instead. Reloading it would replace the only copy of a
receipt with a blob that never contained one, which is the retention rule
broken from the inside: a record lost to a convergence step rather than to a
cap. It converges anyway on its next successful write."
[self]
(if (truthy? (oget self "_dirty"))
(let [box (read-stored self)
state (oget box "state")]
(when (identical? state "ok")
(absorb! self (oget box "snap") nil)
;; readable bytes now, so whatever we were refusing to overwrite is gone
;; from disk — the block has nothing left to protect
(unchecked-set self "unreadable" nil)
(unchecked-set self "storageError" nil))
(when (identical? state "bad")
(unchecked-set self "unreadable" (oget box "raw")))
(reindex! self))
(store-load! self))
(unchecked-set self "_stale" false)
js/undefined)
(defn- sync!
"Converge, if someone has told us disk moved since we last looked.
At the top of every reader and of `prune`, which is what makes the staleness
flag safe: nothing observes this store's memory without first taking the news
it has been sent. The mutators do not need it — `persist!` reads and merges
disk itself, which is the same convergence by a shorter route."
[self]
(when (truthy? (oget self "_stale")) (refresh! self))
js/undefined)
(defn- attach-storage-listener!
"Converge when ANOTHER TAB writes. Without it a tab only learns about a
sibling's receipts on its own next mutation, which is exactly when it is about
to make a decision that depends on them.
This event never reaches a store in the SAME document as the writer — the DOM
fires it on every window of the origin except that one — which is what the
in-process registry above is for. The two are complements, not alternatives."
[self]
(try
(let [g (js* "globalThis")]
(when (js-fn? (oget g "addEventListener"))
(let [h (fn [e]
(try
;; a null key means the whole store was cleared
(let [k (oget e "key")]
(when (or (nil? k) (identical? k (oget self "_key")))
;; stale, not reloaded — same lazy convergence the
;; registry uses, and for the same reason
(unchecked-set self "_stale" true)
(try ((oget self "onChange")) (catch :default _ nil))))
(catch :default _ nil)))]
(unchecked-set self "_onStorage" h)
(.addEventListener ^js g "storage" h))))
(catch :default _ nil))
js/undefined)
(defn- blob-of [self]
(let [oid-of (oget self "_oidOf")
section (fn [m] (.map (js/Array.from (.entries ^js m))
(fn [e] (obj "k" (aget e 0) "a" (aget e 1)))))
settlements (fn [m] (.map (js/Array.from (.entries ^js m))
(fn [e] (obj "k" (aget e 0)
"o" (.get ^js oid-of (aget e 0))
"a" (aget e 1)))))
b (obj "v" 1
"receipts" (settlements (oget self "_receipts"))
"declines" (settlements (oget self "_declines"))
"pending" (section (oget self "_pending"))
"issuance" (section (oget self "_issuance"))
"seqs" (oget self "_seqs")
"stakes" (oget self "_stakes"))
extra (oget self "_extra")
ks (js/Object.keys extra)]
(dotimes [i (.-length ^js ks)]
(let [k (aget ks i)]
(when-not (known-section? k) (unchecked-set b k (unchecked-get extra k)))))
b))
(defn- dirty! [self]
(unchecked-set self "_dirty" true)
js/undefined)
(defn- raw-bytes
"The stored string right now, or nil. Never throws: a store that cannot be
read cannot be compared either, and the caller treats that as \"unchanged\" so
a broken localStorage produces one failing write instead of MAX-CAS-TRIES of
them."
[self]
(try (let [v (js/localStorage.getItem (oget self "_key"))]
(if (string? v) v nil))
(catch :default _ js/undefined)))
(defn- unchanged?
"Is disk still holding exactly what we merged from? `js/undefined` from
`raw-bytes` means unreadable, which answers yes — see there."
[self seen]
(let [now (raw-bytes self)]
(or (identical? now js/undefined) (identical? now seen))))
(defn- write-bytes! [self payload]
(try
(js/localStorage.setItem (oget self "_key") payload)
(unchecked-set self "storageError" nil)
true
(catch :default e
(let [nm (try (or (oget e "name") "Error") (catch :default _ "Error"))
msg (try (or (oget e "message") (str e)) (catch :default _ ""))]
(fail-storage! self nm msg)))))
(defn- write!
"Serialize memory and store it, unconditionally. Only two callers: the CAS in
`persist!`, which has already decided the bytes are safe to replace, and
`acknowledgeUnreadable`, whose whole purpose is to replace bytes nobody could
merge with."
[self]
(if (truthy? (oget self "_closed"))
(do (dirty! self) (fail-storage! self "StoreClosedError" CLOSED-MSG))
(let [ok (write-bytes! self (js/JSON.stringify (blob-of self)))]
(if ok
(do (unchecked-set self "_dirty" false) true)
(do (dirty! self) false)))))
(defn- persist!
"Read-modify-write, serialized by a COMPARE-AND-SWAP over the stored bytes.
Returns true only when this call's state reached disk.
Re-reads what is on disk, unions it into memory by the content digests every
record is keyed by, reindexes, and writes the union — so two tabs on one
identity converge instead of overwriting each other. `drop` is the set of
records THIS call is deliberately removing (`prune` only), the one case where
the disk copy must not come back.
The merge alone is not enough and this file's header says why: a sibling that
completes its own write between our read and our write is simply overwritten.
So the bytes are re-read immediately before the setItem and compared with what
we merged from — different means start over from what they wrote — and read
back afterwards, because a write that landed on top of ours has to be re-merged
rather than believed. Bounded at MAX-CAS-TRIES; each retry merges from newer
bytes, so a retry is progress.
Never drops a record to make room; a failed write is REPORTED and the session
carries on with its in-memory truth. Bytes this version could not read are
never overwritten at all, and neither is anything after `close()`."
[self drop]
(if (truthy? (oget self "_closed"))
(do (dirty! self) (fail-storage! self "StoreClosedError" CLOSED-MSG))
(loop [attempt 0]
(let [blocked (some? (oget self "unreadable"))
box (if blocked nil (read-stored self))]
(when (some? box)
(let [state (oget box "state")]
(when (identical? state "ok") (absorb! self (oget box "snap") drop))
(when (identical? state "bad") (unchecked-set self "unreadable" (oget box "raw")))))
(reindex! self)
;; persist! reads and merges disk on every attempt, so any sibling news
;; is already absorbed by here
(unchecked-set self "_stale" false)
(if (some? (oget self "unreadable"))
(do (dirty! self) (fail-storage! self "UnreadableStorageError" UNREADABLE-MSG))
(let [seen (when (some? box) (oget box "raw"))
payload (js/JSON.stringify (blob-of self))
last? (>= attempt (dec MAX-CAS-TRIES))]
(if (and (not last?) (not (unchanged? self seen)))
;; a writer landed in the window: this merge came from stale bytes
(recur (inc attempt))
(let [ok (write! self)]
(if (and ok (not last?) (not (unchanged? self payload)))
;; and one landed on top of ours: re-merge rather than accept
(recur (inc attempt))
(do (when ok (notify-siblings! self))
ok))))))))))
(defn- changed! [self]
(try ((oget self "onChange")) (catch :default _ nil))
js/undefined)
;; ---- mutation -----------------------------------------------------------------
;;
;; The three write outcomes, spelled out once. See this file's header for why
;; they are integers and not a string union or a result object: 0 is the only
;; falsy member, so `if (await store.addOrder(o))` still means exactly "was it
;; recorded", while `=== true` stops matching and fails loudly on a success path
;; rather than quietly on a rejection one.
(def ^:private REJECTED 0)
(def ^:private STORED 1)
(def ^:private MEMORY-ONLY 2)
(defn- wrote [ok] (if ok STORED MEMORY-ONLY))
(defn- store-add-order
"Record an order this wallet has signed and delivered. Idempotent by orderId;
refuses an order the bank has already answered, and refuses a second order on
a (payer, bank, seq) slot that is already taken.
0 rejected / 1 recorded and on disk / 2 recorded in memory only."
[self po]
(-> (awaits [_ (js/Promise.resolve nil)]
(let [o (shapes/order-shape po js/undefined)]
(if (nil? o)
REJECTED
(awaits [k (shapes/order-id o)]
(let [pending (oget self "_pending")]
(if (or (.has ^js pending k)
;; an order the bank has ANSWERED, by its own id. Nearly
;; always caught by the slot check on the next line —
;; same order id means same slot — but not when the
;; stored `o` beside a settlement is not the id of the
;; order beside it, which is exactly the trade
;; `read-settlements!` documents taking. Cheap, and the
;; conservative half of it
(.has ^js (oget self "_oids") k)
(.has ^js (oget self "_slots") (slot-key o)))
REJECTED
(do (.set ^js pending k o)
(let [ok (persist! self nil)]
(changed! self)
(wrote ok)))))))))
(.catch (fn [_] REJECTED))))
(defn- store-apply-settlement [self art want-t]
(-> (awaits [_ (js/Promise.resolve nil)]
(let [s (shapes/settlement-shape art js/undefined)]
(if (or (nil? s) (not (identical? (oget s "t") want-t)))
REJECTED
(awaits [k (shapes/receipt-key s)
oid (shapes/order-id (oget s "po"))]
(let [m (oget self (if (identical? want-t "wrc") "_receipts" "_declines"))]
(if (.has ^js m k)
REJECTED ; already folded — idempotent, and folding twice would double a balance
(do (.set ^js m k s)
(.set ^js (oget self "_oidOf") k oid)
;; persist! reindexes, which is what takes the answered
;; order out of flight — here and in every other tab
(let [ok (persist! self nil)]
(changed! self)
(wrote ok)))))))))
(.catch (fn [_] REJECTED))))
(defn- store-apply-issuance
"Fold one issuance receipt — minted money made visible OUTSIDE banca, so the
hub and the games see a balance the bank's own log gave finality to.
ONLY WHEN `to` IS THIS IDENTITY, and a wri to anyone else is REJECTED rather
than stored-but-not-folded. A settlement can touch this wallet from either
side (a debit as payer, a credit as payee), so the store keeps both and the
fold sorts them out; a wri has exactly one beneficiary, so a receipt naming
someone else can never fold here — storing it would be dead weight in a
ledger with no retention caps, and every stored issuance folding is a
simpler invariant than a filter. (`balances` still re-checks `to` — storage
is re-shaped on load, not re-gated, so the fold must not trust it either.)
Issuance CREDITS and never debits: money is created at the bank and lands on
the recipient. There is no negative direction — a burn has no wri at all;
see buildIssuanceReceipt.
Idempotent by issuanceKey, which is blind to `ts`: a receipt re-issued with
a fresh timestamp folds once. Same structural discipline as the other
mutators — verify with verifyIssuanceReceipt FIRST, this checks shape only.
0 rejected / 1 recorded and on disk / 2 recorded in memory only."
[self art]
(-> (awaits [_ (js/Promise.resolve nil)]
(let [w (shapes/issuance-shape art js/undefined)]
(if (or (nil? w)
(not (identical? (oget w "to") (oget self "idPub"))))
REJECTED
(awaits [k (shapes/issuance-key w)]
(let [m (oget self "_issuance")]
(if (.has ^js m k)
REJECTED ; already folded — folding twice would double a credit
(do (.set ^js m k w)
(let [ok (persist! self nil)]
(changed! self)
(wrote ok)))))))))
(.catch (fn [_] REJECTED))))
;; ---- reading ------------------------------------------------------------------
(defn- values-of [m] (js/Array.from (.values ^js m)))
(defn- past-hold?
"Has this order stopped being something a bank could still settle?
NOT `expired`, which is the artifact layer's exact question and answers at
`exp` to the millisecond. This is the LEDGER's question, and it has to allow
the same clock skew everything else here allows: `ts?` accepts a bank's
timestamp ±SKEW-MS and `settlement-shape` accepts a settlement SKEW-MS before
its order, so a bank one millisecond behind this wallet's clock settles an
order this wallet has just released — and for that window `available` claimed
money that was in fact still committed. Releasing at `exp + SKEW-MS` costs a
two-minute wait and nothing else; the paper is never dropped either way.
`expired` itself is unchanged, and `expiredOrders` uses THIS, so an order is
never both held and listed as expired."
[po now]
(shapes/expired po (- now consts/SKEW-MS)))
;; ---- exact arithmetic ---------------------------------------------------------
;;
;; The fold is over BigInt, and this is not fastidiousness. Doubles are exact on
;; integers only to 2^53 and one amount may be 2^50 (consts/AMT-MAX), so EIGHT
;; maximal receipts is the whole headroom — after which `+` stops being
;; addition. It stops in the worst possible way, too: silently, and
;; ORDER-DEPENDENTLY. Eight receipts of 2^50 and two of 1 total 2^53+2, which is
;; a perfectly representable double; folded big-first a double fold returns
;; 2^53, folded small-first it returns 2^53+2, and the wallet's answer to "how
;; much do I have" depends on the sequence its receipts happened to arrive in.
;; Two devices holding identical evidence would disagree.
;;
;; BigInt is exact and associative, so the total is a function of the SET of
;; receipts and nothing else — which is the only thing a receipt-first ledger
;; can afford to promise. The Number fields stay, because every consumer of this
;; is a UI that wants a number; what is new is that they are never the only
;; answer. `*Exact` carries the BigInt and `exact` says whether the three
;; Numbers are the same value or a rounding of it.
(def ^:private ZERO (js/BigInt 0))
(defn- b+ [a b] (js* "(~{} + ~{})" a b))
(defn- b- [a b] (js* "(~{} - ~{})" a b))
(defn- exact-as-number?
"Does this BigInt survive the round trip through a double? Total: a magnitude
past 2^1024 makes Number() infinite and BigInt() throw."
[b]
(try (identical? (js/BigInt (js/Number b)) b) (catch :default _ false)))
(defn- store-balances [self now-ms]
(sync! self)
(let [out (js-obj)
me (oget self "idPub")
now (if (number? now-ms) now-ms (js/Date.now))
touch (fn [cur]
(let [e (unchecked-get out cur)]
(if (identical? e js/undefined)
(let [n (obj "settled" 0 "held" 0 "available" 0
"settledExact" ZERO "heldExact" ZERO "availableExact" ZERO
"exact" true)]
(unchecked-set out cur n)
n)
e)))
recs (values-of (oget self "_receipts"))
ords (values-of (oget self "_pending"))]
;; settled: credits for orders paid TO me, debits for orders paid BY me.
;; A self-payment hits both arms and nets to zero, which is what it is.
(dotimes [i (.-length ^js recs)]
(let [po (oget (aget recs i) "po")
e (touch (oget po "cur"))
amt (js/BigInt (oget po "amt"))]
(when (identical? (oget po "to") me)
(unchecked-set e "settledExact" (b+ (oget e "settledExact") amt)))
(when (identical? (oget po "from") me)
(unchecked-set e "settledExact" (b- (oget e "settledExact") amt)))))
;; issuance: settled CREDITS only, and only to me. applyIssuance already
;; refuses a wri whose `to` is someone else, but storage is re-shaped on
;; load rather than re-gated, so the fold re-checks — a hand-edited blob
;; must not credit this wallet with someone else's mint. Never a debit:
;; issuance creates money, and a burn has no wri at all.
(let [iss (values-of (oget self "_issuance"))]
(dotimes [i (.-length ^js iss)]
(let [w (aget iss i)]
(when (identical? (oget w "to") me)
(let [e (touch (oget w "cur"))]
(unchecked-set e "settledExact"
(b+ (oget e "settledExact") (js/BigInt (oget w "amt")))))))))
;; held: only OUTGOING pending, and only while it can still settle. An
;; incoming order someone showed us is not money we have, and it is not money
;; we owe either — but its currency still gets a row, because "every currency
;; this wallet holds paper in appears" is a rule a UI can render, and
;; "…unless the only paper is incoming" is not.
;;
;; An EXPIRED order is out of the hold. `exp` is inside what the payer signed,
;; so an order past it can never be settled by an honest bank, and holding
;; against it depressed `available` forever with no exit but a user-driven
;; prune. The paper is still there — `expiredOrders` lists it, `prune` clears
;; it — but it stops costing the user money that nobody can take.
(dotimes [i (.-length ^js ords)]
(let [po (aget ords i)
e (touch (oget po "cur"))]
(when (and (identical? (oget po "from") me) (not (past-hold? po now)))
(unchecked-set e "heldExact" (b+ (oget e "heldExact") (js/BigInt (oget po "amt")))))))
(let [ks (js/Object.keys out)]
(dotimes [i (.-length ^js ks)]
(let [e (unchecked-get out (aget ks i))
s (oget e "settledExact")
h (oget e "heldExact")
a (b- s h)]
(unchecked-set e "availableExact" a)
(unchecked-set e "settled" (js/Number s))
(unchecked-set e "held" (js/Number h))
(unchecked-set e "available" (js/Number a))
(unchecked-set e "exact" (and (exact-as-number? s)
(exact-as-number? h)
(exact-as-number? a))))))
out))
(defn- store-expired-orders
"The outgoing pending orders that have stopped being held, so this is how a UI
shows the user what is no longer committed and offers to prune it. Uses
`past-hold?`, the same predicate `held` uses — an order is never in both
lists, and never in neither."
[self now-ms]
(sync! self)
(let [now (if (number? now-ms) now-ms (js/Date.now))
out #js []
ords (values-of (oget self "_pending"))]
(dotimes [i (.-length ^js ords)]
(let [po (aget ords i)]
(when (past-hold? po now) (.push out po))))
out))
(defn- store-next-seq
"The payer's next sequence number for the BANK behind `cur` — derived from
this wallet's own history and nothing else, which is the whole point: the
payer owns the sequence, the bank only checks it.
Per (payer, bank), NOT per currency: a bank folds one sequence per payer
across every code it issues. Monotone over pending, settled AND declined — a
declined slot is not reused, because a bank that declined `seq` 7 for one
reason may still have recorded it, and re-using it invites a fold conflict
that costs more than a skipped integer. Monotone across a `prune` too, and
across a reload: the high-water mark is persisted, not re-derived from the
records, so pruning a receipt cannot re-offer the slot it spent.
ADVISORY. It reads a mark; it does not take one. Two calls in one tick return
the same number and two tabs return the same number, and two signed orders on
one slot is the exact failure `seq` exists to prevent. Use `reserveSeq` to
allocate; use this to display.
ESCROW IS NOT TRACKED HERE, AND A LOCK SHARES THIS SPACE. A `wlk` claims the
same (payer, bank, seq) slot a `wpo` does — one account moving its own money,
one slot per act — but this store holds no locks, so a mark it hands out has
not been checked against any. Nothing in the suite hits this today: every
consumer that locks (banca, and bursa after it) REPLICATES the bank's log and
takes its slot from that fold, which sees locks and payments alike. A wallet
that does not replicate and signs both must reserve from one counter it owns
and not from this one, until this store learns about locks."
[self cur]
(sync! self)
(let [banker (shapes/banker-of cur)]
(if (nil? banker)
1
(let [mark (unchecked-get (oget self "_seqs") banker)]
(inc (if (number? mark) mark 0))))))
(defn- store-reserve-seq
"Take the next sequence slot for the bank behind `cur` and PERSIST the
allocation before handing it back. Resolves the slot, or REJECTS.
This is what `nextSeq` cannot be. The compare-and-swap in `persist!` is the
serialization: the mark is merged up from disk, incremented, and written under
a check that disk did not move in between, so a second tab that reserves next
sees the number this one took. A slot handed out here is never handed out
again — not by a reload, not by a prune, not by a sibling tab — which is what
makes it safe to sign an order against.
IT REJECTS WHEN THE ALLOCATION DID NOT REACH DISK, and this is the whole point
of the call. The earlier version threw `persist!`'s answer away and resolved
the number regardless: under a blocked write (`unreadable`) or a failed one
(quota) it handed out a slot nothing had recorded, so the next store over the
same key handed out the same slot, and two signed orders on one (payer, bank,
seq) is the precise double-spend `seq` exists to prevent. There is no honest
number to resolve there, so it rejects and the caller does not sign.
Also rejects on anything an order cannot be denominated in — an unparseable
id, or the quote-only neutral unit — and on a bank whose sequence space is
spent, where the next slot would be a number no `wpo` may carry."
[self cur]
(awaits [_ (js/Promise.resolve nil)]
(let [banker (shapes/banker-of cur)]
(when-not (shapes/is-payable-currency cur)
(throw (js/Error. "wallet-kit: reserveSeq needs a payable currency id")))
;; merge first, so the mark includes whatever any other tab has taken
(persist! self nil)
(let [seqs (oget self "_seqs")
mark (unchecked-get seqs banker)
n (inc (if (number? mark) mark 0))]
(when (> n consts/AMT-MAX)
(throw (js/Error. (str "wallet-kit: this bank's sequence space is exhausted "
"— no slot left that an order may carry"))))
;; the mark moves in memory FIRST and is not rolled back on failure: a
;; skipped integer costs nothing, and a slot this session has already
;; named must never be offered again even if it never reached disk
(unchecked-set seqs banker n)
(let [ok (persist! self nil)]
(changed! self)
(when-not ok
(throw (js/Error.
(str "wallet-kit: reserveSeq could not persist slot " n " ("
(let [e (oget self "storageError")]
(if (some? e) (oget e "name") "unknown"))
"); it is NOT safe to sign an order against an unrecorded slot"))))
n)))))
;; ---- portability --------------------------------------------------------------
;;
;; The export is the user's own data in the plainest shape that exists: three
;; arrays of the artifacts themselves. The dedup keys are NOT exported — they
;; are derivable, and re-deriving them on import is what lets an import from an
;; untrusted file be checked rather than believed.
;;
;; `seqs` IS exported, and is the one thing here that is not derivable from the
;; records. It has to be: the mark is deliberately higher than the records
;; justify after a `prune`, and an export that left it out sent device B a
;; ledger whose sequence marks had been reset to zero. B would then offer slot 1
;; for a bank whose slot 1 device A had already spent — the same re-offered-slot
;; regression the persisted mark exists to prevent, arriving over the
;; portability path instead. It merges by MAX on import, like every other
;; sighting of a high-water mark in this file, and is re-validated on the way in
;; because an export file is untrusted input however it arrived.
(defn- store-export-json [self]
(sync! self)
(js/JSON.stringify
(obj "v" 1
"idPub" (oget self "idPub")
"receipts" (values-of (oget self "_receipts"))
"declines" (values-of (oget self "_declines"))
"pending" (values-of (oget self "_pending"))
"issuance" (values-of (oget self "_issuance"))
"seqs" (oget self "_seqs")
"stakes" (oget self "_stakes"))))
(defn- import-seqs!
"Raise this wallet's marks to the export's, entry by entry. Returns whether
anything moved. MAX, never last-wins: an import must not be able to lower a
mark, or it would re-offer a slot this device has already spent. Entries that
do not validate are skipped — an import is an ADDITIVE merge into a ledger
that is already correct, so a bad entry here is not the bad-bytes situation
`read-seqs!` faces, where the mark on disk was the only copy."
[self from]
(let [moved (js-obj "b" false)]
(when (js-object? from)
(let [seqs (oget self "_seqs")
ks (js/Object.keys from)]
(dotimes [i (.-length ^js ks)]
(let [k (aget ks i)
n (unchecked-get from k)
have (unchecked-get seqs k)]
(when (and (seq-mark? k n) (> n (if (number? have) have 0)))
(unchecked-set seqs k n)
(unchecked-set moved "b" true))))))
(truthy? (oget moved "b"))))
(defn- party?
"Is this wallet's identity one of the two parties to that order? The second
half of the import gate, and the half that does not depend on the blob being
honest about whose ledger it is."
[self po]
(let [me (oget self "idPub")]
(or (identical? (oget po "from") me) (identical? (oget po "to") me))))
(defn- import-one [self art kind added]
;; `0` is TRUTHY in ClojureScript, so the mutators' rejection code has to be
;; compared, never tested for truthiness
(let [recorded? (fn [r] (not (identical? r REJECTED)))
count! (fn [r]
(when (recorded? r) (unchecked-set added "n" (inc (oget added "n"))))
nil)]
(cond
(identical? kind "pending")
(awaits [o (shapes/verify-order art js/undefined)]
(if (or (nil? o) (not (party? self o)))
nil
(awaits [r (store-add-order self o)] (count! r))))
;; an issuance receipt: the SIGNATURE is verified here like every other
;; record in an export file, and the party hygiene is `to` = this
;; identity — the recipient is the only party whose money a wri moves,
;; and store-apply-issuance enforces the same gate again
(identical? kind "wri")
(awaits [w (shapes/verify-issuance-receipt art js/undefined js/undefined)]
(if (or (nil? w) (not (identical? (oget w "to") (oget self "idPub"))))
nil
(awaits [r (store-apply-issuance self w)] (count! r))))
:else
(awaits [s (shapes/verify-settlement art js/undefined js/undefined)]
(if (or (nil? s)
(not (identical? (oget s "t") kind))
(not (party? self (oget s "po"))))
nil
(awaits [r (store-apply-settlement self s kind)] (count! r)))))))
(defn- import-section [self arr i kind added]
(if (or (not (js/Array.isArray arr)) (>= i (.-length ^js arr)))
(js/Promise.resolve nil)
(-> (import-one self (aget arr i) kind added)
(.catch (fn [_] nil)) ; one bad record must not abort the import
(.then (fn [_] (import-section self arr (inc i) kind added))))))
(defn- store-import-json
"Merge an exported ledger. EVERY signature is verified — an export file is
untrusted input, however it reached this device. Returns how many RECORDS were
new. Never destructive: an import only ever adds, and the sequence marks it
carries can only ever be raised, never lowered (see `import-seqs!`). The
returned count does not include a raised mark; it is a count of records.
IT MUST BE YOUR OWN LEDGER. The blob's `idPub` has to be this store's
identity, and every record has to name that identity as payer or payee. Both,
because they fail differently: without the first, someone else's export merges
wholesale and their currencies, receipts and pending orders show up as yours;
without the second, a blob that merely CLAIMS your idPub carries whatever it
likes. This is the kit's only documented untrusted-input path and the only
place it verifies signatures, and neither of those helps at all against a file
whose artifacts are perfectly valid and simply not about you."
[self raw]
(-> (awaits [_ (js/Promise.resolve nil)]
(let [blob (try (if (string? raw) (js/JSON.parse raw) raw) (catch :default _ nil))
added (js-obj "n" 0)]
(if-not (and (js-object? blob)
(identical? 1 (oget blob "v"))
(identical? (oget blob "idPub") (oget self "idPub")))
0
(awaits [_ (import-section self (oget blob "receipts") 0 "wrc" added)
_ (import-section self (oget blob "declines") 0 "wrj" added)
_ (import-section self (oget blob "pending") 0 "pending" added)
_ (import-section self (oget blob "issuance") 0 "wri" added)]
;; the marks last, and persisted even when no record was new: a
;; blob whose records are all duplicates may still carry a higher
;; mark, and that mark is the whole reason it is exported
(when (import-seqs! self (oget blob "seqs"))
(persist! self nil)
(changed! self))
(oget added "n")))))
(.catch (fn [_] 0))))
;; ---- pruning (user-driven ONLY) -----------------------------------------------
(defn- plan-prune! [m keep kind out]
(let [ks (js/Array.from (.keys ^js m))]
(dotimes [i (.-length ^js ks)]
(let [k (aget ks i)]
;; JS truthiness on purpose: `keep` is the user's own callback and a
;; predicate that answers 0 or "" means "no" to whoever wrote it
(when-not (truthy? (keep (.get ^js m k) kind))
(.push out (obj "m" m "k" k "d" (str kind "|" k))))))))
(defn- store-prune
"Drop records the USER has decided to drop. `keep(artifact, kind)` returns
true to keep; `kind` is \"wrc\", \"wrj\" or \"pending\". Returns how many
records went.
ALL-OR-NOTHING. Every decision is taken before anything is removed, so a
predicate that throws half way through leaves memory and disk exactly as they
were and the error reaches the caller who wrote the predicate. Deleting some
records, then throwing, used to leave memory permanently ahead of disk with
nothing to say so.
LOCAL. There are no tombstones: another store still holding a pruned record in
memory restores it on its next write. The `storage` event makes another TAB
converge first, and the in-process registry does the same for a store in this
same document, which that event can never reach — see this file's header.
THE COUNT IS OF RECORDS REMOVED, NOT OF BYTES WRITTEN. Removal always happens;
whether it reached disk is `storageError`, checked after the call like any
other durability question. Unlike the mutators there is no room in a count to
say both, and the count is the answer the caller asked for.
This is the only deletion path in the kit and nothing calls it on the user's
behalf — see this file's header on retention."
[self keep]
(sync! self)
(let [plan #js []]
(plan-prune! (oget self "_receipts") keep "wrc" plan)
(plan-prune! (oget self "_declines") keep "wrj" plan)
(plan-prune! (oget self "_pending") keep "pending" plan)
(plan-prune! (oget self "_issuance") keep "wri" plan)
(let [drop (js/Set.)]
(dotimes [i (.-length ^js plan)]
(let [p (aget plan i)]
(.delete ^js (oget p "m") (oget p "k"))
(.delete ^js (oget self "_oidOf") (oget p "k"))
(.add ^js drop (oget p "d"))))
(when (pos? (.-length ^js plan))
(persist! self drop)
(changed! self))
(.-length ^js plan))))
;; ---- recovery -----------------------------------------------------------------
(defn- store-acknowledge-unreadable
"\"I have seen `unreadable` and I accept losing it.\" Clears the block and
writes this session's ledger over the bytes. Returns false when there was
nothing to acknowledge.
The one call in this kit that destroys data a user might have wanted, which is
why nothing calls it automatically and why the bytes are handed over first."
[self]
(if (nil? (oget self "unreadable"))
false
(do (unchecked-set self "unreadable" nil)
(unchecked-set self "storageError" nil)
(reindex! self)
(let [ok (write! self)]
(when ok (notify-siblings! self))
(changed! self)
ok))))
(defn- store-close
"Retire this store: detach the `storage` listener, leave the same-document
registry, and STOP WRITING.
The last clause is not tidiness. `close()` used to detach the listener and
nothing else, which produced the worst possible object: a store that no longer
hears about anything on disk and still writes over it — a permanently stale
writer whose every mutation resurrected whatever it was holding when it was
closed. Reading still works, and the in-memory ledger stays correct and
inspectable; mutators report 2 (recorded in memory only) and `storageError`
names StoreClosedError. There is no reopen: construct a new store."
[self]
(try
(let [h (oget self "_onStorage")
g (js* "globalThis")]
(when (and (some? h) (js-fn? (oget g "removeEventListener")))
(.removeEventListener ^js g "storage" h)))
(catch :default _ nil))
(unchecked-set self "_onStorage" nil)
(unregister! self)
(unchecked-set self "_closed" true)
js/undefined)
;; ---- prototype ----------------------------------------------------------------
;;
;; String keys via unchecked-set, never `(defn ^:export …)` or a method literal:
;; this kit builds :advanced, and a renamed method is a green build that dies at
;; the first call from JavaScript.
(let [proto (.-prototype LedgerStore)]
(unchecked-set proto "load"
(fn [] (this-as self (store-load! self) (changed! self) js/undefined)))
(unchecked-set proto "balances"
(fn [now-ms] (this-as self (store-balances self now-ms))))
(unchecked-set proto "addOrder"
(fn [po] (this-as self (store-add-order self po))))
(unchecked-set proto "applyReceipt"
(fn [rc] (this-as self (store-apply-settlement self rc "wrc"))))
(unchecked-set proto "applyDecline"
(fn [rj] (this-as self (store-apply-settlement self rj "wrj"))))
(unchecked-set proto "applyIssuance"
(fn [wi] (this-as self (store-apply-issuance self wi))))
(unchecked-set proto "issuances"
(fn [] (this-as self (sync! self) (values-of (oget self "_issuance")))))
(unchecked-set proto "pendingOrders"
(fn [] (this-as self (sync! self) (values-of (oget self "_pending")))))
(unchecked-set proto "expiredOrders"
(fn [now-ms] (this-as self (store-expired-orders self now-ms))))
(unchecked-set proto "receipts"
(fn [] (this-as self (sync! self) (values-of (oget self "_receipts")))))
(unchecked-set proto "declines"
(fn [] (this-as self (sync! self) (values-of (oget self "_declines")))))
(unchecked-set proto "nextSeq"
(fn [cur] (this-as self (store-next-seq self cur))))
(unchecked-set proto "reserveSeq"
(fn [cur] (this-as self (store-reserve-seq self cur))))
(unchecked-set proto "exportJson"
(fn [] (this-as self (store-export-json self))))
(unchecked-set proto "importJson"
(fn [raw] (this-as self (store-import-json self raw))))
(unchecked-set proto "prune"
(fn [keep] (this-as self (store-prune self keep))))
(unchecked-set proto "acknowledgeUnreadable"
(fn [] (this-as self (store-acknowledge-unreadable self))))
(unchecked-set proto "close"
(fn [] (this-as self (store-close self)))))
|