@nano133/nanoterra 0.8.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +390 -6
  3. package/ai/SPEC.md +71 -3
  4. package/dist/admin/panel/mount.d.ts +3 -0
  5. package/dist/admin/panel/mount.js +4 -2
  6. package/dist/player-signin/adapters/d1.d.ts +10 -0
  7. package/dist/player-signin/adapters/d1.js +108 -0
  8. package/dist/player-signin/adapters/firestore.d.ts +45 -0
  9. package/dist/player-signin/adapters/firestore.js +168 -0
  10. package/dist/player-signin/conformance.d.ts +11 -0
  11. package/dist/player-signin/conformance.js +160 -0
  12. package/dist/player-signin/handler.d.ts +64 -0
  13. package/dist/player-signin/handler.js +364 -0
  14. package/dist/player-signin/host.d.ts +4 -0
  15. package/dist/player-signin/host.js +10 -0
  16. package/dist/player-signin/index.d.ts +9 -0
  17. package/dist/player-signin/index.js +15 -0
  18. package/dist/player-signin/memory.d.ts +15 -0
  19. package/dist/player-signin/memory.js +101 -0
  20. package/dist/player-signin/panel/index.d.ts +9 -0
  21. package/dist/player-signin/panel/index.js +8 -0
  22. package/dist/player-signin/panel/memo.d.ts +12 -0
  23. package/dist/player-signin/panel/memo.js +41 -0
  24. package/dist/player-signin/panel/mount.d.ts +21 -0
  25. package/dist/player-signin/panel/mount.js +107 -0
  26. package/dist/player-signin/panel/view.d.ts +48 -0
  27. package/dist/player-signin/panel/view.js +363 -0
  28. package/dist/player-signin/panel/words.d.ts +103 -0
  29. package/dist/player-signin/panel/words.js +111 -0
  30. package/dist/player-signin/scheme-check.d.ts +3 -0
  31. package/dist/player-signin/scheme-check.js +14 -0
  32. package/dist/player-signin/scheme.d.ts +13 -0
  33. package/dist/player-signin/scheme.js +28 -0
  34. package/dist/player-signin/types.d.ts +116 -0
  35. package/dist/player-signin/types.js +33 -0
  36. package/dist/shop/adapters/d1.d.ts +25 -0
  37. package/dist/shop/adapters/d1.js +200 -0
  38. package/dist/shop/adapters/firestore.d.ts +53 -0
  39. package/dist/shop/adapters/firestore.js +245 -0
  40. package/dist/shop/address.d.ts +14 -0
  41. package/dist/shop/address.js +104 -0
  42. package/dist/shop/amount.d.ts +26 -0
  43. package/dist/shop/amount.js +59 -0
  44. package/dist/shop/conformance.d.ts +13 -0
  45. package/dist/shop/conformance.js +203 -0
  46. package/dist/shop/handler.d.ts +76 -0
  47. package/dist/shop/handler.js +156 -0
  48. package/dist/shop/index.d.ts +19 -0
  49. package/dist/shop/index.js +21 -0
  50. package/dist/shop/match.d.ts +23 -0
  51. package/dist/shop/match.js +58 -0
  52. package/dist/shop/memory.d.ts +13 -0
  53. package/dist/shop/memory.js +106 -0
  54. package/dist/shop/nodes.d.ts +47 -0
  55. package/dist/shop/nodes.js +106 -0
  56. package/dist/shop/orders.d.ts +47 -0
  57. package/dist/shop/orders.js +82 -0
  58. package/dist/shop/panel/index.d.ts +9 -0
  59. package/dist/shop/panel/index.js +14 -0
  60. package/dist/shop/panel/looks.d.ts +33 -0
  61. package/dist/shop/panel/looks.js +61 -0
  62. package/dist/shop/panel/mount.d.ts +68 -0
  63. package/dist/shop/panel/mount.js +221 -0
  64. package/dist/shop/panel/pay.d.ts +27 -0
  65. package/dist/shop/panel/pay.js +128 -0
  66. package/dist/shop/panel/qr.d.ts +9 -0
  67. package/dist/shop/panel/qr.js +281 -0
  68. package/dist/shop/panel/words.d.ts +89 -0
  69. package/dist/shop/panel/words.js +79 -0
  70. package/dist/shop/price.d.ts +23 -0
  71. package/dist/shop/price.js +43 -0
  72. package/dist/shop/scan.d.ts +24 -0
  73. package/dist/shop/scan.js +80 -0
  74. package/dist/shop/security.d.ts +10 -0
  75. package/dist/shop/security.js +56 -0
  76. package/dist/shop/store.d.ts +101 -0
  77. package/dist/shop/store.js +4 -0
  78. package/dist/shop/worker.d.ts +43 -0
  79. package/dist/shop/worker.js +76 -0
  80. package/package.json +21 -6
  81. package/src/admin/panel/mount.ts +7 -2
  82. package/src/player-signin/adapters/d1.sql +45 -0
  83. package/src/player-signin/adapters/d1.ts +120 -0
  84. package/src/player-signin/adapters/firestore.ts +194 -0
  85. package/src/player-signin/conformance.ts +184 -0
  86. package/src/player-signin/handler.ts +404 -0
  87. package/src/player-signin/host.ts +12 -0
  88. package/src/player-signin/index.ts +20 -0
  89. package/src/player-signin/memory.ts +94 -0
  90. package/src/player-signin/panel/index.ts +13 -0
  91. package/src/player-signin/panel/memo.ts +49 -0
  92. package/src/player-signin/panel/mount.ts +129 -0
  93. package/src/player-signin/panel/style.css +478 -0
  94. package/src/player-signin/panel/view.ts +433 -0
  95. package/src/player-signin/panel/words.ts +210 -0
  96. package/src/player-signin/scheme-check.ts +14 -0
  97. package/src/player-signin/scheme.ts +32 -0
  98. package/src/player-signin/types.ts +103 -0
  99. package/src/shop/adapters/d1.sql +52 -0
  100. package/src/shop/adapters/d1.ts +245 -0
  101. package/src/shop/adapters/firestore.ts +281 -0
  102. package/src/shop/address.ts +102 -0
  103. package/src/shop/amount.ts +66 -0
  104. package/src/shop/conformance.ts +232 -0
  105. package/src/shop/handler.ts +202 -0
  106. package/src/shop/index.ts +30 -0
  107. package/src/shop/match.ts +63 -0
  108. package/src/shop/memory.ts +102 -0
  109. package/src/shop/nodes.ts +115 -0
  110. package/src/shop/orders.ts +93 -0
  111. package/src/shop/panel/index.ts +19 -0
  112. package/src/shop/panel/looks.ts +106 -0
  113. package/src/shop/panel/mount.ts +280 -0
  114. package/src/shop/panel/pay.ts +192 -0
  115. package/src/shop/panel/qr.ts +256 -0
  116. package/src/shop/panel/style.css +538 -0
  117. package/src/shop/panel/words.ts +172 -0
  118. package/src/shop/price.ts +52 -0
  119. package/src/shop/scan.ts +90 -0
  120. package/src/shop/security.ts +50 -0
  121. package/src/shop/store.ts +99 -0
  122. package/src/shop/worker.ts +107 -0
@@ -0,0 +1,203 @@
1
+ // The conformance suite of the shop's money rules: a game that writes its own store (another database) runs it, and so proves that its store keeps every
2
+ // rule the package's adapters keep. The package runs it on the memory store, D1 (on SQLite) and Firestore.
3
+ //
4
+ // import { shopStoreConformance, SHOP_CASE_TIMEOUT_MS } from "@nano133/nanoterra/shop/conformance";
5
+ // for (const c of shopStoreConformance(async () => ({ store: myStore(), dump: () => allMyRowsAsText() }))) it(c.name, c.run, SHOP_CASE_TIMEOUT_MS);
6
+ //
7
+ // Each case gets a NEW, empty store from `make`, and is short (a few store calls): give each its own test, never one test for the whole suite. The times
8
+ // are fixed (no clock), the players are ids the suite can find in any text, and no case needs the network.
9
+ import { CLOCK_SLACK_MS } from "./match.js";
10
+ import { amountsFor, LATE_HOLD_MS, ORDER_TTL_MS, ShopBusy, newOrderId, reserveOrder } from "./orders.js";
11
+ import { priceRaw, xnoText } from "./amount.js";
12
+ import { FORGOTTEN } from "./store.js";
13
+ /** A time per case that is plenty for a remote store (a case makes at most about 60 store calls; 25 of them at once in one case). */
14
+ export const SHOP_CASE_TIMEOUT_MS = 60_000;
15
+ function check(ok, message) {
16
+ if (!ok)
17
+ throw new Error(message);
18
+ }
19
+ /** A player id the suite can find in any text. */
20
+ const id = (n) => `shop-player-${n}-3c9d`;
21
+ const ANN = id("ann");
22
+ const BOB = id("bob");
23
+ const CYD = id("cyd");
24
+ const T0 = 1_800_000_000_000;
25
+ const PRICE = priceRaw(0.25, 1); // 0.25 XNO
26
+ let hashes = 0;
27
+ /** A new send hash: 64 hex characters, upper case (as the scan gives it). */
28
+ const hash = () => (++hashes).toString(16).toUpperCase().padStart(8, "0").padEnd(64, "A");
29
+ const order = (store, player = ANN, now = T0, price = PRICE, lookId = "look-a") => reserveOrder(store, { player, lookId, usd: "0.25", rate: 1, price, visitor: `visitor-of-${player}`, now });
30
+ const pay = (store, o, seenMs, now = seenMs, h = hash(), payer = o.player) => store.settle(o, { hash: h, payer, seenMs }, now);
31
+ export function shopStoreConformance(make) {
32
+ const cases = [];
33
+ const add = (name, run) => cases.push({ name, run: async () => run(await make()) });
34
+ add("an order asks for its price, and a second open order of the same price one step (0.0001 XNO) more", async ({ store }) => {
35
+ const a = await order(store);
36
+ const b = await order(store, BOB);
37
+ check(xnoText(a.amount) === "0.25" && xnoText(b.amount) === "0.2501", `amounts: ${xnoText(a.amount)}, ${xnoText(b.amount)}`);
38
+ const back = await store.order(a.id);
39
+ check(back !== null && back.amount === a.amount && back.amountLock === a.amount && back.status === "open" && back.player === ANN, `read back: ${JSON.stringify(back)}`);
40
+ check(back.expiresAt === T0 + ORDER_TTL_MS && back.holdUntil === T0 + ORDER_TTL_MS + LATE_HOLD_MS, "the times are kept");
41
+ });
42
+ add("no two held orders ever hold one amount, even when 25 are made at the same instant", async ({ store }) => {
43
+ const made = await Promise.all(Array.from({ length: 25 }, (_, i) => order(store, id(`p${i}`))));
44
+ check(new Set(made.map((o) => o.amount)).size === 25, `amounts: ${made.map((o) => xnoText(o.amount)).join(" ")}`);
45
+ const held = await store.held();
46
+ check(held.length === 25 && new Set(held.map((o) => o.amountLock)).size === 25, `held: ${held.length}`);
47
+ const amounts = amountsFor(PRICE);
48
+ check((await store.heldAmounts(amounts)).size === 25, "heldAmounts names the 25");
49
+ });
50
+ add("insert refuses an amount that another order holds, and writes nothing", async ({ store }) => {
51
+ const a = await order(store);
52
+ const copy = { ...a, id: newOrderId(), player: BOB };
53
+ check((await store.insert(copy)) === false, "a second order with a held amount was written");
54
+ check((await store.order(copy.id)) === null, "the refused order is not there");
55
+ });
56
+ add("every amount of a price held: the next order is refused (busy)", async ({ store }) => {
57
+ const small = priceRaw(0.003, 1); // 30 steps: 3 more allowed, 4 amounts in all
58
+ for (let i = 0; i < 4; i++)
59
+ await order(store, id(`s${i}`), T0, small);
60
+ try {
61
+ await order(store, id("s4"), T0, small);
62
+ }
63
+ catch (e) {
64
+ check(e instanceof ShopBusy, `expected ShopBusy, got ${String(e)}`);
65
+ return;
66
+ }
67
+ throw new Error("a fifth order got an amount");
68
+ });
69
+ add("an expired order keeps its amount until its hold ends; tidy marks it expired, then frees it", async ({ store }) => {
70
+ const a = await order(store);
71
+ const b = await order(store, BOB, a.expiresAt + 1000);
72
+ check(b.amount !== a.amount, "the amount of an order that may still be paid late was given again");
73
+ check((await store.order(a.id))?.status === "expired", "tidy marks an open order past its time expired");
74
+ const c = await order(store, CYD, a.holdUntil + 1);
75
+ check(c.amount === a.amount, "after the hold the amount is free again");
76
+ const was = await store.order(a.id);
77
+ check(was?.status === "expired" && was.amountLock === null, `the old order: ${JSON.stringify(was)}`);
78
+ });
79
+ add("a paid order gives the look to ITS player once, frees its amount at once, and records the payment", async ({ store }) => {
80
+ const a = await order(store);
81
+ const h = hash();
82
+ check((await pay(store, a, T0 + 1000, T0 + 2000, h, BOB)) === "paid", "an exact payment in time pays");
83
+ const after = await store.order(a.id);
84
+ check(after?.status === "paid" && after.hash === h && after.payer === BOB && after.paidAt === T0 + 1000 && after.amountLock === null, `paid order: ${JSON.stringify(after)}`);
85
+ check((await store.owns(ANN, "look-a")) && JSON.stringify(await store.owned(ANN)) === '["look-a"]', "the order's player owns the look");
86
+ check(!(await store.owns(BOB, "look-a")) && (await store.owned(BOB)).length === 0, "the payer is not the owner: one owner per order");
87
+ check((await store.usedHashes([h, hash()])).has(h) && (await store.usedHashes([h])).size === 1, "the payment is recorded");
88
+ const next = await order(store, CYD, T0 + 3000);
89
+ check(next.amount === a.amount, "a paid order's amount is free at once");
90
+ });
91
+ add("a paid order is never paid twice: the same payment or another one changes nothing", async ({ store }) => {
92
+ const a = await order(store);
93
+ const h = hash();
94
+ await pay(store, a, T0 + 1000, T0 + 1000, h);
95
+ check((await pay(store, a, T0 + 1000, T0 + 2000, h)) === "taken", "the same payment again");
96
+ check((await pay(store, a, T0 + 1500, T0 + 2000)) === "taken", "another payment of a paid order");
97
+ const after = await store.order(a.id);
98
+ check(after?.hash === h && after.status === "paid", "the first payment stays");
99
+ check((await store.owned(ANN)).length === 1, "one owned row");
100
+ });
101
+ add("one payment never pays a second order, even one that holds the same amount later", async ({ store }) => {
102
+ const a = await order(store);
103
+ const h = hash();
104
+ await pay(store, a, T0 + 1000, T0 + 1000, h);
105
+ const b = await order(store, BOB, T0 + 2000);
106
+ check(b.amount === a.amount, "the next order holds the freed amount");
107
+ check((await pay(store, b, T0 + 3000, T0 + 3000, h)) === "used", "a payment that paid an order pays no other");
108
+ const after = await store.order(b.id);
109
+ check(after?.status === "open" && after.amountLock === b.amount && after.hash === null, `the second order: ${JSON.stringify(after)}`);
110
+ check(!(await store.owns(BOB, "look-a")), "no look for the second order");
111
+ });
112
+ add("a payment first seen after the order ended (beyond the clock allowance) is paid late: no look, the amount freed", async ({ store }) => {
113
+ const a = await order(store);
114
+ const seen = a.expiresAt + CLOCK_SLACK_MS + 1;
115
+ check((await pay(store, a, seen, seen + 1000)) === "paid_late", "late");
116
+ const after = await store.order(a.id);
117
+ check(after?.status === "paid_late" && after.amountLock === null && after.hash !== null, `late order: ${JSON.stringify(after)}`);
118
+ check((await store.owned(ANN)).length === 0, "a late payment gives no look");
119
+ });
120
+ add("a payment sent in time but recorded after the order expired still pays it", async ({ store }) => {
121
+ const a = await order(store);
122
+ await store.tidy(a.expiresAt + 20 * 60_000);
123
+ check((await store.order(a.id))?.status === "expired", "expired first");
124
+ check((await pay(store, a, a.expiresAt - 30_000, a.expiresAt + 20 * 60_000)) === "paid", "sent in time");
125
+ check(await store.owns(ANN, "look-a"), "the look is given");
126
+ });
127
+ add("an expired order whose hold has ended cannot be paid: it is not held, and settle refuses it", async ({ store }) => {
128
+ const a = await order(store);
129
+ await store.tidy(a.holdUntil + 1);
130
+ check((await store.held()).length === 0, "nothing is held after the hold");
131
+ check((await pay(store, a, a.holdUntil, a.holdUntil + 2)) === "taken", "an order whose amount is no longer held");
132
+ const after = await store.order(a.id);
133
+ check(after?.status === "expired" && after.hash === null, `the order: ${JSON.stringify(after)}`);
134
+ check((await store.owned(ANN)).length === 0, "no look");
135
+ });
136
+ add("the open orders of a player: the same look's newest, and the count", async ({ store }) => {
137
+ const a = await order(store, ANN, T0, PRICE, "look-a");
138
+ await order(store, ANN, T0 + 10, PRICE, "look-b");
139
+ await order(store, BOB, T0 + 20, PRICE, "look-a");
140
+ const same = await store.openOrder(ANN, "look-a", T0 + 60_000);
141
+ check(same?.id === a.id, "the open order for that look");
142
+ check((await store.openOrder(ANN, "look-a", a.expiresAt)) === null, "an order that ends before `until` is not offered again");
143
+ check((await store.openOrder(CYD, "look-a", T0)) === null, "another player's order is not theirs");
144
+ check((await store.countOpen(ANN, T0 + 100)) === 2 && (await store.countOpen(BOB, T0 + 100)) === 1, "the counts");
145
+ check((await store.countOpen(ANN, a.expiresAt + 11)) === 0, "an order past its time is not counted");
146
+ });
147
+ add("amounts are kept as exact text, even with 39 digits", async ({ store }) => {
148
+ const big = "100000000000000000000000000000000000000"; // 39 digits: 10^38 raw (10^8 XNO), a clean amount
149
+ const o = { id: newOrderId(), player: ANN, lookId: "look-a", usd: "1.00", rate: 1, amount: big, amountLock: big, status: "open", createdAt: T0, expiresAt: T0 + ORDER_TTL_MS, holdUntil: T0 + ORDER_TTL_MS + LATE_HOLD_MS, hash: null, payer: null, paidAt: null, visitor: null };
150
+ check(await store.insert(o), "inserted");
151
+ const back = await store.order(o.id);
152
+ check(back?.amount === big && back.amountLock === big, `read back: ${back?.amount}`);
153
+ check((await store.heldAmounts([big])).has(big), "held by its exact text");
154
+ });
155
+ add("the scan's claim: one caller in each gap, also at the same instant", async ({ store }) => {
156
+ const all = await Promise.all(Array.from({ length: 5 }, () => store.claimScan(T0, 4000)));
157
+ check(all.filter(Boolean).length === 1, `claims at once: ${all.join(",")}`);
158
+ check(!(await store.claimScan(T0 + 3999, 4000)), "inside the gap");
159
+ check(await store.claimScan(T0 + 4000, 4000), "after the gap");
160
+ });
161
+ add("the kept price comes back as it was saved", async ({ store }) => {
162
+ check((await store.readRate()) === null, "no price at first");
163
+ await store.saveRate({ usd: 0.8731, sources: ["CoinGecko", "Kraken"], at: T0 });
164
+ const r = await store.readRate();
165
+ check(r?.usd === 0.8731 && r.at === T0 && JSON.stringify(r.sources) === '["CoinGecko","Kraken"]', `rate: ${JSON.stringify(r)}`);
166
+ });
167
+ add("forget waits while an order of the player may still be paid, and deletes nothing", async ({ store }) => {
168
+ const paid = await order(store, ANN, T0, PRICE, "look-a");
169
+ await pay(store, paid, T0 + 1000);
170
+ const waiting = await order(store, ANN, T0 + 2000, PRICE, "look-b");
171
+ const r = await store.forget(ANN, waiting.expiresAt + 1);
172
+ check(r.waitUntil === waiting.holdUntil && r.deleted === 0 && r.kept === 0, `forget: ${JSON.stringify(r)}`);
173
+ check(await store.owns(ANN, "look-a"), "nothing was deleted");
174
+ check((await store.order(paid.id))?.player === ANN, "the paid order still names the player");
175
+ });
176
+ add("forget leaves nothing of the player's except a late payment's payer (the creator sends it back)", async ({ store, dump }) => {
177
+ const paid = await order(store, ANN, T0, PRICE, "look-a");
178
+ await pay(store, paid, T0 + 1000);
179
+ const late = await order(store, ANN, T0 + 10, PRICE, "look-b");
180
+ await pay(store, late, late.expiresAt + CLOCK_SLACK_MS + 10);
181
+ const never = await order(store, ANN, T0 + 20, PRICE, "look-c");
182
+ const gift = await order(store, BOB, T0 + 30, PRICE, "look-d");
183
+ await pay(store, gift, T0 + 1000, T0 + 1000, hash(), ANN);
184
+ const at = never.holdUntil + 1;
185
+ const r = await store.forget(ANN, at);
186
+ check(r.waitUntil === null && r.kept === 2 && r.deleted === 2, `forget: ${JSON.stringify(r)}`);
187
+ check((await store.owned(ANN)).length === 0 && !(await store.owns(ANN, "look-a")), "the owned looks are gone");
188
+ check((await store.order(never.id)) === null, "an order that was never paid is gone");
189
+ const p = await store.order(paid.id);
190
+ check(p?.player === FORGOTTEN && p.payer === FORGOTTEN && p.visitor === null && p.hash !== null && p.status === "paid", `the paid order stays as a sale: ${JSON.stringify(p)}`);
191
+ const l = await store.order(late.id);
192
+ check(l?.player === FORGOTTEN && l.payer === ANN && l.visitor === null && l.status === "paid_late", `the late payment keeps its payer: ${JSON.stringify(l)}`);
193
+ const g = await store.order(gift.id);
194
+ check(g?.player === BOB && g.payer === FORGOTTEN && (await store.owns(BOB, "look-d")), `another player's order that ANN paid stays BOB's: ${JSON.stringify(g)}`);
195
+ const text = await dump();
196
+ const left = text.split(ANN).length - 1;
197
+ check(left === 1, `the player's id is left ${left} times in the store (only the late payer may stay): ${text.slice(0, 400)}`);
198
+ check((await store.usedHashes([p.hash])).size === 1, "the paid order's payment is still recorded (it never counts again)");
199
+ const again = await store.forget(ANN, at + 1);
200
+ check(again.deleted === 0 && again.kept === 0 && again.waitUntil === null, `a second forget: ${JSON.stringify(again)}`);
201
+ });
202
+ return cases;
203
+ }
@@ -0,0 +1,76 @@
1
+ import { type Fetch, type NanoNode, type RpcOptions } from "./nodes.js";
2
+ import { type PriceFeed } from "./price.js";
3
+ import { type ScanResult } from "./scan.js";
4
+ import type { OrderStatus, ShopOrder, ShopStore } from "./store.js";
5
+ /** What the pay screen gets. `address` and `uri` are there only outside test mode. */
6
+ export type PayView = {
7
+ id: string;
8
+ lookId: string;
9
+ usd: string;
10
+ amount: string;
11
+ status: OrderStatus;
12
+ createdAt: number;
13
+ expiresAt: number;
14
+ xno: string;
15
+ test: boolean;
16
+ address?: string;
17
+ uri?: string;
18
+ prices: string[];
19
+ };
20
+ export type ShopHandlerOptions = {
21
+ store: ShopStore;
22
+ /** The game's looks (looks.json as it was read). They are checked with `checkLooks`: a bad file throws here, before any sale. */
23
+ catalogue: unknown;
24
+ /** The creator's Nano address: payments go straight there. Null, the example address or one with a wrong checksum keeps test mode on. */
25
+ payee: string | null | undefined;
26
+ /** True keeps test mode on with a real address (the starter's TEST_MODE). */
27
+ testMode?: boolean;
28
+ /** The game's signed-in player of this request (its own id: the starter's is the Nano address), or null. Never the admin's login. */
29
+ player(request: Request): Promise<string | null>;
30
+ /** Where the routes are. Default "/api/orders". */
31
+ base?: string;
32
+ /** The nodes that check payments (`nodesFor`). Default: the public nodes. */
33
+ nodes?: readonly NanoNode[];
34
+ /** The price feeds and the `fetch` that reads them. Default: CoinGecko, Kraken and KuCoin, the page's fetch. */
35
+ rate?: {
36
+ feeds?: readonly PriceFeed[];
37
+ fetch?: Fetch;
38
+ };
39
+ /** For the scan's node calls (a test gives its own fetch and timeouts). */
40
+ rpc?: RpcOptions;
41
+ /** A limiter for new orders: false means "too many". Default: a fixed window in memory, SHOP_LIMITS.order. */
42
+ limit?: (key: string, limit: number, windowMs: number, now: number) => boolean | Promise<boolean>;
43
+ /** A keyed hash of the visitor's IP address (never the address), kept on the order and used as the limit's key; null or no hook: the player's id. */
44
+ visitor?: (request: Request) => Promise<string | null>;
45
+ /** The person check (Turnstile): true when `token` passes. No hook: no check. The starter's checks `body.turnstile` with Cloudflare. */
46
+ personCheck?: (token: unknown, request: Request) => Promise<boolean>;
47
+ /** The time now in ms. Default Date.now. */
48
+ now?: () => number;
49
+ /** Told about a fault (a store, a node). The player gets a plain 500 and never sees it. */
50
+ onError?: (e: unknown) => void;
51
+ };
52
+ export declare const SHOP_LIMITS: {
53
+ /** New orders of one visitor (the starter's: 10 in 10 minutes). */
54
+ readonly order: {
55
+ readonly limit: 10;
56
+ readonly windowMs: number;
57
+ };
58
+ /** The biggest body of a POST. */
59
+ readonly bodyMax: 8192;
60
+ };
61
+ export type ShopHandler = {
62
+ base: string;
63
+ /** The answer for a request under `base`, or null when it is not one of the shop's routes. */
64
+ handle(request: Request): Promise<Response | null>;
65
+ /** One payment scan now (the cron's and the admin's "Check payments now"). */
66
+ scan(at?: number): Promise<ScanResult>;
67
+ /** The looks a paid order gave this player (the free looks are everyone's and are not listed). */
68
+ owned(player: string): Promise<string[]>;
69
+ /** The pay view of an order (for a game's own route). */
70
+ payView(order: ShopOrder, now?: number): PayView;
71
+ /** The payee the shop uses (checked), or null. */
72
+ readonly payee: string | null;
73
+ /** Whether pay screens are in test mode. */
74
+ readonly testMode: boolean;
75
+ };
76
+ export declare function createShopHandler(options: ShopHandlerOptions): ShopHandler;
@@ -0,0 +1,156 @@
1
+ // The routes of the shop, on the Web `Request` and `Response` types (a Worker, Firebase Functions, Node). The same paths and answers as the game
2
+ // starter's Worker, so its page keeps working:
3
+ //
4
+ // POST {base} { lookId, turnstile? } a new order for a look: 201 and the pay view (or 200 and the same open order again)
5
+ // GET {base}/{id} the pay view of the player's own order
6
+ // POST {base}/{id}/check scans for payments first (at most one scan for the whole game every 4 s), then the pay view
7
+ //
8
+ // `base` is "/api/orders" (the starter's). The pay view is the order ({ id, lookId, usd, amount, status, createdAt, expiresAt }) with `xno` (the amount as
9
+ // XNO text), `test`, `prices` (the feeds' names) and, ONLY outside test mode, `address` and `uri` (the nano: link with the exact raw amount).
10
+ //
11
+ // The player is the game's own signed-in player (`player(request)`): a visitor who is not signed in gets 401, and another player's order looks like no
12
+ // order at all (404). A POST must come from the game's own page. The server decides every price: the browser sends a look's id and nothing else.
13
+ //
14
+ // TEST MODE: with no payee (none, the example address, or a wrong checksum) or `testMode: true`, the answer carries NO address and NO link, and the page
15
+ // shows "TEST MODE: DO NOT PAY". A payment to an address nobody holds the key for is lost for good.
16
+ import { fail, json, memoryLimiter, readLimited, sameOrigin } from "../admin/http.js";
17
+ import { checkLooks } from "../looks/look.js";
18
+ import { payeeOf } from "./address.js";
19
+ import { nanoUri, priceRaw, xnoText } from "./amount.js";
20
+ import { PUBLIC_NODES } from "./nodes.js";
21
+ import { MAX_OPEN_PER_PLAYER, ORDER_ID_PATTERN, ShopBusy, orderView, reserveOrder } from "./orders.js";
22
+ import { PRICE_FEEDS, usdPerXno } from "./price.js";
23
+ import { scanPayments } from "./scan.js";
24
+ export const SHOP_LIMITS = {
25
+ /** New orders of one visitor (the starter's: 10 in 10 minutes). */
26
+ order: { limit: 10, windowMs: 10 * 60_000 },
27
+ /** The biggest body of a POST. */
28
+ bodyMax: 8192,
29
+ };
30
+ const sharedLimit = memoryLimiter();
31
+ export function createShopHandler(options) {
32
+ const base = options.base ?? "/api/orders";
33
+ if (!base.startsWith("/") || base.length < 2 || base.endsWith("/"))
34
+ throw new Error('base must start with "/" and not end with "/", like "/api/orders"');
35
+ const looks = checkLooks(options.catalogue);
36
+ const lookById = (id) => looks.find((l) => l.id === id);
37
+ const payee = payeeOf(options.payee);
38
+ const testMode = payee === null || options.testMode === true;
39
+ const now = options.now ?? (() => Date.now());
40
+ const limit = options.limit ?? sharedLimit;
41
+ const nodes = options.nodes ?? PUBLIC_NODES;
42
+ const feeds = options.rate?.feeds ?? PRICE_FEEDS;
43
+ const fetchRate = options.rate?.fetch ?? ((i, init) => fetch(i, init));
44
+ const store = options.store;
45
+ const report = (e) => {
46
+ try {
47
+ options.onError?.(e);
48
+ }
49
+ catch {
50
+ // nothing more can be done
51
+ }
52
+ };
53
+ const payView = (o, at = now()) => ({
54
+ ...orderView(o, at),
55
+ xno: xnoText(o.amount),
56
+ test: testMode,
57
+ ...(!testMode && payee ? { address: payee, uri: nanoUri(payee, o.amount) } : {}),
58
+ prices: feeds.map((f) => f.name),
59
+ });
60
+ const scan = (at = now()) => scanPayments(store, { address: payee, nodes, at, rpc: options.rpc });
61
+ async function readBody(req) {
62
+ const text = await readLimited(req, SHOP_LIMITS.bodyMax);
63
+ if (text === null)
64
+ return null;
65
+ if (!text)
66
+ return {};
67
+ try {
68
+ const v = JSON.parse(text);
69
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ async function createOrder(req, player, at) {
76
+ const body = await readBody(req);
77
+ if (!body)
78
+ return fail(400, "That request is not readable.");
79
+ const look = lookById(body.lookId);
80
+ if (!look)
81
+ return fail(400, "That look does not exist.");
82
+ if (look.priceUsd === 0)
83
+ return fail(400, "That look is free: it is already yours.");
84
+ const visitor = options.visitor ? await options.visitor(req) : null;
85
+ if (!(await limit(`order|${visitor ?? player}`, SHOP_LIMITS.order.limit, SHOP_LIMITS.order.windowMs, at)))
86
+ return fail(429, "Too many orders. Wait a few minutes.");
87
+ if (options.personCheck && !(await options.personCheck(body.turnstile, req)))
88
+ return fail(403, "The person check failed. Try again.");
89
+ if (await store.owns(player, look.id))
90
+ return fail(409, "You already own this look.");
91
+ // One waiting order per look: the same one comes back, with the same amount.
92
+ const same = await store.openOrder(player, look.id, at + 60_000);
93
+ if (same)
94
+ return json(payView(same, at));
95
+ if ((await store.countOpen(player, at)) >= MAX_OPEN_PER_PLAYER)
96
+ return fail(429, "You have orders waiting. Pay one or wait until it ends.");
97
+ const rate = await usdPerXno(store, fetchRate, at, feeds);
98
+ if (!rate)
99
+ return fail(503, "Prices are not available now. Try again later.");
100
+ const price = priceRaw(look.priceUsd, rate.usd);
101
+ if (!price)
102
+ return fail(503, "Prices are not available now. Try again later.");
103
+ try {
104
+ const order = await reserveOrder(store, { player, lookId: look.id, usd: look.priceUsd.toFixed(2), rate: rate.usd, price, visitor, now: at });
105
+ return json(payView(order, at), 201);
106
+ }
107
+ catch (e) {
108
+ if (e instanceof ShopBusy)
109
+ return fail(503, "Many players are paying this price right now. Try again in a minute.");
110
+ throw e;
111
+ }
112
+ }
113
+ async function handle(req) {
114
+ const path = new URL(req.url).pathname;
115
+ const one = path.startsWith(`${base}/`) ? /^([0-9a-f]{24})(\/check)?$/.exec(path.slice(base.length + 1)) : null;
116
+ const isNew = path === base && req.method === "POST";
117
+ if (!isNew && !one)
118
+ return null;
119
+ try {
120
+ // Every call that changes something must come from the game's own pages.
121
+ if (req.method === "POST" && !sameOrigin(req))
122
+ return fail(403, "This call must come from the game's own page.");
123
+ let player;
124
+ try {
125
+ player = await options.player(req);
126
+ }
127
+ catch (e) {
128
+ report(e);
129
+ return fail(503, "The game's server could not check your sign-in. Try again.");
130
+ }
131
+ if (typeof player !== "string" || !player)
132
+ return fail(401, "Sign in first.");
133
+ const at = now();
134
+ if (isNew)
135
+ return await createOrder(req, player, at);
136
+ const [, id, check] = one;
137
+ if (check && req.method === "POST") {
138
+ const r = await scan(at);
139
+ if (r.error)
140
+ report(new Error(`payment scan: ${r.error}`));
141
+ }
142
+ else if (check || req.method !== "GET")
143
+ return fail(405, "Not allowed.");
144
+ const o = ORDER_ID_PATTERN.test(id) ? await store.order(id) : null;
145
+ // Another player's order looks the same as no order at all.
146
+ if (!o || o.player !== player)
147
+ return fail(404, "No such order.");
148
+ return json(payView(o, at));
149
+ }
150
+ catch (e) {
151
+ report(e);
152
+ return fail(500, "Something went wrong. Try again.");
153
+ }
154
+ }
155
+ return { base, handle, scan, owned: (player) => store.owned(player), payView, payee, testMode };
156
+ }
@@ -0,0 +1,19 @@
1
+ export { EXAMPLE_ADDRESS, blake2bShort, payeeOf, validAddress } from "./address.js";
2
+ export { MAX_RATE, MAX_USD, MIN_RATE, RAW_PER_XNO, STEP_RAW, compareRaw, isClean, isRaw, nanoUri, plusSteps, priceRaw, rawFromSteps, stepsFromUsd, xnoText } from "./amount.js";
3
+ export { CLOCK_SLACK_MS, candidates, checkSend } from "./match.js";
4
+ export type { Verdict } from "./match.js";
5
+ export { KEYED_NODE, NodeDown, PUBLIC_NODES, forgetNodeHealth, nodesFor, rpc, settingValue } from "./nodes.js";
6
+ export type { Fetch, NanoNode, RpcOptions } from "./nodes.js";
7
+ export { LATE_HOLD_MS, MAX_EXTRA_PERCENT, MAX_OPEN_PER_PLAYER, MAX_STEPS, ORDER_ID_PATTERN, ORDER_TTL_MS, ShopBusy, amountsFor, newOrderId, orderView, paidStatus, reserveOrder, smallestAmount, statusAt, stepsFor } from "./orders.js";
8
+ export type { ReserveInput } from "./orders.js";
9
+ export { PRICE_FEEDS, RATE_FRESH_MS, RATE_LAST_GOOD_MS, readFeeds, usdPerXno } from "./price.js";
10
+ export type { PriceFeed, Rate } from "./price.js";
11
+ export { SCAN_GAP_MS, forgetBlocks, scanPayments } from "./scan.js";
12
+ export type { ScanOptions, ScanResult } from "./scan.js";
13
+ export { FORGOTTEN } from "./store.js";
14
+ export type { ForgetResult, KeptRate, OrderStatus, Payment, Settled, ShopOrder, ShopStore } from "./store.js";
15
+ export { createMemoryShop } from "./memory.js";
16
+ export type { MemoryShop } from "./memory.js";
17
+ export { SHOP_LIMITS, createShopHandler } from "./handler.js";
18
+ export type { PayView, ShopHandler, ShopHandlerOptions } from "./handler.js";
19
+ export { SHOP_CSP, TURNSTILE_ORIGIN, mergeCsp } from "./security.js";
@@ -0,0 +1,21 @@
1
+ // "@nano133/nanoterra/shop": the shop of a game that sells looks for Nano (XNO), as the game starter sells them. No database, no cookie, no key: the money
2
+ // goes straight from the player's wallet to the creator's address, and the server only READS the ledger to see that a payment came (a unique, clean
3
+ // amount for each order, matched exactly). The game gives its store, its looks, its address and its signed-in player.
4
+ //
5
+ // createShopHandler the routes (POST an order, GET it, POST its check), on Request and Response
6
+ // scanPayments one look at the creator's address that settles every held order (the cron's and the admin's check)
7
+ // createMemoryShop a store in memory, for tests
8
+ // the rules amounts (raw text and BigInt), the reservation, the match, the price, the nodes, the address check, the CSP lists
9
+ // (the adapters are "/shop/d1" and "/shop/firestore", a Worker set up from the starter's settings "/shop/worker", the conformance suite
10
+ // "/shop/conformance", the browser panel "/shop/panel")
11
+ export { EXAMPLE_ADDRESS, blake2bShort, payeeOf, validAddress } from "./address.js";
12
+ export { MAX_RATE, MAX_USD, MIN_RATE, RAW_PER_XNO, STEP_RAW, compareRaw, isClean, isRaw, nanoUri, plusSteps, priceRaw, rawFromSteps, stepsFromUsd, xnoText } from "./amount.js";
13
+ export { CLOCK_SLACK_MS, candidates, checkSend } from "./match.js";
14
+ export { KEYED_NODE, NodeDown, PUBLIC_NODES, forgetNodeHealth, nodesFor, rpc, settingValue } from "./nodes.js";
15
+ export { LATE_HOLD_MS, MAX_EXTRA_PERCENT, MAX_OPEN_PER_PLAYER, MAX_STEPS, ORDER_ID_PATTERN, ORDER_TTL_MS, ShopBusy, amountsFor, newOrderId, orderView, paidStatus, reserveOrder, smallestAmount, statusAt, stepsFor } from "./orders.js";
16
+ export { PRICE_FEEDS, RATE_FRESH_MS, RATE_LAST_GOOD_MS, readFeeds, usdPerXno } from "./price.js";
17
+ export { SCAN_GAP_MS, forgetBlocks, scanPayments } from "./scan.js";
18
+ export { FORGOTTEN } from "./store.js";
19
+ export { createMemoryShop } from "./memory.js";
20
+ export { SHOP_LIMITS, createShopHandler } from "./handler.js";
21
+ export { SHOP_CSP, TURNSTILE_ORIGIN, mergeCsp } from "./security.js";
@@ -0,0 +1,23 @@
1
+ /** Allowed difference between the Worker's clock and the node's clock. */
2
+ export declare const CLOCK_SLACK_MS = 120000;
3
+ /**
4
+ * The send hashes in a scan that carry one of `amounts` exactly, with their amount.
5
+ * `receivable`: the node's answer to `receivable` (source: true, or not). `history`: to `account_history` (raw).
6
+ */
7
+ export declare function candidates(receivable: unknown, history: unknown, amounts: Set<string>): Map<string, string>;
8
+ export type Verdict = {
9
+ kind: "paid";
10
+ payer: string;
11
+ seenMs: number;
12
+ } | {
13
+ kind: "pending";
14
+ } | {
15
+ kind: "early";
16
+ } | {
17
+ kind: "wrong";
18
+ };
19
+ /**
20
+ * Whether a send block (the node's `block_info` answer, json_block: true) pays `amount` to `address`, and
21
+ * was first seen at or after `notBeforeMs` (the order's start, less the clock allowance).
22
+ */
23
+ export declare function checkSend(info: unknown, address: string, amount: string, notBeforeMs: number): Verdict;
@@ -0,0 +1,58 @@
1
+ // The rules that decide whether a payment arrived (the game starter's src/worker/match.ts, moved here unchanged). No network and no database here, so
2
+ // they are easy to test alone. DO NOT LOOSEN THEM: they are what stops a look from being given away.
3
+ //
4
+ // A payment to the creator's address is in one of two places: still receivable (no wallet took it yet), or
5
+ // received (the creator's wallet took it, and it shows in the address's history). Both are searched for the
6
+ // exact amount of a held order. A hit is only a candidate: the send block itself is then read, and it counts
7
+ // only when it is a confirmed send of exactly that amount to exactly that address, first seen by the node
8
+ // after the order was made.
9
+ /** Allowed difference between the Worker's clock and the node's clock. */
10
+ export const CLOCK_SLACK_MS = 120_000;
11
+ const HASH = /^[0-9A-F]{64}$/i;
12
+ const nanoPrefix = (a) => (typeof a === "string" ? a.replace(/^xrb_/, "nano_") : "");
13
+ /**
14
+ * The send hashes in a scan that carry one of `amounts` exactly, with their amount.
15
+ * `receivable`: the node's answer to `receivable` (source: true, or not). `history`: to `account_history` (raw).
16
+ */
17
+ export function candidates(receivable, history, amounts) {
18
+ const found = new Map();
19
+ const blocks = receivable?.blocks;
20
+ if (blocks && typeof blocks === "object" && !Array.isArray(blocks)) {
21
+ for (const [hash, entry] of Object.entries(blocks)) {
22
+ const amount = typeof entry === "string" ? entry : entry?.amount;
23
+ if (HASH.test(hash) && typeof amount === "string" && amounts.has(amount))
24
+ found.set(hash.toUpperCase(), amount);
25
+ }
26
+ }
27
+ const list = history?.history;
28
+ if (Array.isArray(list)) {
29
+ for (const h of list) {
30
+ const kind = h?.subtype ?? h?.type;
31
+ if (kind !== "receive" && kind !== "open")
32
+ continue;
33
+ // In a raw history, a receive's `link` is the hash of the send it received.
34
+ if (typeof h.amount === "string" && typeof h.link === "string" && HASH.test(h.link) && amounts.has(h.amount))
35
+ found.set(h.link.toUpperCase(), h.amount);
36
+ }
37
+ }
38
+ return found;
39
+ }
40
+ /**
41
+ * Whether a send block (the node's `block_info` answer, json_block: true) pays `amount` to `address`, and
42
+ * was first seen at or after `notBeforeMs` (the order's start, less the clock allowance).
43
+ */
44
+ export function checkSend(info, address, amount, notBeforeMs) {
45
+ const i = (info ?? {});
46
+ const contents = (i.contents ?? {});
47
+ const subtype = i.subtype ?? contents.subtype;
48
+ if (subtype !== "send" || nanoPrefix(contents.link_as_account) !== address || i.amount !== amount)
49
+ return { kind: "wrong" };
50
+ // The node's own time for the send, in seconds. A block with no time cannot be shown to be newer than
51
+ // the order, so it never counts.
52
+ const seenMs = Number(i.local_timestamp) * 1000;
53
+ if (!Number.isFinite(seenMs) || seenMs <= 0 || seenMs < notBeforeMs - CLOCK_SLACK_MS)
54
+ return { kind: "early" };
55
+ if (i.confirmed !== "true" && i.confirmed !== true)
56
+ return { kind: "pending" };
57
+ return { kind: "paid", payer: nanoPrefix(i.block_account), seenMs };
58
+ }
@@ -0,0 +1,13 @@
1
+ import { type ShopOrder, type ShopStore } from "./store.js";
2
+ export type MemoryShop = ShopStore & {
3
+ /** The rows as an adapter would keep them. */
4
+ readonly orders: Map<string, ShopOrder>;
5
+ /** "player\u0000lookId" -> the owned row. */
6
+ readonly ownedRows: Map<string, {
7
+ player: string;
8
+ lookId: string;
9
+ orderId: string;
10
+ grantedAt: number;
11
+ }>;
12
+ };
13
+ export declare function createMemoryShop(): MemoryShop;