@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.
- package/CHANGELOG.md +59 -0
- package/LICENSE +3 -0
- package/README.md +363 -25
- package/ai/SPEC.md +65 -3
- package/dist/admin/panel/dom.d.ts +2 -0
- package/dist/admin/panel/dom.js +6 -0
- package/dist/admin/panel/mount.d.ts +3 -0
- package/dist/admin/panel/mount.js +4 -2
- package/dist/emotes/panel/mount.d.ts +5 -0
- package/dist/emotes/panel/mount.js +7 -3
- package/dist/friends/panel/mount.d.ts +5 -0
- package/dist/friends/panel/mount.js +5 -2
- package/dist/options/panel/mount.d.ts +5 -0
- package/dist/options/panel/mount.js +5 -3
- 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 +7 -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 +105 -0
- package/dist/player-signin/panel/view.d.ts +45 -0
- package/dist/player-signin/panel/view.js +360 -0
- package/dist/player-signin/panel/words.d.ts +8 -0
- package/dist/player-signin/panel/words.js +26 -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 +7 -0
- package/dist/shop/panel/index.js +11 -0
- package/dist/shop/panel/looks.d.ts +30 -0
- package/dist/shop/panel/looks.js +61 -0
- package/dist/shop/panel/mount.d.ts +52 -0
- package/dist/shop/panel/mount.js +194 -0
- package/dist/shop/panel/pay.d.ts +24 -0
- package/dist/shop/panel/pay.js +115 -0
- package/dist/shop/panel/qr.d.ts +9 -0
- package/dist/shop/panel/qr.js +281 -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/dom.ts +6 -0
- package/src/admin/panel/mount.ts +7 -2
- package/src/emotes/panel/mount.ts +9 -2
- package/src/friends/panel/mount.ts +8 -2
- package/src/options/panel/mount.ts +8 -3
- 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 +12 -0
- package/src/player-signin/panel/memo.ts +49 -0
- package/src/player-signin/panel/mount.ts +127 -0
- package/src/player-signin/panel/style.css +473 -0
- package/src/player-signin/panel/view.ts +428 -0
- package/src/player-signin/panel/words.ts +32 -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 +15 -0
- package/src/shop/panel/looks.ts +106 -0
- package/src/shop/panel/mount.ts +241 -0
- package/src/shop/panel/pay.ts +171 -0
- package/src/shop/panel/qr.ts +256 -0
- package/src/shop/panel/style.css +534 -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,200 @@
|
|
|
1
|
+
// The shop store for a game on Cloudflare D1, on the game starter's own tables (migrations/0001_init.sql): `orders`, `owned` and `meta`. The package
|
|
2
|
+
// ships the same tables as "@nano133/nanoterra/shop/d1.sql" (CREATE ... IF NOT EXISTS) for a new game; the starter has them already, so it switches with
|
|
3
|
+
// no migration and keeps every order and owned look.
|
|
4
|
+
//
|
|
5
|
+
// import { createD1Shop } from "@nano133/nanoterra/shop/d1";
|
|
6
|
+
// const store = createD1Shop(env.DB);
|
|
7
|
+
//
|
|
8
|
+
// It reads and writes ONLY those three tables (in `meta`, only the keys "rate" and "scan"). The table names can be changed (`tables`), but the admin's
|
|
9
|
+
// D1 store ("@nano133/nanoterra/admin/d1") reads the starter's names: keep them unless the game has its own admin store.
|
|
10
|
+
//
|
|
11
|
+
// The money rules are in the statements themselves: `amount_lock` and `hash` are UNIQUE (no two held orders ask for one amount, one payment pays one
|
|
12
|
+
// order), the payment and the grant are ONE batch (a D1 transaction), and the scan's claim is one upsert that only one caller in a gap can win.
|
|
13
|
+
import { parseRate } from "../../admin/summary.js";
|
|
14
|
+
import { paidStatus } from "../orders.js";
|
|
15
|
+
import { FORGOTTEN } from "../store.js";
|
|
16
|
+
const STATUSES = ["open", "paid", "expired", "paid_late"];
|
|
17
|
+
const orderOf = (r) => ({
|
|
18
|
+
id: r.id,
|
|
19
|
+
player: r.player,
|
|
20
|
+
lookId: r.look_id,
|
|
21
|
+
usd: r.usd,
|
|
22
|
+
rate: Number(r.rate),
|
|
23
|
+
amount: r.amount,
|
|
24
|
+
amountLock: r.amount_lock ?? null,
|
|
25
|
+
status: STATUSES.includes(r.status) ? r.status : "expired",
|
|
26
|
+
createdAt: Number(r.created_at),
|
|
27
|
+
expiresAt: Number(r.expires_at),
|
|
28
|
+
holdUntil: Number(r.hold_until),
|
|
29
|
+
hash: r.hash ?? null,
|
|
30
|
+
payer: r.payer ?? null,
|
|
31
|
+
paidAt: r.paid_at === null || r.paid_at === undefined ? null : Number(r.paid_at),
|
|
32
|
+
visitor: r.visitor ?? null,
|
|
33
|
+
});
|
|
34
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
35
|
+
const changes = (res) => Number(res?.meta?.changes ?? 0);
|
|
36
|
+
const isUnique = (e) => /UNIQUE/i.test(String(e?.message ?? e));
|
|
37
|
+
/** D1 takes at most 100 bound values in one statement. */
|
|
38
|
+
const CHUNK = 90;
|
|
39
|
+
const tableName = (v, fallback) => {
|
|
40
|
+
const name = v ?? fallback;
|
|
41
|
+
if (!NAME.test(name))
|
|
42
|
+
throw new Error(`the table name "${name}" must be plain (letters, digits and _)`);
|
|
43
|
+
return name;
|
|
44
|
+
};
|
|
45
|
+
async function sha256Hex(text) {
|
|
46
|
+
const bytes = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
|
|
47
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* True while `key` has made at most `limit` calls in this fixed window, counted in D1 (the table `hits`: one count for the whole game, every location).
|
|
51
|
+
* The game starter's `d1Limit`. Old windows stay until the game deletes them (the starter's cron deletes those older than a day).
|
|
52
|
+
*/
|
|
53
|
+
export async function d1Limit(db, key, limit, windowMs, now = Date.now(), tables = {}) {
|
|
54
|
+
const hits = tableName(tables.hits, "hits");
|
|
55
|
+
const win = Math.floor(now / windowMs);
|
|
56
|
+
const row = await db.prepare(`INSERT INTO ${hits} (key, win, count) VALUES (?, ?, 1) ON CONFLICT (key, win) DO UPDATE SET count = count + 1 RETURNING count`).bind(key, win).first();
|
|
57
|
+
return Number(row?.count ?? 1) <= limit;
|
|
58
|
+
}
|
|
59
|
+
/** The visitor's IP address as Cloudflare saw it (CF-Connecting-IP); an IPv6 address by its /64 (one home network). "local" when there is none. */
|
|
60
|
+
export function visitorIp(req) {
|
|
61
|
+
const ip = req.headers.get("CF-Connecting-IP") ?? "local";
|
|
62
|
+
return ip.includes(":") ? ip.split(":").slice(0, 4).join(":") + "::/64" : ip;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The visitor's key: a hash of a random salt (made once per game and kept in `meta` as "salt") and the IP address, never the address itself. The game
|
|
66
|
+
* starter's `visitorKey`: the same salt row, so the keys stay the same after a switch.
|
|
67
|
+
*/
|
|
68
|
+
export async function visitorKey(db, req, tables = {}) {
|
|
69
|
+
const meta = tableName(tables.meta, "meta");
|
|
70
|
+
let row = await db.prepare(`SELECT value FROM ${meta} WHERE key = 'salt'`).first();
|
|
71
|
+
if (!row) {
|
|
72
|
+
const fresh = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), (x) => x.toString(16).padStart(2, "0")).join("");
|
|
73
|
+
await db.prepare(`INSERT OR IGNORE INTO ${meta} (key, value, at) VALUES ('salt', ?, ?)`).bind(fresh, Date.now()).run();
|
|
74
|
+
row = await db.prepare(`SELECT value FROM ${meta} WHERE key = 'salt'`).first();
|
|
75
|
+
}
|
|
76
|
+
return (await sha256Hex(`${row.value}|${visitorIp(req)}`)).slice(0, 32);
|
|
77
|
+
}
|
|
78
|
+
export function createD1Shop(db, options = {}) {
|
|
79
|
+
const T = { orders: tableName(options.tables?.orders, "orders"), owned: tableName(options.tables?.owned, "owned"), meta: tableName(options.tables?.meta, "meta") };
|
|
80
|
+
const COLS = "id, player, look_id, usd, rate, amount, amount_lock, status, created_at, expires_at, hold_until, hash, payer, paid_at, visitor";
|
|
81
|
+
async function inChunks(sql, values, col) {
|
|
82
|
+
const out = new Set();
|
|
83
|
+
for (let i = 0; i < values.length; i += CHUNK) {
|
|
84
|
+
const part = values.slice(i, i + CHUNK);
|
|
85
|
+
const rows = (await db.prepare(sql(part.map(() => "?").join(", "))).bind(...part).all()).results;
|
|
86
|
+
for (const r of rows)
|
|
87
|
+
out.add(r[col]);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
const tidy = async (now) => {
|
|
92
|
+
await db.batch([
|
|
93
|
+
db.prepare(`UPDATE ${T.orders} SET status = 'expired' WHERE status = 'open' AND expires_at < ?`).bind(now),
|
|
94
|
+
db.prepare(`UPDATE ${T.orders} SET amount_lock = NULL WHERE amount_lock IS NOT NULL AND hold_until < ?`).bind(now),
|
|
95
|
+
]);
|
|
96
|
+
};
|
|
97
|
+
const waiting = async (player) => db.prepare(`SELECT COUNT(*) AS n, MAX(hold_until) AS until FROM ${T.orders} WHERE player = ? AND amount_lock IS NOT NULL`).bind(player).first();
|
|
98
|
+
return {
|
|
99
|
+
tidy,
|
|
100
|
+
heldAmounts: (amounts) => inChunks((m) => `SELECT amount_lock FROM ${T.orders} WHERE amount_lock IN (${m})`, amounts, "amount_lock"),
|
|
101
|
+
async insert(o) {
|
|
102
|
+
try {
|
|
103
|
+
await db
|
|
104
|
+
.prepare(`INSERT INTO ${T.orders} (id, player, look_id, usd, rate, amount, amount_lock, status, created_at, expires_at, hold_until, visitor) VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?)`)
|
|
105
|
+
.bind(o.id, o.player, o.lookId, o.usd, o.rate, o.amount, o.amount, o.createdAt, o.expiresAt, o.holdUntil, o.visitor)
|
|
106
|
+
.run();
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
// Another order took this amount since the read. Any other failure stops.
|
|
111
|
+
if (isUnique(e))
|
|
112
|
+
return false;
|
|
113
|
+
throw e;
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
async order(id) {
|
|
117
|
+
const r = await db.prepare(`SELECT ${COLS} FROM ${T.orders} WHERE id = ?`).bind(id).first();
|
|
118
|
+
return r ? orderOf(r) : null;
|
|
119
|
+
},
|
|
120
|
+
async openOrder(player, lookId, until) {
|
|
121
|
+
const r = await db
|
|
122
|
+
.prepare(`SELECT ${COLS} FROM ${T.orders} WHERE player = ? AND look_id = ? AND status = 'open' AND expires_at > ? ORDER BY created_at DESC LIMIT 1`)
|
|
123
|
+
.bind(player, lookId, until)
|
|
124
|
+
.first();
|
|
125
|
+
return r ? orderOf(r) : null;
|
|
126
|
+
},
|
|
127
|
+
async countOpen(player, now) {
|
|
128
|
+
const r = await db.prepare(`SELECT COUNT(*) AS n FROM ${T.orders} WHERE player = ? AND status = 'open' AND expires_at > ?`).bind(player, now).first();
|
|
129
|
+
return Number(r?.n ?? 0);
|
|
130
|
+
},
|
|
131
|
+
async owns(player, lookId) {
|
|
132
|
+
return Boolean(await db.prepare(`SELECT 1 AS one FROM ${T.owned} WHERE player = ? AND look_id = ?`).bind(player, lookId).first());
|
|
133
|
+
},
|
|
134
|
+
async owned(player) {
|
|
135
|
+
return (await db.prepare(`SELECT look_id FROM ${T.owned} WHERE player = ? ORDER BY granted_at, look_id`).bind(player).all()).results.map((r) => r.look_id);
|
|
136
|
+
},
|
|
137
|
+
async held() {
|
|
138
|
+
return (await db.prepare(`SELECT ${COLS} FROM ${T.orders} WHERE amount_lock IS NOT NULL`).all()).results.map(orderOf);
|
|
139
|
+
},
|
|
140
|
+
usedHashes: (hashes) => inChunks((m) => `SELECT hash FROM ${T.orders} WHERE hash IN (${m})`, hashes, "hash"),
|
|
141
|
+
async settle(order, pay, now) {
|
|
142
|
+
const status = paidStatus(order, pay.seenMs);
|
|
143
|
+
try {
|
|
144
|
+
const [update] = await db.batch([
|
|
145
|
+
db
|
|
146
|
+
.prepare(`UPDATE ${T.orders} SET status = ?, hash = ?, payer = ?, paid_at = ?, amount_lock = NULL WHERE id = ? AND status IN ('open', 'expired') AND amount_lock IS NOT NULL`)
|
|
147
|
+
.bind(status, pay.hash, pay.payer, pay.seenMs, order.id),
|
|
148
|
+
db
|
|
149
|
+
.prepare(`INSERT OR IGNORE INTO ${T.owned} (player, look_id, order_id, granted_at) SELECT player, look_id, id, ? FROM ${T.orders} WHERE id = ? AND status = 'paid' AND hash = ?`)
|
|
150
|
+
.bind(now, order.id, pay.hash),
|
|
151
|
+
]);
|
|
152
|
+
return changes(update) === 1 ? status : "taken";
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
if (isUnique(e))
|
|
156
|
+
return "used";
|
|
157
|
+
throw e;
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
async claimScan(now, gapMs) {
|
|
161
|
+
const r = await db
|
|
162
|
+
.prepare(`INSERT INTO ${T.meta} (key, value, at) VALUES ('scan', '', ?) ON CONFLICT (key) DO UPDATE SET at = excluded.at WHERE ${T.meta}.at <= ?`)
|
|
163
|
+
.bind(now, now - gapMs)
|
|
164
|
+
.run();
|
|
165
|
+
return changes(r) === 1;
|
|
166
|
+
},
|
|
167
|
+
async readRate() {
|
|
168
|
+
const r = await db.prepare(`SELECT value, at FROM ${T.meta} WHERE key = 'rate'`).first();
|
|
169
|
+
return r ? parseRate(r.value, Number(r.at)) : null;
|
|
170
|
+
},
|
|
171
|
+
async saveRate(rate) {
|
|
172
|
+
await db
|
|
173
|
+
.prepare(`INSERT INTO ${T.meta} (key, value, at) VALUES ('rate', ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at`)
|
|
174
|
+
.bind(JSON.stringify({ usd: rate.usd, sources: rate.sources }), rate.at)
|
|
175
|
+
.run();
|
|
176
|
+
},
|
|
177
|
+
async forget(player, now) {
|
|
178
|
+
await tidy(now);
|
|
179
|
+
const w = await waiting(player);
|
|
180
|
+
if (Number(w?.n ?? 0) > 0)
|
|
181
|
+
return { deleted: 0, kept: 0, waitUntil: Number(w.until) };
|
|
182
|
+
// One batch, and each statement holds the money gate again: an order made meanwhile stops every one of them.
|
|
183
|
+
const gate = `NOT EXISTS (SELECT 1 FROM ${T.orders} WHERE player = ?1 AND amount_lock IS NOT NULL)`;
|
|
184
|
+
const done = await db.batch([
|
|
185
|
+
db.prepare(`DELETE FROM ${T.owned} WHERE player = ?1 AND ${gate}`).bind(player),
|
|
186
|
+
db.prepare(`DELETE FROM ${T.orders} WHERE player = ?1 AND hash IS NULL AND amount_lock IS NULL AND ${gate}`).bind(player),
|
|
187
|
+
// A paid order keeps no payer; a paid-late one keeps it: the creator sends that payment back by hand.
|
|
188
|
+
db.prepare(`UPDATE ${T.orders} SET payer = ?2 WHERE payer = ?1 AND status = 'paid' AND ${gate}`).bind(player, FORGOTTEN),
|
|
189
|
+
db.prepare(`UPDATE ${T.orders} SET player = ?2, visitor = NULL WHERE player = ?1 AND hash IS NOT NULL AND ${gate}`).bind(player, FORGOTTEN),
|
|
190
|
+
]);
|
|
191
|
+
const [owned, orders, , kept] = done.map(changes);
|
|
192
|
+
if (owned + orders + kept === 0) {
|
|
193
|
+
const again = await waiting(player);
|
|
194
|
+
if (Number(again?.n ?? 0) > 0)
|
|
195
|
+
return { deleted: 0, kept: 0, waitUntil: Number(again.until) };
|
|
196
|
+
}
|
|
197
|
+
return { deleted: owned + orders, kept, waitUntil: null };
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ShopStore } from "../store.js";
|
|
2
|
+
export type ShopDocSnap = {
|
|
3
|
+
exists: boolean;
|
|
4
|
+
data(): Record<string, unknown> | undefined;
|
|
5
|
+
};
|
|
6
|
+
export interface ShopDocRef {
|
|
7
|
+
get(): Promise<ShopDocSnap>;
|
|
8
|
+
}
|
|
9
|
+
export type ShopQuerySnap = {
|
|
10
|
+
docs: {
|
|
11
|
+
id: string;
|
|
12
|
+
ref: ShopDocRef;
|
|
13
|
+
data(): Record<string, unknown>;
|
|
14
|
+
}[];
|
|
15
|
+
};
|
|
16
|
+
export interface ShopQuery {
|
|
17
|
+
where(field: string, op: "==" | "<", value: unknown): ShopQuery;
|
|
18
|
+
limit(n: number): ShopQuery;
|
|
19
|
+
get(): Promise<ShopQuerySnap>;
|
|
20
|
+
}
|
|
21
|
+
export interface ShopCollection extends ShopQuery {
|
|
22
|
+
doc(id: string): ShopDocRef;
|
|
23
|
+
}
|
|
24
|
+
/** A transaction: every read before the first write, as Firestore asks. */
|
|
25
|
+
export interface ShopTransaction {
|
|
26
|
+
get(ref: ShopDocRef): Promise<ShopDocSnap>;
|
|
27
|
+
set(ref: ShopDocRef, data: Record<string, unknown>, options?: {
|
|
28
|
+
merge?: boolean;
|
|
29
|
+
}): unknown;
|
|
30
|
+
delete(ref: ShopDocRef): unknown;
|
|
31
|
+
}
|
|
32
|
+
export interface ShopBatch {
|
|
33
|
+
set(ref: ShopDocRef, data: Record<string, unknown>, options?: {
|
|
34
|
+
merge?: boolean;
|
|
35
|
+
}): unknown;
|
|
36
|
+
delete(ref: ShopDocRef): unknown;
|
|
37
|
+
commit(): Promise<unknown>;
|
|
38
|
+
}
|
|
39
|
+
export interface FirestoreShopLike {
|
|
40
|
+
collection(name: string): ShopCollection;
|
|
41
|
+
runTransaction<T>(fn: (tx: ShopTransaction) => Promise<T>): Promise<T>;
|
|
42
|
+
batch(): ShopBatch;
|
|
43
|
+
}
|
|
44
|
+
export type ShopCollections = {
|
|
45
|
+
orders?: string;
|
|
46
|
+
owned?: string;
|
|
47
|
+
meta?: string;
|
|
48
|
+
amounts?: string;
|
|
49
|
+
hashes?: string;
|
|
50
|
+
};
|
|
51
|
+
/** The most documents one query reads: a small game's orders of one player, or its held amounts. */
|
|
52
|
+
export declare const FIRESTORE_SHOP_SCAN = 2000;
|
|
53
|
+
export declare function createFirestoreShop(db: FirestoreShopLike, collections?: ShopCollections): ShopStore;
|
|
@@ -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;
|