@nano133/nanoterra 0.8.0 → 0.9.0

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 (132) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/LICENSE +3 -0
  3. package/README.md +363 -25
  4. package/ai/SPEC.md +65 -3
  5. package/dist/admin/panel/dom.d.ts +2 -0
  6. package/dist/admin/panel/dom.js +6 -0
  7. package/dist/admin/panel/mount.d.ts +3 -0
  8. package/dist/admin/panel/mount.js +4 -2
  9. package/dist/emotes/panel/mount.d.ts +5 -0
  10. package/dist/emotes/panel/mount.js +7 -3
  11. package/dist/friends/panel/mount.d.ts +5 -0
  12. package/dist/friends/panel/mount.js +5 -2
  13. package/dist/options/panel/mount.d.ts +5 -0
  14. package/dist/options/panel/mount.js +5 -3
  15. package/dist/player-signin/adapters/d1.d.ts +10 -0
  16. package/dist/player-signin/adapters/d1.js +108 -0
  17. package/dist/player-signin/adapters/firestore.d.ts +45 -0
  18. package/dist/player-signin/adapters/firestore.js +168 -0
  19. package/dist/player-signin/conformance.d.ts +11 -0
  20. package/dist/player-signin/conformance.js +160 -0
  21. package/dist/player-signin/handler.d.ts +64 -0
  22. package/dist/player-signin/handler.js +364 -0
  23. package/dist/player-signin/host.d.ts +4 -0
  24. package/dist/player-signin/host.js +10 -0
  25. package/dist/player-signin/index.d.ts +9 -0
  26. package/dist/player-signin/index.js +15 -0
  27. package/dist/player-signin/memory.d.ts +15 -0
  28. package/dist/player-signin/memory.js +101 -0
  29. package/dist/player-signin/panel/index.d.ts +9 -0
  30. package/dist/player-signin/panel/index.js +7 -0
  31. package/dist/player-signin/panel/memo.d.ts +12 -0
  32. package/dist/player-signin/panel/memo.js +41 -0
  33. package/dist/player-signin/panel/mount.d.ts +21 -0
  34. package/dist/player-signin/panel/mount.js +105 -0
  35. package/dist/player-signin/panel/view.d.ts +45 -0
  36. package/dist/player-signin/panel/view.js +360 -0
  37. package/dist/player-signin/panel/words.d.ts +8 -0
  38. package/dist/player-signin/panel/words.js +26 -0
  39. package/dist/player-signin/scheme-check.d.ts +3 -0
  40. package/dist/player-signin/scheme-check.js +14 -0
  41. package/dist/player-signin/scheme.d.ts +13 -0
  42. package/dist/player-signin/scheme.js +28 -0
  43. package/dist/player-signin/types.d.ts +116 -0
  44. package/dist/player-signin/types.js +33 -0
  45. package/dist/shop/adapters/d1.d.ts +25 -0
  46. package/dist/shop/adapters/d1.js +200 -0
  47. package/dist/shop/adapters/firestore.d.ts +53 -0
  48. package/dist/shop/adapters/firestore.js +245 -0
  49. package/dist/shop/address.d.ts +14 -0
  50. package/dist/shop/address.js +104 -0
  51. package/dist/shop/amount.d.ts +26 -0
  52. package/dist/shop/amount.js +59 -0
  53. package/dist/shop/conformance.d.ts +13 -0
  54. package/dist/shop/conformance.js +203 -0
  55. package/dist/shop/handler.d.ts +76 -0
  56. package/dist/shop/handler.js +156 -0
  57. package/dist/shop/index.d.ts +19 -0
  58. package/dist/shop/index.js +21 -0
  59. package/dist/shop/match.d.ts +23 -0
  60. package/dist/shop/match.js +58 -0
  61. package/dist/shop/memory.d.ts +13 -0
  62. package/dist/shop/memory.js +106 -0
  63. package/dist/shop/nodes.d.ts +47 -0
  64. package/dist/shop/nodes.js +106 -0
  65. package/dist/shop/orders.d.ts +47 -0
  66. package/dist/shop/orders.js +82 -0
  67. package/dist/shop/panel/index.d.ts +7 -0
  68. package/dist/shop/panel/index.js +11 -0
  69. package/dist/shop/panel/looks.d.ts +30 -0
  70. package/dist/shop/panel/looks.js +61 -0
  71. package/dist/shop/panel/mount.d.ts +52 -0
  72. package/dist/shop/panel/mount.js +194 -0
  73. package/dist/shop/panel/pay.d.ts +24 -0
  74. package/dist/shop/panel/pay.js +115 -0
  75. package/dist/shop/panel/qr.d.ts +9 -0
  76. package/dist/shop/panel/qr.js +281 -0
  77. package/dist/shop/price.d.ts +23 -0
  78. package/dist/shop/price.js +43 -0
  79. package/dist/shop/scan.d.ts +24 -0
  80. package/dist/shop/scan.js +80 -0
  81. package/dist/shop/security.d.ts +10 -0
  82. package/dist/shop/security.js +56 -0
  83. package/dist/shop/store.d.ts +101 -0
  84. package/dist/shop/store.js +4 -0
  85. package/dist/shop/worker.d.ts +43 -0
  86. package/dist/shop/worker.js +76 -0
  87. package/package.json +21 -6
  88. package/src/admin/panel/dom.ts +6 -0
  89. package/src/admin/panel/mount.ts +7 -2
  90. package/src/emotes/panel/mount.ts +9 -2
  91. package/src/friends/panel/mount.ts +8 -2
  92. package/src/options/panel/mount.ts +8 -3
  93. package/src/player-signin/adapters/d1.sql +45 -0
  94. package/src/player-signin/adapters/d1.ts +120 -0
  95. package/src/player-signin/adapters/firestore.ts +194 -0
  96. package/src/player-signin/conformance.ts +184 -0
  97. package/src/player-signin/handler.ts +404 -0
  98. package/src/player-signin/host.ts +12 -0
  99. package/src/player-signin/index.ts +20 -0
  100. package/src/player-signin/memory.ts +94 -0
  101. package/src/player-signin/panel/index.ts +12 -0
  102. package/src/player-signin/panel/memo.ts +49 -0
  103. package/src/player-signin/panel/mount.ts +127 -0
  104. package/src/player-signin/panel/style.css +473 -0
  105. package/src/player-signin/panel/view.ts +428 -0
  106. package/src/player-signin/panel/words.ts +32 -0
  107. package/src/player-signin/scheme-check.ts +14 -0
  108. package/src/player-signin/scheme.ts +32 -0
  109. package/src/player-signin/types.ts +103 -0
  110. package/src/shop/adapters/d1.sql +52 -0
  111. package/src/shop/adapters/d1.ts +245 -0
  112. package/src/shop/adapters/firestore.ts +281 -0
  113. package/src/shop/address.ts +102 -0
  114. package/src/shop/amount.ts +66 -0
  115. package/src/shop/conformance.ts +232 -0
  116. package/src/shop/handler.ts +202 -0
  117. package/src/shop/index.ts +30 -0
  118. package/src/shop/match.ts +63 -0
  119. package/src/shop/memory.ts +102 -0
  120. package/src/shop/nodes.ts +115 -0
  121. package/src/shop/orders.ts +93 -0
  122. package/src/shop/panel/index.ts +15 -0
  123. package/src/shop/panel/looks.ts +106 -0
  124. package/src/shop/panel/mount.ts +241 -0
  125. package/src/shop/panel/pay.ts +171 -0
  126. package/src/shop/panel/qr.ts +256 -0
  127. package/src/shop/panel/style.css +534 -0
  128. package/src/shop/price.ts +52 -0
  129. package/src/shop/scan.ts +90 -0
  130. package/src/shop/security.ts +50 -0
  131. package/src/shop/store.ts +99 -0
  132. package/src/shop/worker.ts +107 -0
@@ -0,0 +1,59 @@
1
+ // Nano amounts for the shop, in raw. 1 XNO is 10^30 raw, more than a JavaScript number holds exactly, so amounts are decimal strings and the math is
2
+ // BigInt. Never use a float for an amount that is paid or compared. (The game starter's src/shared/amount.ts, moved here unchanged.)
3
+ //
4
+ // Every order asks for a CLEAN amount: the price, rounded up to a step of 0.0001 XNO. While another held order has that exact amount, the next order
5
+ // asks for one step more (./orders.ts). So an amount has at most four decimals. Wallets that shorten an amount (Cake Wallet cuts a link's amount to
6
+ // 6 decimals; Natrium and Nautilus send 6 decimals when the payer touches the amount field) still send it exactly, and the payment matches its order.
7
+ export { RAW_PER_XNO, xnoText } from "../admin/amount.js";
8
+ /** One step: 0.0001 XNO. */
9
+ export const STEP_RAW = 10n ** 26n;
10
+ /** Sane bounds for dollars per XNO: outside them a price feed is wrong, not the market. */
11
+ export const MIN_RATE = 0.05;
12
+ export const MAX_RATE = 100;
13
+ /** The highest price a look can have, in US dollars (the looks file allows up to PRICE_MAX_USD, $1000). */
14
+ export const MAX_USD = 10_000;
15
+ /** A raw amount: digits only, no leading zero, not zero, at most 39 digits. */
16
+ export function isRaw(s) {
17
+ return typeof s === "string" && /^[1-9][0-9]{0,38}$/.test(s);
18
+ }
19
+ /** Whole steps of 0.0001 XNO, so at most four decimals. */
20
+ export function isClean(raw) {
21
+ return isRaw(raw) && BigInt(raw) % STEP_RAW === 0n;
22
+ }
23
+ /**
24
+ * The number of 0.0001 XNO steps for `usd` dollars at `rate` dollars per XNO, rounded UP (the creator never gets less than the price), or null when
25
+ * the price or the rate can't be used.
26
+ */
27
+ export function stepsFromUsd(usd, rate) {
28
+ if (!Number.isFinite(usd) || !Number.isFinite(rate) || usd <= 0 || usd > MAX_USD || rate < MIN_RATE || rate > MAX_RATE)
29
+ return null;
30
+ // A tiny allowance, so float noise (0.01 / 0.5 * 10000 = 200.00000000000003) doesn't add a whole step.
31
+ return Math.max(1, Math.ceil((usd / rate) * 10_000 - 1e-6));
32
+ }
33
+ export function rawFromSteps(steps) {
34
+ const n = BigInt(steps);
35
+ if (n <= 0n)
36
+ throw new Error("an amount must be above zero");
37
+ return (n * STEP_RAW).toString();
38
+ }
39
+ /** The clean price in raw, or null. */
40
+ export function priceRaw(usd, rate) {
41
+ const steps = stepsFromUsd(usd, rate);
42
+ return steps === null ? null : rawFromSteps(steps);
43
+ }
44
+ /** A clean amount plus `more` steps. */
45
+ export function plusSteps(raw, more) {
46
+ if (!isClean(raw))
47
+ throw new Error("not a clean amount");
48
+ return (BigInt(raw) + BigInt(more) * STEP_RAW).toString();
49
+ }
50
+ /** -1, 0 or 1. */
51
+ export function compareRaw(a, b) {
52
+ const x = BigInt(a);
53
+ const y = BigInt(b);
54
+ return x < y ? -1 : x > y ? 1 : 0;
55
+ }
56
+ /** The standard payment link: the amount in raw, every digit (docs.nano.org, URI and QR code standards). */
57
+ export function nanoUri(address, raw) {
58
+ return `nano:${address}?amount=${raw}`;
59
+ }
@@ -0,0 +1,13 @@
1
+ import { type ShopStore } from "./store.js";
2
+ export type ShopHarness = {
3
+ store: ShopStore;
4
+ /** Every row the store keeps (orders, owned looks, locks, recorded payments) as one text. `forget` must leave the player's id only where the money rules keep it. */
5
+ dump(): Promise<string>;
6
+ };
7
+ export type ShopConformanceCase = {
8
+ name: string;
9
+ run: () => Promise<void>;
10
+ };
11
+ /** 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). */
12
+ export declare const SHOP_CASE_TIMEOUT_MS = 60000;
13
+ export declare function shopStoreConformance(make: () => Promise<ShopHarness>): ShopConformanceCase[];
@@ -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
+ }