402-trinity-gaming 0.1.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.
@@ -0,0 +1,291 @@
1
+ import { createStorefront } from "./storefront.js";
2
+ import { signPurchase } from "./signer.js";
3
+ function createBatchManager(cfg) {
4
+ const sizes = Object.entries(cfg.tabs);
5
+ if (sizes.length === 0) throw new Error("batch manager: no tab sizes configured");
6
+ for (const [id, v] of sizes) {
7
+ if (!/^[0-9]+$/.test(v) || BigInt(v) <= 0n) {
8
+ throw new Error(`batch manager: tab '${id}' must be a positive integer in atomic units, got '${v}'`);
9
+ }
10
+ }
11
+ const store = createStorefront({ ...cfg, catalog: cfg.tabs });
12
+ const mem = /* @__PURE__ */ new Map();
13
+ const lowAt = cfg.lowWaterMark ?? 0.15;
14
+ const handlers = { opened: [], refunded: [], spent: [], low: [], exhausted: [] };
15
+ const emit = (n, e) => {
16
+ for (const h of handlers[n]) {
17
+ try {
18
+ h(e);
19
+ } catch (err) {
20
+ cfg.onDiagnostic?.({
21
+ code: "handler_threw",
22
+ message: `${n} handler threw: ${err instanceof Error ? err.message : String(err)}`
23
+ });
24
+ }
25
+ }
26
+ };
27
+ const read = async (playerId) => cfg.ledger ? cfg.ledger.get(playerId) : mem.get(playerId) ?? null;
28
+ const write = async (playerId, s) => {
29
+ if (cfg.ledger) await cfg.ledger.set(playerId, s);
30
+ else mem.set(playerId, s);
31
+ };
32
+ return {
33
+ /** Tab sizes on offer. */
34
+ get sizes() {
35
+ return sizes.map(([id]) => id);
36
+ },
37
+ /** What the client signs to open tab `tabId`. */
38
+ quote(tabId) {
39
+ return store.quote(tabId);
40
+ },
41
+ on(n, h) {
42
+ handlers[n].push(h);
43
+ return () => {
44
+ const i = handlers[n].indexOf(h);
45
+ if (i >= 0) handlers[n].splice(i, 1);
46
+ };
47
+ },
48
+ /**
49
+ * Open or top up a tab. This is the ONLY on-chain step - one settlement covering every
50
+ * action the player takes until the balance runs out.
51
+ *
52
+ * Credit is added only after the transfer settles. A declined payment adds nothing.
53
+ */
54
+ async open(req) {
55
+ const result = await store.purchase({
56
+ itemId: req.tabId,
57
+ playerId: req.playerId,
58
+ playerAddress: req.playerAddress,
59
+ authorization: req.authorization,
60
+ signature: req.signature
61
+ });
62
+ if (!("transaction" in result)) return { ok: false, declined: result };
63
+ const add = BigInt(result.amount);
64
+ const cur = await read(req.playerId);
65
+ const next = {
66
+ playerAddress: req.playerAddress,
67
+ remaining: String((cur ? BigInt(cur.remaining) : 0n) + add),
68
+ opened: String((cur ? BigInt(cur.opened) : 0n) + add),
69
+ spent: cur?.spent ?? [],
70
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
71
+ };
72
+ await write(req.playerId, next);
73
+ emit("opened", {
74
+ playerId: req.playerId,
75
+ playerAddress: req.playerAddress,
76
+ amount: result.amount,
77
+ transaction: result.transaction
78
+ });
79
+ return { ok: true, remaining: next.remaining, transaction: result.transaction };
80
+ },
81
+ /**
82
+ * Charge one micro-action. Synchronous in spirit - no signature, no chain, no network -
83
+ * so it is safe on a gameplay path.
84
+ *
85
+ * `actionId` makes it idempotent. A client that retries after a dropped response charges
86
+ * once, and the second call reports `duplicate: true` so you can grant without re-billing.
87
+ */
88
+ async spend(req) {
89
+ const { playerId, actionId, amount } = req;
90
+ if (!/^[0-9]+$/.test(amount) || BigInt(amount) <= 0n) {
91
+ return {
92
+ ok: false,
93
+ code: "invalid_amount",
94
+ remaining: "0",
95
+ message: `amount must be a positive integer in atomic units, got '${amount}'`
96
+ };
97
+ }
98
+ if (!await read(playerId)) {
99
+ return { ok: false, code: "no_tab", remaining: "0", message: "no open tab" };
100
+ }
101
+ let out = { ok: false, code: "no_tab", remaining: "0", message: "no open tab" };
102
+ const outcome = () => out;
103
+ let low = false, empty = false;
104
+ const step = (cur) => {
105
+ if (!cur) {
106
+ out = { ok: false, code: "no_tab", remaining: "0", message: "no open tab" };
107
+ return { playerAddress: "", remaining: "0", opened: "0", spent: [], updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
108
+ }
109
+ if (cur.spent.some((e) => e.id === actionId)) {
110
+ out = { ok: true, remaining: cur.remaining, charged: "0", duplicate: true };
111
+ return cur;
112
+ }
113
+ const rem = BigInt(cur.remaining), amt = BigInt(amount);
114
+ if (rem < amt) {
115
+ out = {
116
+ ok: false,
117
+ code: "insufficient",
118
+ remaining: cur.remaining,
119
+ message: `tab has ${cur.remaining}, action costs ${amount}`
120
+ };
121
+ return cur;
122
+ }
123
+ const left = rem - amt;
124
+ out = { ok: true, remaining: String(left), charged: amount, duplicate: false };
125
+ low = left > 0n && Number(left) < Number(BigInt(cur.opened)) * lowAt;
126
+ empty = left === 0n;
127
+ return {
128
+ ...cur,
129
+ remaining: String(left),
130
+ // Bounded by age, not by count. The ledger store prunes on write as well; this
131
+ // keeps the in-memory path from growing without bound over a long session.
132
+ spent: [...cur.spent.filter((e) => e.at > Date.now() - 864e5), { id: actionId, at: Date.now() }],
133
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
134
+ };
135
+ };
136
+ if (cfg.ledger?.update) await cfg.ledger.update(playerId, step);
137
+ else {
138
+ const cur = await read(playerId);
139
+ const next = step(cur);
140
+ if (cur) await write(playerId, next);
141
+ }
142
+ const res = outcome();
143
+ if (res.ok && !res.duplicate) {
144
+ emit("spent", { playerId, actionId, amount, remaining: res.remaining });
145
+ const st = await read(playerId);
146
+ if (low && st) emit("low", { playerId, remaining: st.remaining, opened: st.opened });
147
+ if (empty) emit("exhausted", { playerId });
148
+ }
149
+ return res;
150
+ },
151
+ /**
152
+ * Return unspent credit to the player's wallet.
153
+ *
154
+ * The studio signs an authorization to the player and the facilitator submits it, so the
155
+ * studio needs no gas - the same shape as every other transfer here, just pointing the
156
+ * other way. Requires `surcharge.proceedsKey`, because that is the key for the wallet
157
+ * holding the money.
158
+ *
159
+ * ORDER MATTERS. The credit is deducted BEFORE the transfer is attempted, so it cannot be
160
+ * spent while the refund is in flight, and restored if the transfer fails. The opposite
161
+ * order lets a player spend the same money twice - once in-game and once on-chain.
162
+ *
163
+ * The 0.1% taken when the tab opened is NOT reversed. It was charged on a sale that did
164
+ * happen, and clawing it back out of the vault is not something this can do.
165
+ */
166
+ async refund(req) {
167
+ const { playerId } = req;
168
+ const key = cfg.surcharge?.proceedsKey;
169
+ if (!key) {
170
+ return {
171
+ ok: false,
172
+ code: "no_key",
173
+ remaining: "0",
174
+ message: "refunds need surcharge.proceedsKey - the key for the payTo wallet"
175
+ };
176
+ }
177
+ const cur = await read(playerId);
178
+ if (!cur) return { ok: false, code: "no_tab", remaining: "0", message: "no open tab" };
179
+ const rem = BigInt(cur.remaining);
180
+ if (rem === 0n) {
181
+ return { ok: false, code: "nothing_to_refund", remaining: "0", message: "tab is empty" };
182
+ }
183
+ const amount = req.amount ?? cur.remaining;
184
+ if (!/^[0-9]+$/.test(amount) || BigInt(amount) <= 0n) {
185
+ return {
186
+ ok: false,
187
+ code: "too_much",
188
+ remaining: cur.remaining,
189
+ message: `amount must be a positive integer in atomic units, got '${amount}'`
190
+ };
191
+ }
192
+ if (BigInt(amount) > rem) {
193
+ return {
194
+ ok: false,
195
+ code: "too_much",
196
+ remaining: cur.remaining,
197
+ message: `tab has ${cur.remaining}, cannot refund ${amount}`
198
+ };
199
+ }
200
+ const reserved = {
201
+ ...cur,
202
+ remaining: String(rem - BigInt(amount)),
203
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
204
+ };
205
+ if (cfg.ledger?.update) await cfg.ledger.update(playerId, () => reserved);
206
+ else await write(playerId, reserved);
207
+ const restore = async () => {
208
+ const now = await read(playerId);
209
+ const back = {
210
+ ...now ?? reserved,
211
+ remaining: String(BigInt(now?.remaining ?? reserved.remaining) + BigInt(amount)),
212
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
213
+ };
214
+ if (cfg.ledger?.update) await cfg.ledger.update(playerId, () => back);
215
+ else await write(playerId, back);
216
+ };
217
+ try {
218
+ const quote = { ...store.quote(sizes[0][0]), amount, payTo: cur.playerAddress };
219
+ const signed = signPurchase(quote, key);
220
+ const r = await fetch(cfg.facilitator.replace(/\/$/, "") + "/settle", {
221
+ method: "POST",
222
+ headers: { "content-type": "application/json" },
223
+ body: JSON.stringify({
224
+ x402Version: 2,
225
+ paymentPayload: {
226
+ x402Version: 2,
227
+ scheme: "exact",
228
+ network: quote.network,
229
+ payload: { authorization: signed.authorization, signature: signed.signature }
230
+ },
231
+ paymentRequirements: {
232
+ scheme: "exact",
233
+ network: quote.network,
234
+ payTo: cur.playerAddress,
235
+ asset: quote.asset,
236
+ amount,
237
+ maxAmountRequired: amount,
238
+ maxTimeoutSeconds: 300,
239
+ extra: quote.extra
240
+ }
241
+ })
242
+ });
243
+ const body = r.ok ? await r.json().catch(() => null) : null;
244
+ if (body?.success !== true) {
245
+ await restore();
246
+ const why = body?.errorReason ?? `facilitator returned ${r.status}`;
247
+ cfg.onDiagnostic?.({ code: "refund_failed", message: String(why) });
248
+ return {
249
+ ok: false,
250
+ code: "failed",
251
+ remaining: cur.remaining,
252
+ message: `refund not settled: ${why} - the credit was returned to the tab`
253
+ };
254
+ }
255
+ const after = await read(playerId);
256
+ emit("refunded", {
257
+ playerId,
258
+ playerAddress: cur.playerAddress,
259
+ amount,
260
+ transaction: body.transaction
261
+ });
262
+ return {
263
+ ok: true,
264
+ refunded: amount,
265
+ remaining: after?.remaining ?? reserved.remaining,
266
+ transaction: body.transaction
267
+ };
268
+ } catch (err) {
269
+ await restore();
270
+ const message = err instanceof Error ? err.message : String(err);
271
+ cfg.onDiagnostic?.({ code: "refund_error", message });
272
+ return {
273
+ ok: false,
274
+ code: "failed",
275
+ remaining: cur.remaining,
276
+ message: `${message} - the credit was returned to the tab`
277
+ };
278
+ }
279
+ },
280
+ /** What the player has left. Read-only. */
281
+ async balance(playerId) {
282
+ const s = await read(playerId);
283
+ return s ? { remaining: s.remaining, opened: s.opened } : null;
284
+ },
285
+ /** The network fee taken from proceeds when a tab is opened. */
286
+ fee: store.fee
287
+ };
288
+ }
289
+ export {
290
+ createBatchManager
291
+ };
@@ -0,0 +1 @@
1
+ import{createStorefront as x}from"./storefront.js";import{signPurchase as v}from"./signer.js";function $(r){const y=Object.entries(r.tabs);if(y.length===0)throw new Error("batch manager: no tab sizes configured");for(const[t,e]of y)if(!/^[0-9]+$/.test(e)||BigInt(e)<=0n)throw new Error(`batch manager: tab '${t}' must be a positive integer in atomic units, got '${e}'`);const b=x({...r,catalog:r.tabs}),k=new Map,A=r.lowWaterMark??.15,h={opened:[],refunded:[],spent:[],low:[],exhausted:[]},p=(t,e)=>{for(const s of h[t])try{s(e)}catch(n){r.onDiagnostic?.({code:"handler_threw",message:`${t} handler threw: ${n instanceof Error?n.message:String(n)}`})}},m=async t=>r.ledger?r.ledger.get(t):k.get(t)??null,w=async(t,e)=>{r.ledger?await r.ledger.set(t,e):k.set(t,e)};return{get sizes(){return y.map(([t])=>t)},quote(t){return b.quote(t)},on(t,e){return h[t].push(e),()=>{const s=h[t].indexOf(e);s>=0&&h[t].splice(s,1)}},async open(t){const e=await b.purchase({itemId:t.tabId,playerId:t.playerId,playerAddress:t.playerAddress,authorization:t.authorization,signature:t.signature});if(!("transaction"in e))return{ok:!1,declined:e};const s=BigInt(e.amount),n=await m(t.playerId),g={playerAddress:t.playerAddress,remaining:String((n?BigInt(n.remaining):0n)+s),opened:String((n?BigInt(n.opened):0n)+s),spent:n?.spent??[],updatedAt:new Date().toISOString()};return await w(t.playerId,g),p("opened",{playerId:t.playerId,playerAddress:t.playerAddress,amount:e.amount,transaction:e.transaction}),{ok:!0,remaining:g.remaining,transaction:e.transaction}},async spend(t){const{playerId:e,actionId:s,amount:n}=t;if(!/^[0-9]+$/.test(n)||BigInt(n)<=0n)return{ok:!1,code:"invalid_amount",remaining:"0",message:`amount must be a positive integer in atomic units, got '${n}'`};if(!await m(e))return{ok:!1,code:"no_tab",remaining:"0",message:"no open tab"};let g={ok:!1,code:"no_tab",remaining:"0",message:"no open tab"};const o=()=>g;let l=!1,f=!1;const i=a=>{if(!a)return g={ok:!1,code:"no_tab",remaining:"0",message:"no open tab"},{playerAddress:"",remaining:"0",opened:"0",spent:[],updatedAt:new Date().toISOString()};if(a.spent.some(I=>I.id===s))return g={ok:!0,remaining:a.remaining,charged:"0",duplicate:!0},a;const u=BigInt(a.remaining),S=BigInt(n);if(u<S)return g={ok:!1,code:"insufficient",remaining:a.remaining,message:`tab has ${a.remaining}, action costs ${n}`},a;const c=u-S;return g={ok:!0,remaining:String(c),charged:n,duplicate:!1},l=c>0n&&Number(c)<Number(BigInt(a.opened))*A,f=c===0n,{...a,remaining:String(c),spent:[...a.spent.filter(I=>I.at>Date.now()-864e5),{id:s,at:Date.now()}],updatedAt:new Date().toISOString()}};if(r.ledger?.update)await r.ledger.update(e,i);else{const a=await m(e),u=i(a);a&&await w(e,u)}const d=o();if(d.ok&&!d.duplicate){p("spent",{playerId:e,actionId:s,amount:n,remaining:d.remaining});const a=await m(e);l&&a&&p("low",{playerId:e,remaining:a.remaining,opened:a.opened}),f&&p("exhausted",{playerId:e})}return d},async refund(t){const{playerId:e}=t,s=r.surcharge?.proceedsKey;if(!s)return{ok:!1,code:"no_key",remaining:"0",message:"refunds need surcharge.proceedsKey - the key for the payTo wallet"};const n=await m(e);if(!n)return{ok:!1,code:"no_tab",remaining:"0",message:"no open tab"};const g=BigInt(n.remaining);if(g===0n)return{ok:!1,code:"nothing_to_refund",remaining:"0",message:"tab is empty"};const o=t.amount??n.remaining;if(!/^[0-9]+$/.test(o)||BigInt(o)<=0n)return{ok:!1,code:"too_much",remaining:n.remaining,message:`amount must be a positive integer in atomic units, got '${o}'`};if(BigInt(o)>g)return{ok:!1,code:"too_much",remaining:n.remaining,message:`tab has ${n.remaining}, cannot refund ${o}`};const l={...n,remaining:String(g-BigInt(o)),updatedAt:new Date().toISOString()};r.ledger?.update?await r.ledger.update(e,()=>l):await w(e,l);const f=async()=>{const i=await m(e),d={...i??l,remaining:String(BigInt(i?.remaining??l.remaining)+BigInt(o)),updatedAt:new Date().toISOString()};r.ledger?.update?await r.ledger.update(e,()=>d):await w(e,d)};try{const i={...b.quote(y[0][0]),amount:o,payTo:n.playerAddress},d=v(i,s),a=await fetch(r.facilitator.replace(/\/$/,"")+"/settle",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({x402Version:2,paymentPayload:{x402Version:2,scheme:"exact",network:i.network,payload:{authorization:d.authorization,signature:d.signature}},paymentRequirements:{scheme:"exact",network:i.network,payTo:n.playerAddress,asset:i.asset,amount:o,maxAmountRequired:o,maxTimeoutSeconds:300,extra:i.extra}})}),u=a.ok?await a.json().catch(()=>null):null;if(u?.success!==!0){await f();const c=u?.errorReason??`facilitator returned ${a.status}`;return r.onDiagnostic?.({code:"refund_failed",message:String(c)}),{ok:!1,code:"failed",remaining:n.remaining,message:`refund not settled: ${c} - the credit was returned to the tab`}}const S=await m(e);return p("refunded",{playerId:e,playerAddress:n.playerAddress,amount:o,transaction:u.transaction}),{ok:!0,refunded:o,remaining:S?.remaining??l.remaining,transaction:u.transaction}}catch(i){await f();const d=i instanceof Error?i.message:String(i);return r.onDiagnostic?.({code:"refund_error",message:d}),{ok:!1,code:"failed",remaining:n.remaining,message:`${d} - the credit was returned to the tab`}}},async balance(t){const e=await m(t);return e?{remaining:e.remaining,opened:e.opened}:null},fee:b.fee}}export{$ as createBatchManager};
@@ -0,0 +1,106 @@
1
+ export interface BudgetStore {
2
+ reserve: (amount: bigint, totalBudget: bigint) => Promise<boolean>;
3
+ release?: (amount: bigint) => Promise<void>;
4
+ /** Current spend, for reporting. */
5
+ spent: () => bigint;
6
+ }
7
+ export declare function createFileBudgetStore(path: string, opts?: {
8
+ lockTimeoutMs?: number;
9
+ }): BudgetStore;
10
+ /**
11
+ * File-backed fee tally. Node-only, same atomic-rename pattern. `accrued` is ATOMIC x 1e6
12
+ * so sub-unit fees are not rounded away; `count` is payments since the last settlement.
13
+ */
14
+ export declare function createFileFeeStore(path: string, opts?: {
15
+ lockTimeoutMs?: number;
16
+ }): {
17
+ get: () => Promise<{
18
+ accrued: bigint;
19
+ count: bigint;
20
+ }>;
21
+ set: (v: {
22
+ accrued: bigint;
23
+ count: bigint;
24
+ }) => Promise<void>;
25
+ /**
26
+ * Read, modify and write while HOLDING the lock. Separate get/set calls each take the
27
+ * lock and release it, so two processes can both read the same count and both write
28
+ * count+1 - one increment vanishes. Measured at 82% loss with eight concurrent writers,
29
+ * which shows up as the fee firing far less often than it is owed.
30
+ */
31
+ update: (fn: (cur: {
32
+ accrued: bigint;
33
+ count: bigint;
34
+ }) => {
35
+ accrued: bigint;
36
+ count: bigint;
37
+ }) => Promise<{
38
+ accrued: bigint;
39
+ count: bigint;
40
+ }>;
41
+ };
42
+ /**
43
+ * File-backed collector queue. Node-only. Keeps pending authorizations and the set of
44
+ * nonces ever seen, so a replay is rejected even across restarts.
45
+ */
46
+ export declare function createFileCollectorStore(path: string): {
47
+ pending: () => Promise<any>;
48
+ add: (item: any) => Promise<void>;
49
+ remove: (nonces: string[]) => Promise<void>;
50
+ seen: (nonce: string) => Promise<any>;
51
+ markSeen: (nonce: string) => Promise<void>;
52
+ };
53
+ /**
54
+ * File-backed replay guard for the seller. Node-only.
55
+ *
56
+ * Remembers each settled authorization nonce only until it expires - after `validBefore`
57
+ * the authorization can never settle again, so the entry is prunable. Without that, a
58
+ * long-running seller's guard grows forever.
59
+ */
60
+ export declare function createFileNonceStore(path: string): {
61
+ seen: (nonce: string) => Promise<boolean>;
62
+ add: (nonce: string, expiresAtUnix: number) => Promise<void>;
63
+ /** Entries currently held (after pruning expired ones). */
64
+ size: () => number;
65
+ };
66
+ /**
67
+ * File-backed tab ledger. Node-only, same atomic-rename and lock pattern as the fee tally.
68
+ *
69
+ * WHY THIS EXISTS. A tab is money a player has already paid. Holding that balance only in
70
+ * process memory means a deploy, a crash or a scale-down silently deletes credit somebody
71
+ * bought - and unlike a failed payment, nothing fails loudly when it happens.
72
+ *
73
+ * Single-instance only. If more than one game server can serve the same player, back the
74
+ * ledger with your database instead and hold a row lock across the read and the write - the
75
+ * `update` contract below is what that lock has to protect.
76
+ */
77
+ export declare function createFileLedgerStore(path: string, opts?: {
78
+ lockTimeoutMs?: number;
79
+ }): {
80
+ get: (playerId: string) => Promise<{
81
+ playerAddress: string;
82
+ remaining: string;
83
+ opened: string;
84
+ spent: Array<{
85
+ id: string;
86
+ at: number;
87
+ }>;
88
+ updatedAt: string;
89
+ }>;
90
+ set: (playerId: string, s: any) => Promise<void>;
91
+ /**
92
+ * Read, modify and write while HOLDING the lock. Separate get/set calls each take and
93
+ * release it, so two writers can both read the same balance and both approve a spend it
94
+ * could only cover once.
95
+ */
96
+ update: (playerId: string, fn: (cur: any) => any) => Promise<{
97
+ playerAddress: string;
98
+ remaining: string;
99
+ opened: string;
100
+ spent: Array<{
101
+ id: string;
102
+ at: number;
103
+ }>;
104
+ updatedAt: string;
105
+ }>;
106
+ };
@@ -0,0 +1,270 @@
1
+ import { openSync, closeSync, unlinkSync, readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
2
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
3
+ function createFileBudgetStore(path, opts = {}) {
4
+ const lockPath = path + ".lock";
5
+ const timeout = opts.lockTimeoutMs ?? 5e3;
6
+ const read = () => {
7
+ if (!existsSync(path)) return 0n;
8
+ try {
9
+ const j = JSON.parse(readFileSync(path, "utf8"));
10
+ const v = BigInt(j.spent ?? "0");
11
+ return v < 0n ? 0n : v;
12
+ } catch {
13
+ throw new Error(`x402 budget file is unreadable: ${path}. Refusing to treat it as zero spend.`);
14
+ }
15
+ };
16
+ const write = (v) => {
17
+ const tmp = path + ".tmp";
18
+ writeFileSync(tmp, JSON.stringify({ spent: v.toString(), updated: (/* @__PURE__ */ new Date()).toISOString() }));
19
+ renameSync(tmp, path);
20
+ };
21
+ async function withLock(fn) {
22
+ const deadline = Date.now() + timeout;
23
+ let fd;
24
+ for (; ; ) {
25
+ try {
26
+ fd = openSync(lockPath, "wx");
27
+ break;
28
+ } catch {
29
+ if (Date.now() > deadline) throw new Error(`x402 budget lock timed out: ${lockPath}`);
30
+ await sleep(15);
31
+ }
32
+ }
33
+ try {
34
+ return fn();
35
+ } finally {
36
+ try {
37
+ closeSync(fd);
38
+ } catch {
39
+ }
40
+ try {
41
+ unlinkSync(lockPath);
42
+ } catch {
43
+ }
44
+ }
45
+ }
46
+ return {
47
+ /** Atomic check-and-increment. Returns false if the payment would exceed the budget. */
48
+ reserve: (amount, totalBudget) => withLock(() => {
49
+ const now = read();
50
+ if (now + amount > totalBudget) return false;
51
+ write(now + amount);
52
+ return true;
53
+ }),
54
+ release: (amount) => withLock(() => {
55
+ const now = read();
56
+ write(now - amount < 0n ? 0n : now - amount);
57
+ }),
58
+ spent: () => read()
59
+ };
60
+ }
61
+ function createFileFeeStore(path, opts = {}) {
62
+ const lockPath = path + ".lock";
63
+ const timeout = opts.lockTimeoutMs ?? 5e3;
64
+ const read = () => {
65
+ if (!existsSync(path)) return { accrued: 0n, count: 0n };
66
+ try {
67
+ const j = JSON.parse(readFileSync(path, "utf8"));
68
+ return { accrued: BigInt(j.accruedScaled ?? "0"), count: BigInt(j.count ?? "0") };
69
+ } catch {
70
+ throw new Error(`x402 fee tally is unreadable: ${path}. Refusing to treat it as zero.`);
71
+ }
72
+ };
73
+ const write = (v) => {
74
+ const tmp = path + ".tmp";
75
+ writeFileSync(tmp, JSON.stringify({
76
+ accruedScaled: v.accrued.toString(),
77
+ count: v.count.toString(),
78
+ updated: (/* @__PURE__ */ new Date()).toISOString()
79
+ }));
80
+ renameSync(tmp, path);
81
+ };
82
+ async function withLock(fn) {
83
+ const deadline = Date.now() + timeout;
84
+ let fd;
85
+ for (; ; ) {
86
+ try {
87
+ fd = openSync(lockPath, "wx");
88
+ break;
89
+ } catch {
90
+ if (Date.now() > deadline) throw new Error(`x402 fee lock timed out: ${lockPath}`);
91
+ await sleep(15);
92
+ }
93
+ }
94
+ try {
95
+ return fn();
96
+ } finally {
97
+ try {
98
+ closeSync(fd);
99
+ } catch {
100
+ }
101
+ try {
102
+ unlinkSync(lockPath);
103
+ } catch {
104
+ }
105
+ }
106
+ }
107
+ return {
108
+ get: async () => withLock(read),
109
+ set: async (v) => {
110
+ await withLock(() => write(v));
111
+ },
112
+ /**
113
+ * Read, modify and write while HOLDING the lock. Separate get/set calls each take the
114
+ * lock and release it, so two processes can both read the same count and both write
115
+ * count+1 - one increment vanishes. Measured at 82% loss with eight concurrent writers,
116
+ * which shows up as the fee firing far less often than it is owed.
117
+ */
118
+ update: async (fn) => withLock(() => {
119
+ const next = fn(read());
120
+ write(next);
121
+ return next;
122
+ })
123
+ };
124
+ }
125
+ function createFileCollectorStore(path) {
126
+ const read = () => {
127
+ if (!existsSync(path)) return { pending: [], seen: [] };
128
+ try {
129
+ return JSON.parse(readFileSync(path, "utf8"));
130
+ } catch {
131
+ throw new Error(`x402 collector store is unreadable: ${path}. Refusing to treat it as empty.`);
132
+ }
133
+ };
134
+ const write = (d) => {
135
+ const tmp = path + ".tmp";
136
+ writeFileSync(tmp, JSON.stringify(d));
137
+ renameSync(tmp, path);
138
+ };
139
+ return {
140
+ pending: async () => read().pending ?? [],
141
+ add: async (item) => {
142
+ const d = read();
143
+ d.pending = [...d.pending ?? [], item];
144
+ write(d);
145
+ },
146
+ remove: async (nonces) => {
147
+ const d = read();
148
+ const drop = new Set(nonces.map((n) => n.toLowerCase()));
149
+ d.pending = (d.pending ?? []).filter((i) => !drop.has(i.authorization.nonce.toLowerCase()));
150
+ write(d);
151
+ },
152
+ seen: async (nonce) => (read().seen ?? []).includes(nonce.toLowerCase()),
153
+ markSeen: async (nonce) => {
154
+ const d = read();
155
+ d.seen = [...d.seen ?? [], nonce.toLowerCase()];
156
+ write(d);
157
+ }
158
+ };
159
+ }
160
+ function createFileNonceStore(path) {
161
+ const read = () => {
162
+ if (!existsSync(path)) return {};
163
+ try {
164
+ return JSON.parse(readFileSync(path, "utf8")).nonces ?? {};
165
+ } catch {
166
+ throw new Error(`x402 nonce store is unreadable: ${path}. Refusing to treat it as empty.`);
167
+ }
168
+ };
169
+ const write = (n) => {
170
+ const tmp = path + ".tmp";
171
+ writeFileSync(tmp, JSON.stringify({ nonces: n, updated: (/* @__PURE__ */ new Date()).toISOString() }));
172
+ renameSync(tmp, path);
173
+ };
174
+ return {
175
+ seen: async (nonce) => {
176
+ const now = Math.floor(Date.now() / 1e3);
177
+ const exp = read()[nonce.toLowerCase()];
178
+ return exp !== void 0 && exp > now;
179
+ },
180
+ add: async (nonce, expiresAtUnix) => {
181
+ const now = Math.floor(Date.now() / 1e3);
182
+ const n = read();
183
+ for (const [k, exp] of Object.entries(n)) if (exp <= now) delete n[k];
184
+ n[nonce.toLowerCase()] = expiresAtUnix;
185
+ write(n);
186
+ },
187
+ /** Entries currently held (after pruning expired ones). */
188
+ size: () => {
189
+ const now = Math.floor(Date.now() / 1e3);
190
+ return Object.values(read()).filter((e) => e > now).length;
191
+ }
192
+ };
193
+ }
194
+ function createFileLedgerStore(path, opts = {}) {
195
+ const lockPath = path + ".lock";
196
+ const timeout = opts.lockTimeoutMs ?? 5e3;
197
+ const readAll = () => {
198
+ if (!existsSync(path)) return {};
199
+ try {
200
+ return JSON.parse(readFileSync(path, "utf8"));
201
+ } catch {
202
+ throw new Error(`x402 tab ledger unreadable: ${path} - fix or remove it deliberately`);
203
+ }
204
+ };
205
+ const writeAll = (all) => {
206
+ const tmp = path + ".tmp";
207
+ writeFileSync(tmp, JSON.stringify(all));
208
+ renameSync(tmp, path);
209
+ };
210
+ async function withLock(fn) {
211
+ const deadline = Date.now() + timeout;
212
+ let fd;
213
+ for (; ; ) {
214
+ try {
215
+ fd = openSync(lockPath, "wx");
216
+ break;
217
+ } catch {
218
+ if (Date.now() > deadline) throw new Error(`x402 tab ledger lock timed out: ${lockPath}`);
219
+ await sleep(15);
220
+ }
221
+ }
222
+ try {
223
+ return fn();
224
+ } finally {
225
+ try {
226
+ closeSync(fd);
227
+ } catch {
228
+ }
229
+ try {
230
+ unlinkSync(lockPath);
231
+ } catch {
232
+ }
233
+ }
234
+ }
235
+ const KEEP_MS = 24 * 60 * 60 * 1e3;
236
+ const prune = (spent) => {
237
+ const cutoff = Date.now() - KEEP_MS;
238
+ const kept = spent.filter((e) => e.at > cutoff);
239
+ return kept.length > 5e3 ? kept.slice(-5e3) : kept;
240
+ };
241
+ return {
242
+ get: async (playerId) => withLock(() => readAll()[playerId] ?? null),
243
+ set: async (playerId, s) => {
244
+ await withLock(() => {
245
+ const all = readAll();
246
+ all[playerId] = { ...s, spent: prune(s.spent ?? []) };
247
+ writeAll(all);
248
+ });
249
+ },
250
+ /**
251
+ * Read, modify and write while HOLDING the lock. Separate get/set calls each take and
252
+ * release it, so two writers can both read the same balance and both approve a spend it
253
+ * could only cover once.
254
+ */
255
+ update: async (playerId, fn) => withLock(() => {
256
+ const all = readAll();
257
+ const next = fn(all[playerId] ?? null);
258
+ all[playerId] = { ...next, spent: prune(next.spent ?? []) };
259
+ writeAll(all);
260
+ return all[playerId];
261
+ })
262
+ };
263
+ }
264
+ export {
265
+ createFileBudgetStore,
266
+ createFileCollectorStore,
267
+ createFileFeeStore,
268
+ createFileLedgerStore,
269
+ createFileNonceStore
270
+ };
@@ -0,0 +1 @@
1
+ import{openSync as b,closeSync as S,unlinkSync as p,readFileSync as g,writeFileSync as f,renameSync as w,existsSync as y}from"node:fs";const k=t=>new Promise(s=>setTimeout(s,t));function T(t,s={}){const a=t+".lock",o=s.lockTimeoutMs??5e3,e=()=>{if(!y(t))return 0n;try{const n=JSON.parse(g(t,"utf8")),r=BigInt(n.spent??"0");return r<0n?0n:r}catch{throw new Error(`x402 budget file is unreadable: ${t}. Refusing to treat it as zero spend.`)}},u=n=>{const r=t+".tmp";f(r,JSON.stringify({spent:n.toString(),updated:new Date().toISOString()})),w(r,t)};async function c(n){const r=Date.now()+o;let i;for(;;)try{i=b(a,"wx");break}catch{if(Date.now()>r)throw new Error(`x402 budget lock timed out: ${a}`);await k(15)}try{return n()}finally{try{S(i)}catch{}try{p(a)}catch{}}}return{reserve:(n,r)=>c(()=>{const i=e();return i+n>r?!1:(u(i+n),!0)}),release:n=>c(()=>{const r=e();u(r-n<0n?0n:r-n)}),spent:()=>e()}}function O(t,s={}){const a=t+".lock",o=s.lockTimeoutMs??5e3,e=()=>{if(!y(t))return{accrued:0n,count:0n};try{const n=JSON.parse(g(t,"utf8"));return{accrued:BigInt(n.accruedScaled??"0"),count:BigInt(n.count??"0")}}catch{throw new Error(`x402 fee tally is unreadable: ${t}. Refusing to treat it as zero.`)}},u=n=>{const r=t+".tmp";f(r,JSON.stringify({accruedScaled:n.accrued.toString(),count:n.count.toString(),updated:new Date().toISOString()})),w(r,t)};async function c(n){const r=Date.now()+o;let i;for(;;)try{i=b(a,"wx");break}catch{if(Date.now()>r)throw new Error(`x402 fee lock timed out: ${a}`);await k(15)}try{return n()}finally{try{S(i)}catch{}try{p(a)}catch{}}}return{get:async()=>c(e),set:async n=>{await c(()=>u(n))},update:async n=>c(()=>{const r=n(e());return u(r),r})}}function D(t){const s=()=>{if(!y(t))return{pending:[],seen:[]};try{return JSON.parse(g(t,"utf8"))}catch{throw new Error(`x402 collector store is unreadable: ${t}. Refusing to treat it as empty.`)}},a=o=>{const e=t+".tmp";f(e,JSON.stringify(o)),w(e,t)};return{pending:async()=>s().pending??[],add:async o=>{const e=s();e.pending=[...e.pending??[],o],a(e)},remove:async o=>{const e=s(),u=new Set(o.map(c=>c.toLowerCase()));e.pending=(e.pending??[]).filter(c=>!u.has(c.authorization.nonce.toLowerCase())),a(e)},seen:async o=>(s().seen??[]).includes(o.toLowerCase()),markSeen:async o=>{const e=s();e.seen=[...e.seen??[],o.toLowerCase()],a(e)}}}function h(t){const s=()=>{if(!y(t))return{};try{return JSON.parse(g(t,"utf8")).nonces??{}}catch{throw new Error(`x402 nonce store is unreadable: ${t}. Refusing to treat it as empty.`)}},a=o=>{const e=t+".tmp";f(e,JSON.stringify({nonces:o,updated:new Date().toISOString()})),w(e,t)};return{seen:async o=>{const e=Math.floor(Date.now()/1e3),u=s()[o.toLowerCase()];return u!==void 0&&u>e},add:async(o,e)=>{const u=Math.floor(Date.now()/1e3),c=s();for(const[n,r]of Object.entries(c))r<=u&&delete c[n];c[o.toLowerCase()]=e,a(c)},size:()=>{const o=Math.floor(Date.now()/1e3);return Object.values(s()).filter(e=>e>o).length}}}function P(t,s={}){const a=t+".lock",o=s.lockTimeoutMs??5e3,e=()=>{if(!y(t))return{};try{return JSON.parse(g(t,"utf8"))}catch{throw new Error(`x402 tab ledger unreadable: ${t} - fix or remove it deliberately`)}},u=i=>{const l=t+".tmp";f(l,JSON.stringify(i)),w(l,t)};async function c(i){const l=Date.now()+o;let d;for(;;)try{d=b(a,"wx");break}catch{if(Date.now()>l)throw new Error(`x402 tab ledger lock timed out: ${a}`);await k(15)}try{return i()}finally{try{S(d)}catch{}try{p(a)}catch{}}}const n=1440*60*1e3,r=i=>{const l=Date.now()-n,d=i.filter(m=>m.at>l);return d.length>5e3?d.slice(-5e3):d};return{get:async i=>c(()=>e()[i]??null),set:async(i,l)=>{await c(()=>{const d=e();d[i]={...l,spent:r(l.spent??[])},u(d)})},update:async(i,l)=>c(()=>{const d=e(),m=l(d[i]??null);return d[i]={...m,spent:r(m.spent??[])},u(d),d[i]})}}export{T as createFileBudgetStore,D as createFileCollectorStore,O as createFileFeeStore,P as createFileLedgerStore,h as createFileNonceStore};