@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.
- package/CHANGELOG.md +92 -0
- package/README.md +390 -6
- package/ai/SPEC.md +71 -3
- package/dist/admin/panel/mount.d.ts +3 -0
- package/dist/admin/panel/mount.js +4 -2
- package/dist/player-signin/adapters/d1.d.ts +10 -0
- package/dist/player-signin/adapters/d1.js +108 -0
- package/dist/player-signin/adapters/firestore.d.ts +45 -0
- package/dist/player-signin/adapters/firestore.js +168 -0
- package/dist/player-signin/conformance.d.ts +11 -0
- package/dist/player-signin/conformance.js +160 -0
- package/dist/player-signin/handler.d.ts +64 -0
- package/dist/player-signin/handler.js +364 -0
- package/dist/player-signin/host.d.ts +4 -0
- package/dist/player-signin/host.js +10 -0
- package/dist/player-signin/index.d.ts +9 -0
- package/dist/player-signin/index.js +15 -0
- package/dist/player-signin/memory.d.ts +15 -0
- package/dist/player-signin/memory.js +101 -0
- package/dist/player-signin/panel/index.d.ts +9 -0
- package/dist/player-signin/panel/index.js +8 -0
- package/dist/player-signin/panel/memo.d.ts +12 -0
- package/dist/player-signin/panel/memo.js +41 -0
- package/dist/player-signin/panel/mount.d.ts +21 -0
- package/dist/player-signin/panel/mount.js +107 -0
- package/dist/player-signin/panel/view.d.ts +48 -0
- package/dist/player-signin/panel/view.js +363 -0
- package/dist/player-signin/panel/words.d.ts +103 -0
- package/dist/player-signin/panel/words.js +111 -0
- package/dist/player-signin/scheme-check.d.ts +3 -0
- package/dist/player-signin/scheme-check.js +14 -0
- package/dist/player-signin/scheme.d.ts +13 -0
- package/dist/player-signin/scheme.js +28 -0
- package/dist/player-signin/types.d.ts +116 -0
- package/dist/player-signin/types.js +33 -0
- package/dist/shop/adapters/d1.d.ts +25 -0
- package/dist/shop/adapters/d1.js +200 -0
- package/dist/shop/adapters/firestore.d.ts +53 -0
- package/dist/shop/adapters/firestore.js +245 -0
- package/dist/shop/address.d.ts +14 -0
- package/dist/shop/address.js +104 -0
- package/dist/shop/amount.d.ts +26 -0
- package/dist/shop/amount.js +59 -0
- package/dist/shop/conformance.d.ts +13 -0
- package/dist/shop/conformance.js +203 -0
- package/dist/shop/handler.d.ts +76 -0
- package/dist/shop/handler.js +156 -0
- package/dist/shop/index.d.ts +19 -0
- package/dist/shop/index.js +21 -0
- package/dist/shop/match.d.ts +23 -0
- package/dist/shop/match.js +58 -0
- package/dist/shop/memory.d.ts +13 -0
- package/dist/shop/memory.js +106 -0
- package/dist/shop/nodes.d.ts +47 -0
- package/dist/shop/nodes.js +106 -0
- package/dist/shop/orders.d.ts +47 -0
- package/dist/shop/orders.js +82 -0
- package/dist/shop/panel/index.d.ts +9 -0
- package/dist/shop/panel/index.js +14 -0
- package/dist/shop/panel/looks.d.ts +33 -0
- package/dist/shop/panel/looks.js +61 -0
- package/dist/shop/panel/mount.d.ts +68 -0
- package/dist/shop/panel/mount.js +221 -0
- package/dist/shop/panel/pay.d.ts +27 -0
- package/dist/shop/panel/pay.js +128 -0
- package/dist/shop/panel/qr.d.ts +9 -0
- package/dist/shop/panel/qr.js +281 -0
- package/dist/shop/panel/words.d.ts +89 -0
- package/dist/shop/panel/words.js +79 -0
- package/dist/shop/price.d.ts +23 -0
- package/dist/shop/price.js +43 -0
- package/dist/shop/scan.d.ts +24 -0
- package/dist/shop/scan.js +80 -0
- package/dist/shop/security.d.ts +10 -0
- package/dist/shop/security.js +56 -0
- package/dist/shop/store.d.ts +101 -0
- package/dist/shop/store.js +4 -0
- package/dist/shop/worker.d.ts +43 -0
- package/dist/shop/worker.js +76 -0
- package/package.json +21 -6
- package/src/admin/panel/mount.ts +7 -2
- package/src/player-signin/adapters/d1.sql +45 -0
- package/src/player-signin/adapters/d1.ts +120 -0
- package/src/player-signin/adapters/firestore.ts +194 -0
- package/src/player-signin/conformance.ts +184 -0
- package/src/player-signin/handler.ts +404 -0
- package/src/player-signin/host.ts +12 -0
- package/src/player-signin/index.ts +20 -0
- package/src/player-signin/memory.ts +94 -0
- package/src/player-signin/panel/index.ts +13 -0
- package/src/player-signin/panel/memo.ts +49 -0
- package/src/player-signin/panel/mount.ts +129 -0
- package/src/player-signin/panel/style.css +478 -0
- package/src/player-signin/panel/view.ts +433 -0
- package/src/player-signin/panel/words.ts +210 -0
- package/src/player-signin/scheme-check.ts +14 -0
- package/src/player-signin/scheme.ts +32 -0
- package/src/player-signin/types.ts +103 -0
- package/src/shop/adapters/d1.sql +52 -0
- package/src/shop/adapters/d1.ts +245 -0
- package/src/shop/adapters/firestore.ts +281 -0
- package/src/shop/address.ts +102 -0
- package/src/shop/amount.ts +66 -0
- package/src/shop/conformance.ts +232 -0
- package/src/shop/handler.ts +202 -0
- package/src/shop/index.ts +30 -0
- package/src/shop/match.ts +63 -0
- package/src/shop/memory.ts +102 -0
- package/src/shop/nodes.ts +115 -0
- package/src/shop/orders.ts +93 -0
- package/src/shop/panel/index.ts +19 -0
- package/src/shop/panel/looks.ts +106 -0
- package/src/shop/panel/mount.ts +280 -0
- package/src/shop/panel/pay.ts +192 -0
- package/src/shop/panel/qr.ts +256 -0
- package/src/shop/panel/style.css +538 -0
- package/src/shop/panel/words.ts +172 -0
- package/src/shop/price.ts +52 -0
- package/src/shop/scan.ts +90 -0
- package/src/shop/security.ts +50 -0
- package/src/shop/store.ts +99 -0
- package/src/shop/worker.ts +107 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// The shop store for a game on Firestore (the Admin SDK). The package has no Firebase dependency: the adapter uses the small interface below, which the
|
|
2
|
+
// Admin SDK's `Firestore` fits. It reads and writes ONLY its five collections.
|
|
3
|
+
//
|
|
4
|
+
// import { createFirestoreShop } from "@nano133/nanoterra/shop/firestore";
|
|
5
|
+
// const store = createFirestoreShop(getFirestore());
|
|
6
|
+
//
|
|
7
|
+
// The data model (the five collection names can be changed; the first three are the ones the admin's Firestore store reads):
|
|
8
|
+
// orders/{id} { player, lookId, usd, rate, amount, amountLock, status, createdAt, expiresAt, holdUntil, hash, payer, paidAt, visitor }
|
|
9
|
+
// owned/{player__lookId} { player, lookId, orderId, grantedAt } (the player's id with encodeURIComponent: a Nano address stays as it is)
|
|
10
|
+
// meta/rate { value: JSON { usd, sources }, at } meta/scan { value: "", at }
|
|
11
|
+
// shop_amounts/{amount} { orderId, holdUntil } the reservation: a document for each held amount, so two orders cannot hold one (a transaction makes it)
|
|
12
|
+
// shop_hashes/{hash} { orderId } one payment pays one order: a document for each recorded payment
|
|
13
|
+
// Every write that a rule depends on is a transaction that reads what it checks. Indexes: the single-field indexes Firestore makes by itself are enough
|
|
14
|
+
// (the queries are equalities, and one `<` on shop_amounts.holdUntil).
|
|
15
|
+
import { paidStatus } from "../orders.js";
|
|
16
|
+
import { FORGOTTEN } from "../store.js";
|
|
17
|
+
/** The most documents one query reads: a small game's orders of one player, or its held amounts. */
|
|
18
|
+
export const FIRESTORE_SHOP_SCAN = 2000;
|
|
19
|
+
const text = (v, fallback = "") => (typeof v === "string" ? v : fallback);
|
|
20
|
+
const num = (v, fallback = 0) => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
|
|
21
|
+
const orNull = (v) => (typeof v === "string" ? v : null);
|
|
22
|
+
const STATUSES = ["open", "paid", "expired", "paid_late"];
|
|
23
|
+
function orderOf(id, d) {
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
player: text(d.player),
|
|
27
|
+
lookId: text(d.lookId),
|
|
28
|
+
usd: text(d.usd, "0"),
|
|
29
|
+
rate: num(d.rate),
|
|
30
|
+
amount: text(d.amount, "0"),
|
|
31
|
+
amountLock: orNull(d.amountLock),
|
|
32
|
+
status: STATUSES.includes(d.status) ? d.status : "expired",
|
|
33
|
+
createdAt: num(d.createdAt),
|
|
34
|
+
expiresAt: num(d.expiresAt),
|
|
35
|
+
holdUntil: num(d.holdUntil),
|
|
36
|
+
hash: orNull(d.hash),
|
|
37
|
+
payer: orNull(d.payer),
|
|
38
|
+
paidAt: typeof d.paidAt === "number" ? d.paidAt : null,
|
|
39
|
+
visitor: orNull(d.visitor),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const dataOf = (o) => ({
|
|
43
|
+
player: o.player,
|
|
44
|
+
lookId: o.lookId,
|
|
45
|
+
usd: o.usd,
|
|
46
|
+
rate: o.rate,
|
|
47
|
+
amount: o.amount,
|
|
48
|
+
amountLock: o.amountLock,
|
|
49
|
+
status: o.status,
|
|
50
|
+
createdAt: o.createdAt,
|
|
51
|
+
expiresAt: o.expiresAt,
|
|
52
|
+
holdUntil: o.holdUntil,
|
|
53
|
+
hash: o.hash,
|
|
54
|
+
payer: o.payer,
|
|
55
|
+
paidAt: o.paidAt,
|
|
56
|
+
visitor: o.visitor,
|
|
57
|
+
});
|
|
58
|
+
export function createFirestoreShop(db, collections = {}) {
|
|
59
|
+
const C = {
|
|
60
|
+
orders: collections.orders ?? "orders",
|
|
61
|
+
owned: collections.owned ?? "owned",
|
|
62
|
+
meta: collections.meta ?? "meta",
|
|
63
|
+
amounts: collections.amounts ?? "shop_amounts",
|
|
64
|
+
hashes: collections.hashes ?? "shop_hashes",
|
|
65
|
+
};
|
|
66
|
+
const col = (name) => db.collection(C[name]);
|
|
67
|
+
const ownedId = (player, lookId) => `${encodeURIComponent(player)}__${lookId}`;
|
|
68
|
+
const ordersOf = (player) => col("orders").where("player", "==", player).limit(FIRESTORE_SHOP_SCAN);
|
|
69
|
+
async function commitAll(writes) {
|
|
70
|
+
// A batch holds at most 500 writes.
|
|
71
|
+
for (let i = 0; i < writes.length; i += 400) {
|
|
72
|
+
const batch = db.batch();
|
|
73
|
+
for (const w of writes.slice(i, i + 400))
|
|
74
|
+
w(batch);
|
|
75
|
+
await batch.commit();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const tidy = async (now) => {
|
|
79
|
+
// Each change reads the document again in a transaction: a blind write could undo a payment that was recorded meanwhile.
|
|
80
|
+
const open = (await col("orders").where("status", "==", "open").limit(FIRESTORE_SHOP_SCAN).get()).docs.filter((d) => num(d.data().expiresAt) < now);
|
|
81
|
+
for (const d of open) {
|
|
82
|
+
await db.runTransaction(async (tx) => {
|
|
83
|
+
const snap = await tx.get(d.ref);
|
|
84
|
+
const o = snap.exists ? snap.data() : undefined;
|
|
85
|
+
if (o && o.status === "open" && num(o.expiresAt) < now)
|
|
86
|
+
tx.set(d.ref, { status: "expired" }, { merge: true });
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const stale = (await col("amounts").where("holdUntil", "<", now).limit(FIRESTORE_SHOP_SCAN).get()).docs;
|
|
90
|
+
for (const d of stale) {
|
|
91
|
+
await db.runTransaction(async (tx) => {
|
|
92
|
+
const snap = await tx.get(d.ref);
|
|
93
|
+
const lock = snap.exists ? snap.data() : undefined;
|
|
94
|
+
if (!lock || num(lock.holdUntil) >= now)
|
|
95
|
+
return;
|
|
96
|
+
const orderRef = col("orders").doc(text(lock.orderId));
|
|
97
|
+
const order = await tx.get(orderRef);
|
|
98
|
+
tx.delete(d.ref);
|
|
99
|
+
if (order.exists && order.data()?.amountLock === d.id)
|
|
100
|
+
tx.set(orderRef, { amountLock: null }, { merge: true });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
tidy,
|
|
106
|
+
async heldAmounts(amounts) {
|
|
107
|
+
const want = new Set(amounts);
|
|
108
|
+
return new Set((await col("amounts").limit(FIRESTORE_SHOP_SCAN).get()).docs.map((d) => d.id).filter((id) => want.has(id)));
|
|
109
|
+
},
|
|
110
|
+
async insert(o) {
|
|
111
|
+
const lockRef = col("amounts").doc(o.amount);
|
|
112
|
+
const orderRef = col("orders").doc(o.id);
|
|
113
|
+
return db.runTransaction(async (tx) => {
|
|
114
|
+
const [lock, order] = [await tx.get(lockRef), await tx.get(orderRef)];
|
|
115
|
+
if (lock.exists || order.exists)
|
|
116
|
+
return false;
|
|
117
|
+
tx.set(lockRef, { orderId: o.id, holdUntil: o.holdUntil });
|
|
118
|
+
tx.set(orderRef, dataOf({ ...o, amountLock: o.amount, status: "open", hash: null, payer: null, paidAt: null }));
|
|
119
|
+
return true;
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
async order(id) {
|
|
123
|
+
const snap = await col("orders").doc(id).get();
|
|
124
|
+
const d = snap.exists ? snap.data() : undefined;
|
|
125
|
+
return d ? orderOf(id, d) : null;
|
|
126
|
+
},
|
|
127
|
+
async openOrder(player, lookId, until) {
|
|
128
|
+
const docs = (await ordersOf(player).where("lookId", "==", lookId).where("status", "==", "open").get()).docs;
|
|
129
|
+
const list = docs.map((d) => orderOf(d.id, d.data())).filter((o) => o.player === player && o.expiresAt > until).sort((a, b) => b.createdAt - a.createdAt);
|
|
130
|
+
return list[0] ?? null;
|
|
131
|
+
},
|
|
132
|
+
async countOpen(player, now) {
|
|
133
|
+
return (await ordersOf(player).where("status", "==", "open").get()).docs.map((d) => orderOf(d.id, d.data())).filter((o) => o.player === player && o.expiresAt > now).length;
|
|
134
|
+
},
|
|
135
|
+
async owns(player, lookId) {
|
|
136
|
+
const snap = await col("owned").doc(ownedId(player, lookId)).get();
|
|
137
|
+
return snap.exists && snap.data()?.player === player;
|
|
138
|
+
},
|
|
139
|
+
async owned(player) {
|
|
140
|
+
const rows = (await col("owned").where("player", "==", player).limit(FIRESTORE_SHOP_SCAN).get()).docs.map((d) => d.data());
|
|
141
|
+
return rows
|
|
142
|
+
.filter((r) => r.player === player)
|
|
143
|
+
.sort((a, b) => num(a.grantedAt) - num(b.grantedAt) || (text(a.lookId) < text(b.lookId) ? -1 : 1))
|
|
144
|
+
.map((r) => text(r.lookId));
|
|
145
|
+
},
|
|
146
|
+
async held() {
|
|
147
|
+
const locks = (await col("amounts").limit(FIRESTORE_SHOP_SCAN).get()).docs;
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const l of locks) {
|
|
150
|
+
const id = text(l.data().orderId);
|
|
151
|
+
const snap = await col("orders").doc(id).get();
|
|
152
|
+
const d = snap.exists ? snap.data() : undefined;
|
|
153
|
+
if (d && d.amountLock === l.id)
|
|
154
|
+
out.push(orderOf(id, d));
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
},
|
|
158
|
+
async usedHashes(hashes) {
|
|
159
|
+
const snaps = await Promise.all(hashes.map(async (h) => [h, await col("hashes").doc(h).get()]));
|
|
160
|
+
return new Set(snaps.filter(([, s]) => s.exists).map(([h]) => h));
|
|
161
|
+
},
|
|
162
|
+
async settle(order, pay, now) {
|
|
163
|
+
const orderRef = col("orders").doc(order.id);
|
|
164
|
+
const hashRef = col("hashes").doc(pay.hash);
|
|
165
|
+
return db.runTransaction(async (tx) => {
|
|
166
|
+
const hash = await tx.get(hashRef);
|
|
167
|
+
const snap = await tx.get(orderRef);
|
|
168
|
+
const d = snap.exists ? snap.data() : undefined;
|
|
169
|
+
const lockRef = col("amounts").doc(text(d?.amountLock, order.amount));
|
|
170
|
+
const lock = await tx.get(lockRef);
|
|
171
|
+
const o = d ? orderOf(order.id, d) : null;
|
|
172
|
+
const ownedRef = col("owned").doc(ownedId(o?.player ?? order.player, o?.lookId ?? order.lookId));
|
|
173
|
+
const owned = await tx.get(ownedRef);
|
|
174
|
+
if (!o || (o.status !== "open" && o.status !== "expired") || o.amountLock === null)
|
|
175
|
+
return "taken";
|
|
176
|
+
if (hash.exists)
|
|
177
|
+
return "used";
|
|
178
|
+
const status = paidStatus(o, pay.seenMs);
|
|
179
|
+
tx.set(orderRef, dataOf({ ...o, status, hash: pay.hash, payer: pay.payer, paidAt: pay.seenMs, amountLock: null }));
|
|
180
|
+
if (lock.exists && lock.data()?.orderId === order.id)
|
|
181
|
+
tx.delete(lockRef);
|
|
182
|
+
tx.set(hashRef, { orderId: order.id });
|
|
183
|
+
if (status === "paid" && !owned.exists)
|
|
184
|
+
tx.set(ownedRef, { player: o.player, lookId: o.lookId, orderId: o.id, grantedAt: now });
|
|
185
|
+
return status;
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
async claimScan(now, gapMs) {
|
|
189
|
+
const ref = col("meta").doc("scan");
|
|
190
|
+
return db.runTransaction(async (tx) => {
|
|
191
|
+
const snap = await tx.get(ref);
|
|
192
|
+
const at = snap.exists ? num(snap.data()?.at, -Infinity) : -Infinity;
|
|
193
|
+
if (at > now - gapMs)
|
|
194
|
+
return false;
|
|
195
|
+
tx.set(ref, { value: "", at: now });
|
|
196
|
+
return true;
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
async readRate() {
|
|
200
|
+
const snap = await col("meta").doc("rate").get();
|
|
201
|
+
const d = snap.exists ? snap.data() : undefined;
|
|
202
|
+
if (!d || typeof d.value !== "string" || typeof d.at !== "number")
|
|
203
|
+
return null;
|
|
204
|
+
try {
|
|
205
|
+
const v = JSON.parse(d.value);
|
|
206
|
+
if (typeof v.usd !== "number" || !Number.isFinite(v.usd) || !Array.isArray(v.sources) || !v.sources.every((s) => typeof s === "string"))
|
|
207
|
+
return null;
|
|
208
|
+
return { usd: v.usd, sources: v.sources, at: d.at };
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
async saveRate(rate) {
|
|
215
|
+
const batch = db.batch();
|
|
216
|
+
batch.set(col("meta").doc("rate"), { value: JSON.stringify({ usd: rate.usd, sources: rate.sources }), at: rate.at });
|
|
217
|
+
await batch.commit();
|
|
218
|
+
},
|
|
219
|
+
async forget(player, now) {
|
|
220
|
+
await tidy(now);
|
|
221
|
+
const mine = (await ordersOf(player).get()).docs.map((d) => ({ ref: d.ref, o: orderOf(d.id, d.data()) })).filter((x) => x.o.player === player);
|
|
222
|
+
const waiting = mine.filter((x) => x.o.amountLock !== null);
|
|
223
|
+
if (waiting.length)
|
|
224
|
+
return { deleted: 0, kept: 0, waitUntil: Math.max(...waiting.map((x) => x.o.holdUntil)) };
|
|
225
|
+
const owned = (await col("owned").where("player", "==", player).limit(FIRESTORE_SHOP_SCAN).get()).docs.filter((d) => d.data().player === player);
|
|
226
|
+
const paidBy = (await col("orders").where("payer", "==", player).limit(FIRESTORE_SHOP_SCAN).get()).docs.filter((d) => d.data().status === "paid");
|
|
227
|
+
const writes = [];
|
|
228
|
+
let deleted = 0;
|
|
229
|
+
let kept = 0;
|
|
230
|
+
for (const d of owned)
|
|
231
|
+
(writes.push((b) => b.delete(d.ref)), deleted++);
|
|
232
|
+
for (const { ref, o } of mine) {
|
|
233
|
+
if (o.hash === null)
|
|
234
|
+
(writes.push((b) => b.delete(ref)), deleted++);
|
|
235
|
+
else
|
|
236
|
+
(writes.push((b) => b.set(ref, { player: FORGOTTEN, visitor: null }, { merge: true })), kept++);
|
|
237
|
+
}
|
|
238
|
+
// A paid order keeps no payer; a paid-late one keeps it: the creator sends that payment back by hand.
|
|
239
|
+
for (const d of paidBy)
|
|
240
|
+
writes.push((b) => b.set(d.ref, { payer: FORGOTTEN }, { merge: true }));
|
|
241
|
+
await commitAll(writes);
|
|
242
|
+
return { deleted, kept, waitUntil: null };
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** The address that belongs to nobody (its key is all zeros): the example in the game starter's wrangler.jsonc. A shop with it is in test mode. */
|
|
2
|
+
export declare const EXAMPLE_ADDRESS = "nano_1111111111111111111111111111111111111111111111111111hifc8npp";
|
|
3
|
+
/** BLAKE2b of at most 128 bytes (one block), `outLen` bytes, no key (RFC 7693). Enough for a checksum of a 32-byte key. */
|
|
4
|
+
export declare function blake2bShort(input: Uint8Array, outLen: number): Uint8Array;
|
|
5
|
+
/**
|
|
6
|
+
* True for a whole Nano address: "nano_" (or "xrb_"), its shape, and its checksum (the last 8 characters: the BLAKE2b-40 of the key, bytes reversed).
|
|
7
|
+
* One wrong character fails it.
|
|
8
|
+
*/
|
|
9
|
+
export declare function validAddress(address: unknown): address is string;
|
|
10
|
+
/**
|
|
11
|
+
* The payee as the shop uses it: a whole address in lower case with the "nano_" prefix, or null (no address, the example, or a wrong checksum). With
|
|
12
|
+
* null the shop is in test mode: no pay screen shows an address, a QR code or a wallet link.
|
|
13
|
+
*/
|
|
14
|
+
export declare function payeeOf(address: unknown): string | null;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The creator's address (the payee): a whole Nano address, its shape AND its checksum. A typo in a receive address would send every payment to an
|
|
2
|
+
// account nobody holds the key for, so the shop sells only to an address whose checksum is right (the game starter checks it with @nano133/signin's
|
|
3
|
+
// `validAddress`; this package has no dependency, so it carries the 5-byte BLAKE2b that the checksum needs, and nothing else of it).
|
|
4
|
+
/** The address that belongs to nobody (its key is all zeros): the example in the game starter's wrangler.jsonc. A shop with it is in test mode. */
|
|
5
|
+
export const EXAMPLE_ADDRESS = "nano_1111111111111111111111111111111111111111111111111111hifc8npp";
|
|
6
|
+
const ALPHABET = "13456789abcdefghijkmnopqrstuwxyz";
|
|
7
|
+
const SHAPE = /^nano_[13][13456789abcdefghijkmnopqrstuwxyz]{59}$/;
|
|
8
|
+
const MASK = (1n << 64n) - 1n;
|
|
9
|
+
const IV = [
|
|
10
|
+
0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n, 0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n,
|
|
11
|
+
];
|
|
12
|
+
const SIGMA = [
|
|
13
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
|
14
|
+
[14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
|
|
15
|
+
[11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4],
|
|
16
|
+
[7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
|
|
17
|
+
[9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13],
|
|
18
|
+
[2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
|
|
19
|
+
[12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11],
|
|
20
|
+
[13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
|
|
21
|
+
[6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5],
|
|
22
|
+
[10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0],
|
|
23
|
+
];
|
|
24
|
+
const rotr = (x, n) => ((x >> n) | (x << (64n - n))) & MASK;
|
|
25
|
+
/** BLAKE2b of at most 128 bytes (one block), `outLen` bytes, no key (RFC 7693). Enough for a checksum of a 32-byte key. */
|
|
26
|
+
export function blake2bShort(input, outLen) {
|
|
27
|
+
if (input.length > 128 || outLen < 1 || outLen > 64)
|
|
28
|
+
throw new Error("blake2bShort: one block of at most 128 bytes, 1 to 64 bytes out");
|
|
29
|
+
const h = IV.slice();
|
|
30
|
+
h[0] ^= 0x01010000n ^ BigInt(outLen);
|
|
31
|
+
const block = new Uint8Array(128);
|
|
32
|
+
block.set(input);
|
|
33
|
+
const m = [];
|
|
34
|
+
for (let i = 0; i < 16; i++) {
|
|
35
|
+
let w = 0n;
|
|
36
|
+
for (let b = 7; b >= 0; b--)
|
|
37
|
+
w = (w << 8n) | BigInt(block[i * 8 + b]);
|
|
38
|
+
m.push(w);
|
|
39
|
+
}
|
|
40
|
+
const v = [...h, ...IV];
|
|
41
|
+
v[12] ^= BigInt(input.length);
|
|
42
|
+
v[14] ^= MASK;
|
|
43
|
+
const g = (a, b, c, d, x, y) => {
|
|
44
|
+
v[a] = (v[a] + v[b] + x) & MASK;
|
|
45
|
+
v[d] = rotr(v[d] ^ v[a], 32n);
|
|
46
|
+
v[c] = (v[c] + v[d]) & MASK;
|
|
47
|
+
v[b] = rotr(v[b] ^ v[c], 24n);
|
|
48
|
+
v[a] = (v[a] + v[b] + y) & MASK;
|
|
49
|
+
v[d] = rotr(v[d] ^ v[a], 16n);
|
|
50
|
+
v[c] = (v[c] + v[d]) & MASK;
|
|
51
|
+
v[b] = rotr(v[b] ^ v[c], 63n);
|
|
52
|
+
};
|
|
53
|
+
for (let r = 0; r < 12; r++) {
|
|
54
|
+
const s = SIGMA[r % 10];
|
|
55
|
+
g(0, 4, 8, 12, m[s[0]], m[s[1]]);
|
|
56
|
+
g(1, 5, 9, 13, m[s[2]], m[s[3]]);
|
|
57
|
+
g(2, 6, 10, 14, m[s[4]], m[s[5]]);
|
|
58
|
+
g(3, 7, 11, 15, m[s[6]], m[s[7]]);
|
|
59
|
+
g(0, 5, 10, 15, m[s[8]], m[s[9]]);
|
|
60
|
+
g(1, 6, 11, 12, m[s[10]], m[s[11]]);
|
|
61
|
+
g(2, 7, 8, 13, m[s[12]], m[s[13]]);
|
|
62
|
+
g(3, 4, 9, 14, m[s[14]], m[s[15]]);
|
|
63
|
+
}
|
|
64
|
+
const out = new Uint8Array(outLen);
|
|
65
|
+
for (let i = 0; i < outLen; i++)
|
|
66
|
+
out[i] = Number(((h[i >> 3] ^ v[i >> 3] ^ v[(i >> 3) + 8]) >> BigInt(8 * (i & 7))) & 0xffn);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/** Base-32 text (Nano's alphabet) as a big number. */
|
|
70
|
+
const bitsOf = (text) => [...text].reduce((n, c) => (n << 5n) | BigInt(ALPHABET.indexOf(c)), 0n);
|
|
71
|
+
/**
|
|
72
|
+
* True for a whole Nano address: "nano_" (or "xrb_"), its shape, and its checksum (the last 8 characters: the BLAKE2b-40 of the key, bytes reversed).
|
|
73
|
+
* One wrong character fails it.
|
|
74
|
+
*/
|
|
75
|
+
export function validAddress(address) {
|
|
76
|
+
if (typeof address !== "string")
|
|
77
|
+
return false;
|
|
78
|
+
const a = address.replace(/^xrb_/, "nano_");
|
|
79
|
+
if (!SHAPE.test(a))
|
|
80
|
+
return false;
|
|
81
|
+
const body = a.slice(5);
|
|
82
|
+
// 52 characters are 260 bits: 4 bits that must be zero, then the 32-byte key.
|
|
83
|
+
const key = bitsOf(body.slice(0, 52));
|
|
84
|
+
if (key >> 256n !== 0n)
|
|
85
|
+
return false;
|
|
86
|
+
const bytes = new Uint8Array(32);
|
|
87
|
+
for (let i = 0; i < 32; i++)
|
|
88
|
+
bytes[i] = Number((key >> BigInt(8 * (31 - i))) & 0xffn);
|
|
89
|
+
const check = blake2bShort(bytes, 5).reverse();
|
|
90
|
+
const want = check.reduce((n, b) => (n << 8n) | BigInt(b), 0n);
|
|
91
|
+
return bitsOf(body.slice(52)) === want;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The payee as the shop uses it: a whole address in lower case with the "nano_" prefix, or null (no address, the example, or a wrong checksum). With
|
|
95
|
+
* null the shop is in test mode: no pay screen shows an address, a QR code or a wallet link.
|
|
96
|
+
*/
|
|
97
|
+
export function payeeOf(address) {
|
|
98
|
+
if (typeof address !== "string")
|
|
99
|
+
return null;
|
|
100
|
+
const a = address.trim().toLowerCase().replace(/^xrb_/, "nano_");
|
|
101
|
+
if (!a || a === EXAMPLE_ADDRESS)
|
|
102
|
+
return null;
|
|
103
|
+
return validAddress(a) ? a : null;
|
|
104
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export { RAW_PER_XNO, xnoText } from "../admin/amount.js";
|
|
2
|
+
/** One step: 0.0001 XNO. */
|
|
3
|
+
export declare const STEP_RAW: bigint;
|
|
4
|
+
/** Sane bounds for dollars per XNO: outside them a price feed is wrong, not the market. */
|
|
5
|
+
export declare const MIN_RATE = 0.05;
|
|
6
|
+
export declare const MAX_RATE = 100;
|
|
7
|
+
/** The highest price a look can have, in US dollars (the looks file allows up to PRICE_MAX_USD, $1000). */
|
|
8
|
+
export declare const MAX_USD = 10000;
|
|
9
|
+
/** A raw amount: digits only, no leading zero, not zero, at most 39 digits. */
|
|
10
|
+
export declare function isRaw(s: unknown): s is string;
|
|
11
|
+
/** Whole steps of 0.0001 XNO, so at most four decimals. */
|
|
12
|
+
export declare function isClean(raw: unknown): raw is string;
|
|
13
|
+
/**
|
|
14
|
+
* 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
|
|
15
|
+
* the price or the rate can't be used.
|
|
16
|
+
*/
|
|
17
|
+
export declare function stepsFromUsd(usd: number, rate: number): number | null;
|
|
18
|
+
export declare function rawFromSteps(steps: number | bigint): string;
|
|
19
|
+
/** The clean price in raw, or null. */
|
|
20
|
+
export declare function priceRaw(usd: number, rate: number): string | null;
|
|
21
|
+
/** A clean amount plus `more` steps. */
|
|
22
|
+
export declare function plusSteps(raw: string, more: number): string;
|
|
23
|
+
/** -1, 0 or 1. */
|
|
24
|
+
export declare function compareRaw(a: string, b: string): number;
|
|
25
|
+
/** The standard payment link: the amount in raw, every digit (docs.nano.org, URI and QR code standards). */
|
|
26
|
+
export declare function nanoUri(address: string, raw: string): string;
|
|
@@ -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[];
|