@aifinpay/agent 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/PAYMENT_RECEIPTS.md +194 -19
  2. package/README.md +58 -27
  3. package/dist/agentHistory.d.ts +9 -0
  4. package/dist/agentHistory.d.ts.map +1 -1
  5. package/dist/agentHistory.js +110 -16
  6. package/dist/agentHistory.js.map +1 -1
  7. package/dist/aifp1.d.ts +124 -17
  8. package/dist/aifp1.d.ts.map +1 -1
  9. package/dist/aifp1.js +580 -82
  10. package/dist/aifp1.js.map +1 -1
  11. package/dist/deploymentErrors.d.ts +5 -0
  12. package/dist/deploymentErrors.d.ts.map +1 -0
  13. package/dist/deploymentErrors.js +8 -0
  14. package/dist/deploymentErrors.js.map +1 -0
  15. package/dist/deploymentResolver.d.ts +2 -3
  16. package/dist/deploymentResolver.d.ts.map +1 -1
  17. package/dist/deploymentResolver.js +2 -6
  18. package/dist/deploymentResolver.js.map +1 -1
  19. package/dist/facilitators/index.d.ts +1 -1
  20. package/dist/facilitators/index.d.ts.map +1 -1
  21. package/dist/generated/solanaV14Deployments.generated.d.ts +1 -0
  22. package/dist/generated/solanaV14Deployments.generated.d.ts.map +1 -1
  23. package/dist/generated/solanaV14Deployments.generated.js +4 -2
  24. package/dist/generated/solanaV14Deployments.generated.js.map +1 -1
  25. package/dist/index.d.ts +10 -4
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +12 -3
  28. package/dist/index.js.map +1 -1
  29. package/dist/paymentChains.d.ts +95 -0
  30. package/dist/paymentChains.d.ts.map +1 -0
  31. package/dist/paymentChains.js +116 -0
  32. package/dist/paymentChains.js.map +1 -0
  33. package/dist/settlement.d.ts +5 -5
  34. package/dist/settlementSolanaV14.d.ts +135 -0
  35. package/dist/settlementSolanaV14.d.ts.map +1 -0
  36. package/dist/settlementSolanaV14.js +733 -0
  37. package/dist/settlementSolanaV14.js.map +1 -0
  38. package/dist/settlementV14.d.ts +9 -2
  39. package/dist/settlementV14.d.ts.map +1 -1
  40. package/dist/settlementV14.js +81 -14
  41. package/dist/settlementV14.js.map +1 -1
  42. package/dist/solanaDeploymentResolver.d.ts +2 -1
  43. package/dist/solanaDeploymentResolver.d.ts.map +1 -1
  44. package/dist/solanaDeploymentResolver.js +1 -32
  45. package/dist/solanaDeploymentResolver.js.map +1 -1
  46. package/dist/spendLedger.d.ts +74 -33
  47. package/dist/spendLedger.d.ts.map +1 -1
  48. package/dist/spendLedger.js +523 -130
  49. package/dist/spendLedger.js.map +1 -1
  50. package/dist/unifiedAgent.d.ts +17 -8
  51. package/dist/unifiedAgent.d.ts.map +1 -1
  52. package/dist/unifiedAgent.js +227 -42
  53. package/dist/unifiedAgent.js.map +1 -1
  54. package/package.json +3 -3
@@ -1,182 +1,575 @@
1
- // Making a daily spending cap mean something.
2
- //
3
- // The cap was a number compared against a ring buffer in one object's memory.
4
- // Two consequences, both of which defeat it entirely.
5
- //
6
- // A new process starts at zero. An agent that restarts — a crash loop, a
7
- // deploy, a cron that runs it hourly — gets its full daily allowance again each
8
- // time, and the "daily" cap becomes a per-process cap.
9
- //
10
- // And the check was separate from the record: `checkBudget()` read the total,
11
- // the payment happened, and only then was the cost added. Two calls in flight
12
- // at once both read the same total, both passed, and both paid. The cap held
13
- // only for an agent making one call at a time, which is not the kind of agent
14
- // this SDK is for.
15
- //
16
- // So a cap now goes through reserve → commit or release, where the reservation
17
- // is what the next check sees. A reservation that is never resolved — the
18
- // process died mid-payment — expires, because the alternative is an agent that
19
- // loses its budget to a crash and cannot spend again until tomorrow.
20
- //
21
- // Durability is engaged only when a daily cap is actually set. A library that
22
- // writes to a user's disk because it was imported would be rude; one that
23
- // promises a daily limit and forgets it on restart is worse.
24
- import { randomUUID } from "node:crypto";
25
- import { mkdir, readFile, writeFile, rename, open, unlink } from "node:fs/promises";
1
+ // Daily caps use reserve → confirmed commit or proven nonpayment release.
2
+ // Unknown outcomes never expire: a process restart is not evidence of no payment.
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { mkdir, readFile, rename, open, unlink } from "node:fs/promises";
26
5
  import { homedir } from "node:os";
27
- import { dirname, join } from "node:path";
28
- /** How long a reservation survives without being committed or released. */
29
- const RESERVATION_TTL_MS = 5 * 60_000;
30
- function liveTotal(entries, windowMs, now) {
31
- const cutoff = now - windowMs;
32
- return entries.filter((e) => e.at >= cutoff && (!e.expiresAt || e.expiresAt > now)).reduce((s, e) => s + e.usd, 0);
6
+ import { dirname, join, resolve } from "node:path";
7
+ import bs58 from "bs58";
8
+ const RESERVATION_MARKER_MS = 5 * 60_000;
9
+ const LOCK_TIMEOUT_MS = 5_000;
10
+ const BINDING_FIELDS = [
11
+ "apiBaseUrl",
12
+ "paymentIssuer",
13
+ "payer",
14
+ "merchantId",
15
+ "scope",
16
+ "resource",
17
+ "networkMode",
18
+ "chain",
19
+ "asset",
20
+ "token",
21
+ "grossAmount",
22
+ "quoteId",
23
+ ];
24
+ function validBinding(binding) {
25
+ const b = binding;
26
+ const optional = [
27
+ "walletIdentity",
28
+ "solanaAdmissionRateUsd",
29
+ "solanaMaxFeeLamports",
30
+ "solanaTransactionFeeLamports",
31
+ "quoteAdmissionVersion",
32
+ ];
33
+ return (!!binding &&
34
+ typeof binding === "object" &&
35
+ Object.keys(binding).every((k) => BINDING_FIELDS.includes(k) || optional.includes(k)) &&
36
+ (!Object.hasOwn(binding, "walletIdentity") ||
37
+ (typeof binding.walletIdentity === "string" &&
38
+ !!binding.walletIdentity)) &&
39
+ (b.quoteAdmissionVersion === undefined ||
40
+ (b.quoteAdmissionVersion === "1" &&
41
+ b.chain.startsWith("solana:") &&
42
+ b.grossAmount === "0" &&
43
+ b.quoteId === "quote-admission")) &&
44
+ ((b.solanaAdmissionRateUsd === undefined &&
45
+ b.solanaMaxFeeLamports === undefined &&
46
+ b.solanaTransactionFeeLamports === undefined) ||
47
+ (b.chain?.startsWith("solana:") &&
48
+ typeof b.solanaAdmissionRateUsd === "string" &&
49
+ /^(?:0|[1-9][0-9]*)(?:\.[0-9]{1,18})?$/.test(b.solanaAdmissionRateUsd) &&
50
+ Number(b.solanaAdmissionRateUsd) > 0 &&
51
+ Number(b.solanaAdmissionRateUsd) < 100000 &&
52
+ typeof b.solanaMaxFeeLamports === "string" &&
53
+ /^[1-9][0-9]*$/.test(b.solanaMaxFeeLamports) &&
54
+ typeof b.solanaTransactionFeeLamports === "string" &&
55
+ /^(?:0|[1-9][0-9]*)$/.test(b.solanaTransactionFeeLamports) &&
56
+ BigInt(b.solanaTransactionFeeLamports) <= BigInt(b.solanaMaxFeeLamports))) &&
57
+ BINDING_FIELDS.every((k) => typeof binding[k] === "string" && !!binding[k]));
58
+ }
59
+ function sameBinding(left, right) {
60
+ return (!!left &&
61
+ left.walletIdentity === right.walletIdentity &&
62
+ left.quoteAdmissionVersion === right.quoteAdmissionVersion &&
63
+ left.solanaAdmissionRateUsd === right.solanaAdmissionRateUsd &&
64
+ left.solanaMaxFeeLamports === right.solanaMaxFeeLamports &&
65
+ left.solanaTransactionFeeLamports === right.solanaTransactionFeeLamports &&
66
+ BINDING_FIELDS.every((k) => left[k] === right[k]));
67
+ }
68
+ function purchaseKey(binding) {
69
+ // Exact chain/token binding is retained, but changing rail cannot rebuy unresolved access.
70
+ return createHash("sha256")
71
+ .update(JSON.stringify([
72
+ binding.apiBaseUrl,
73
+ binding.paymentIssuer,
74
+ binding.walletIdentity ?? binding.payer,
75
+ binding.merchantId,
76
+ binding.scope,
77
+ binding.resource,
78
+ binding.networkMode,
79
+ ]))
80
+ .digest("hex");
81
+ }
82
+ function admissionAccessKey(binding) {
83
+ // An unknown admission cannot be bypassed by changing live/test or rail.
84
+ return purchaseKey({ ...binding, networkMode: "quote-access" });
85
+ }
86
+ function finiteNonnegative(value) {
87
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
88
+ }
89
+ function validateCost(usd, cap, windowMs, binding) {
90
+ if (!finiteNonnegative(usd) ||
91
+ !(Number.isFinite(cap) && cap > 0) ||
92
+ !(Number.isFinite(windowMs) && windowMs > 0) ||
93
+ (binding !== undefined && !validBinding(binding)) ||
94
+ binding?.quoteAdmissionVersion !== undefined)
95
+ throw new Error("Invalid spending reservation parameters");
33
96
  }
34
97
  function prune(entries, windowMs, now) {
35
- const cutoff = now - windowMs;
36
- // Drop what has aged out of the window, and reservations nobody resolved.
37
- return entries.filter((e) => e.at >= cutoff && (!e.expiresAt || e.expiresAt > now));
38
- }
39
- /**
40
- * In-process ledger. Correct for one process, forgotten on restart.
41
- *
42
- * Kept because it is the right answer when no daily cap is set — there is
43
- * nothing to remember — and because a test should not need a filesystem.
44
- */
98
+ // Retain confirmed-but-unreceipted purchases too; never silently remove their guard.
99
+ return entries.filter((e) => e.binding !== undefined || e.expiresAt !== undefined || e.receiptPending === true || e.at >= now - windowMs);
100
+ }
101
+ function liveTotal(entries, windowMs, now) {
102
+ return entries.filter((e) => e.expiresAt !== undefined || e.at >= now - windowMs).reduce((sum, e) => sum + e.usd, 0);
103
+ }
104
+ function reserveEntry(entries, usd, cap, windowMs, binding, admissionId) {
105
+ validateCost(usd, cap, windowMs, binding);
106
+ const now = Date.now(), live = prune(entries, windowMs, now);
107
+ const admission = admissionId ? live.find((e) => e.id === admissionId) : undefined;
108
+ if (admissionId &&
109
+ (!admission?.quoteAdmission ||
110
+ admission.quoteAdmission.phase !== "pending" ||
111
+ !admission.quoteAdmission.quoteJson ||
112
+ !binding ||
113
+ !sameAdmissionPurchase(admission.quoteAdmission.originalBinding, binding) ||
114
+ JSON.parse(admission.quoteAdmission.quoteJson).quote_id !== binding.quoteId ||
115
+ admission.txRef ||
116
+ admission.usd !== 0))
117
+ throw new Error("Monetary reservation disagrees with the durable quote admission");
118
+ if (binding &&
119
+ live.some((e) => e.id !== admissionId &&
120
+ e.binding &&
121
+ (e.expiresAt !== undefined || e.receiptPending === true) &&
122
+ (purchaseKey(e.binding) === purchaseKey(binding) ||
123
+ (e.quoteAdmission && admissionAccessKey(e.quoteAdmission.originalBinding) === admissionAccessKey(binding)))))
124
+ throw new Error("Unresolved purchase; recover its existing payment before buying again");
125
+ if (liveTotal(live, windowMs, now) + usd > cap)
126
+ return { entries: live, result: null };
127
+ if (admission) {
128
+ admission.usd = usd;
129
+ admission.at = now;
130
+ admission.binding = { ...binding };
131
+ admission.quoteAdmission.phase = "monetary";
132
+ admission.quoteAdmission.wasReserved = true;
133
+ return { entries: live, result: admission.id };
134
+ }
135
+ const id = randomUUID();
136
+ live.push({
137
+ id,
138
+ usd,
139
+ at: now,
140
+ expiresAt: now + RESERVATION_MARKER_MS,
141
+ ...(binding ? { binding: { ...binding } } : {}),
142
+ });
143
+ return { entries: live, result: id };
144
+ }
145
+ function commitEntry(entries, id, actualUsd) {
146
+ if (actualUsd !== undefined && !finiteNonnegative(actualUsd))
147
+ throw new Error("Invalid confirmed spending amount");
148
+ const entry = entries.find((e) => e.id === id);
149
+ if (!entry)
150
+ throw new Error("Spending reservation is missing; reconciliation is required");
151
+ if (entry.quoteAdmission && entry.quoteAdmission.phase !== "monetary")
152
+ throw new Error("Quote admission is not a monetary reservation");
153
+ if (entry.failure && actualUsd !== undefined && actualUsd !== entry.usd)
154
+ throw new Error("Finalized failure debit is immutable");
155
+ if (actualUsd !== undefined)
156
+ entry.usd = actualUsd;
157
+ if (entry.expiresAt !== undefined)
158
+ entry.at = Date.now();
159
+ delete entry.expiresAt;
160
+ }
161
+ function boundEntry(entries, id, txRef, binding, allowFailure = false) {
162
+ const entry = entries.find((e) => e.id === id);
163
+ if (!entry ||
164
+ (entry.failure && !allowFailure) ||
165
+ !validBinding(binding) ||
166
+ !sameBinding(entry.binding, binding) ||
167
+ entry.txRef !== txRef)
168
+ throw new Error("Recovery disagrees with the original spending reservation");
169
+ return entry;
170
+ }
171
+ function finalizeFailureEntry(entries, id, txRef, binding, feeUsd) {
172
+ const entry = entries.find((e) => e.id === id);
173
+ if (!entry ||
174
+ !validBinding(binding) ||
175
+ !sameBinding(entry.binding, binding) ||
176
+ entry.txRef !== txRef ||
177
+ !binding.solanaAdmissionRateUsd ||
178
+ !binding.solanaMaxFeeLamports ||
179
+ !finiteNonnegative(feeUsd) ||
180
+ feeUsd > entry.usd)
181
+ throw new Error("Finalized failure disagrees with the original spending reservation");
182
+ if (entry.failure) {
183
+ if (entry.usd !== feeUsd)
184
+ throw new Error("Finalized failure debit is immutable");
185
+ return;
186
+ }
187
+ if (entry.expiresAt === undefined)
188
+ throw new Error("Confirmed successful spending cannot become a failed debit");
189
+ // A late proof does not move the original admission into a new daily window.
190
+ // The immutable identity survives indefinitely; only its original timestamp
191
+ // decides whether this proven fee is counted in the current spending window.
192
+ entry.usd = feeUsd;
193
+ delete entry.expiresAt;
194
+ entry.failure = true;
195
+ delete entry.receiptPending;
196
+ }
197
+ function validTxRef(txRef, binding) {
198
+ if (!binding.chain.startsWith("solana:"))
199
+ return /^0x[0-9a-fA-F]{64}$/.test(txRef);
200
+ try {
201
+ return bs58.decode(txRef).length === 64 && bs58.encode(bs58.decode(txRef)) === txRef;
202
+ }
203
+ catch {
204
+ return false;
205
+ }
206
+ }
207
+ function prepareEntry(entries, id, txRef, binding) {
208
+ const entry = entries.find((e) => e.id === id);
209
+ if (!entry ||
210
+ (entry.quoteAdmission && entry.quoteAdmission.phase !== "monetary") ||
211
+ entry.expiresAt === undefined ||
212
+ !sameBinding(entry.binding, binding) ||
213
+ !validTxRef(txRef, binding) ||
214
+ (entry.txRef !== undefined && entry.txRef !== txRef))
215
+ throw new Error("Prepared transaction disagrees with the spending reservation");
216
+ entry.txRef = txRef;
217
+ entry.receiptPending = true;
218
+ }
219
+ function releaseEntries(entries, id) {
220
+ if (entries.some((e) => e.id === id && e.expiresAt === undefined))
221
+ throw new Error("Confirmed spending cannot be released");
222
+ const entry = entries.find((e) => e.id === id);
223
+ if (entry?.quoteAdmission) {
224
+ if (entry.quoteAdmission.phase !== "monetary")
225
+ throw new Error("Quote admission requires proof-bound closure");
226
+ // A proven prebroadcast failure can retry its original admission, never a new nonce.
227
+ entry.usd = 0;
228
+ entry.binding = { ...entry.quoteAdmission.originalBinding };
229
+ entry.quoteAdmission.phase = "pending";
230
+ delete entry.txRef;
231
+ delete entry.receiptPending;
232
+ return entries;
233
+ }
234
+ return entries.filter((e) => e.id !== id);
235
+ }
236
+ function sameAdmissionPurchase(original, binding) {
237
+ return (purchaseKey(original) === purchaseKey(binding) &&
238
+ ["payer", "chain", "asset", "token", "walletIdentity"].every((k) => original[k] === binding[k]));
239
+ }
240
+ function validAdmission(a) {
241
+ const x = a;
242
+ return (!!x &&
243
+ typeof x === "object" &&
244
+ typeof x.id === "string" &&
245
+ !!x.id &&
246
+ typeof x.ownerContext === "string" &&
247
+ !!x.ownerContext &&
248
+ x.ownerContext.length <= 65536 &&
249
+ typeof x.requestBody === "string" &&
250
+ !!x.requestBody &&
251
+ x.requestBody.length <= 65536 &&
252
+ typeof x.statement === "string" &&
253
+ !!x.statement &&
254
+ x.statement.length <= 65536 &&
255
+ (x.quoteJson === undefined ||
256
+ (typeof x.quoteJson === "string" && !!x.quoteJson && x.quoteJson.length <= 1048576)) &&
257
+ ["pending", "monetary", "terminal"].includes(x.phase) &&
258
+ (x.wasReserved === undefined || x.wasReserved === true) &&
259
+ validBinding(x.originalBinding) &&
260
+ x.originalBinding.quoteAdmissionVersion === "1" &&
261
+ (x.phase === "terminal" ? ["not-admitted", "expired-unbroadcast"].includes(x.terminal) : x.terminal === undefined));
262
+ }
263
+ async function beginAdmission(entries, binding, ownerContext, create) {
264
+ if (!validBinding(binding) || binding.quoteAdmissionVersion !== "1")
265
+ throw new Error("Invalid quote admission binding");
266
+ const existing = entries.find((e) => e.binding &&
267
+ (e.expiresAt !== undefined || e.receiptPending) &&
268
+ (purchaseKey(e.binding) === purchaseKey(binding) ||
269
+ (e.quoteAdmission && admissionAccessKey(e.quoteAdmission.originalBinding) === admissionAccessKey(binding))));
270
+ if (existing) {
271
+ if (!existing.quoteAdmission ||
272
+ existing.quoteAdmission.phase !== "pending" ||
273
+ !sameBinding(existing.binding, binding) ||
274
+ existing.quoteAdmission.ownerContext !== ownerContext)
275
+ throw new Error("Unresolved purchase or changed owner context; reconcile the original admission");
276
+ return structuredClone(existing.quoteAdmission);
277
+ }
278
+ const id = randomUUID(), result = {
279
+ id,
280
+ ownerContext,
281
+ ...(await create()),
282
+ phase: "pending",
283
+ originalBinding: { ...binding },
284
+ };
285
+ if (!validAdmission(result))
286
+ throw new Error("Malformed quote admission");
287
+ entries.push({
288
+ id,
289
+ usd: 0,
290
+ at: Date.now(),
291
+ expiresAt: Date.now() + RESERVATION_MARKER_MS,
292
+ binding: { ...binding },
293
+ quoteAdmission: result,
294
+ });
295
+ return structuredClone(result);
296
+ }
297
+ function boundAdmission(entries, id, binding, context) {
298
+ const entry = entries.find((e) => e.id === id);
299
+ if (!entry?.quoteAdmission ||
300
+ !sameBinding(entry.binding, binding) ||
301
+ entry.quoteAdmission.ownerContext !== context ||
302
+ entry.txRef ||
303
+ entry.usd !== 0)
304
+ throw new Error("Quote admission phase or owner binding mismatch");
305
+ return entry.quoteAdmission;
306
+ }
307
+ function adoptAdmission(entries, id, binding, context, quoteJson) {
308
+ const a = boundAdmission(entries, id, binding, context);
309
+ if (a.phase !== "pending" || (a.quoteJson !== undefined && a.quoteJson !== quoteJson))
310
+ throw new Error("Original quote admission is immutable");
311
+ a.quoteJson = quoteJson;
312
+ if (!validAdmission(a))
313
+ throw new Error("Malformed adopted quote");
314
+ }
315
+ function closeAdmission(entries, id, binding, context, terminal, quoteJson) {
316
+ const a = boundAdmission(entries, id, binding, context);
317
+ if (a.phase === "terminal" && a.terminal === terminal && a.quoteJson === quoteJson)
318
+ return;
319
+ if (a.phase !== "pending" ||
320
+ a.wasReserved ||
321
+ a.quoteJson !== quoteJson ||
322
+ (terminal === "not-admitted" ? quoteJson !== undefined : !quoteJson))
323
+ throw new Error("Ambiguous admission cannot be closed without original nonbroadcast evidence");
324
+ a.phase = "terminal";
325
+ a.terminal = terminal;
326
+ delete entries.find((e) => e.id === id).expiresAt;
327
+ }
328
+ function validateEntries(data) {
329
+ if (!Array.isArray(data) ||
330
+ data.some((e) => !e ||
331
+ typeof e !== "object" ||
332
+ typeof e.id !== "string" ||
333
+ !e.id ||
334
+ !finiteNonnegative(e.usd) ||
335
+ !finiteNonnegative(e.at) ||
336
+ (e.expiresAt !== undefined && !finiteNonnegative(e.expiresAt)) ||
337
+ (e.binding !== undefined && !validBinding(e.binding)) ||
338
+ (e.quoteAdmission !== undefined &&
339
+ (!validAdmission(e.quoteAdmission) ||
340
+ e.quoteAdmission.id !== e.id ||
341
+ (e.quoteAdmission.phase !== "monetary" &&
342
+ (!sameBinding(e.binding, e.quoteAdmission.originalBinding) ||
343
+ e.usd !== 0 ||
344
+ e.txRef ||
345
+ e.receiptPending)) ||
346
+ (e.quoteAdmission.phase === "terminal") !==
347
+ (e.expiresAt === undefined && e.quoteAdmission.phase !== "monetary"))) ||
348
+ (e.binding?.quoteAdmissionVersion !== undefined && !e.quoteAdmission) ||
349
+ (e.txRef !== undefined && (!e.binding || !validTxRef(e.txRef, e.binding))) ||
350
+ (e.receiptPending !== undefined && (typeof e.receiptPending !== "boolean" || !e.txRef)) ||
351
+ (e.failure !== undefined &&
352
+ (e.failure !== true ||
353
+ e.expiresAt !== undefined ||
354
+ e.receiptPending !== undefined ||
355
+ !e.binding?.solanaAdmissionRateUsd ||
356
+ !e.txRef))))
357
+ throw new Error("Spending ledger is malformed; refusing to reset the budget");
358
+ if (new Set(data.map((e) => e.id)).size !== data.length)
359
+ throw new Error("Spending ledger contains duplicate reservations");
360
+ const pendingKeys = data
361
+ .filter((e) => e.quoteAdmission && (e.expiresAt !== undefined || e.receiptPending))
362
+ .map((e) => admissionAccessKey(e.quoteAdmission.originalBinding));
363
+ if (new Set(pendingKeys).size !== pendingKeys.length)
364
+ throw new Error("Spending ledger contains duplicate quote admissions");
365
+ return data;
366
+ }
367
+ /** One-process implementation; use one shared instance or the local file ledger. */
45
368
  export class MemorySpendLedger {
46
369
  entries = [];
47
- async reserve(usd, cap, windowMs) {
48
- const now = Date.now();
49
- this.entries = prune(this.entries, windowMs, now);
50
- if (liveTotal(this.entries, windowMs, now) + usd > cap)
51
- return null;
52
- const id = randomUUID();
53
- this.entries.push({ id, usd, at: now, expiresAt: now + RESERVATION_TTL_MS });
54
- return id;
370
+ tail = Promise.resolve();
371
+ serial(fn) {
372
+ const task = this.tail.then(fn);
373
+ this.tail = task.catch(() => undefined);
374
+ return task;
55
375
  }
56
- async commit(id, actualUsd) {
57
- const e = this.entries.find((x) => x.id === id);
58
- if (!e)
59
- return;
60
- if (typeof actualUsd === "number" && Number.isFinite(actualUsd))
61
- e.usd = actualUsd;
62
- delete e.expiresAt;
376
+ reserve(usd, cap, windowMs, binding, admissionId) {
377
+ return this.serial(() => {
378
+ const next = reserveEntry(this.entries, usd, cap, windowMs, binding, admissionId);
379
+ this.entries = next.entries;
380
+ return next.result;
381
+ });
63
382
  }
64
- async release(id) {
65
- this.entries = this.entries.filter((x) => x.id !== id);
383
+ commit(id, actualUsd) {
384
+ return this.serial(() => commitEntry(this.entries, id, actualUsd));
66
385
  }
67
- async total(windowMs) {
68
- return liveTotal(this.entries, windowMs, Date.now());
69
- }
70
- }
71
- /**
72
- * A ledger on disk, so a restart does not hand the agent its allowance again.
73
- *
74
- * Read-modify-write is guarded by an exclusive lock file, which is atomic on a
75
- * local filesystem: `open(..., "wx")` either creates the file or fails, with no
76
- * window in between. That covers several processes on one host, which is what
77
- * a restart, a cron and a worker pool actually are.
78
- *
79
- * It does NOT cover several hosts — a lock file on a network filesystem is not
80
- * a lock. An agent fleet spanning machines should pass its own SpendLedger
81
- * backed by something that can answer for all of them; that is why this is an
82
- * interface rather than a class the SDK insists on.
83
- */
386
+ release(id) {
387
+ return this.serial(() => {
388
+ this.entries = releaseEntries(this.entries, id);
389
+ });
390
+ }
391
+ total(windowMs) {
392
+ return this.serial(() => liveTotal(this.entries, windowMs, Date.now()));
393
+ }
394
+ prepare(id, txRef, binding) {
395
+ return this.serial(() => prepareEntry(this.entries, id, txRef, binding));
396
+ }
397
+ assertRecovery(id, txRef, binding) {
398
+ return this.serial(() => {
399
+ boundEntry(this.entries, id, txRef, binding, true);
400
+ });
401
+ }
402
+ complete(id, txRef, binding) {
403
+ return this.serial(() => {
404
+ const entry = boundEntry(this.entries, id, txRef, binding);
405
+ commitEntry(this.entries, id);
406
+ delete entry.receiptPending;
407
+ });
408
+ }
409
+ finalizeFailure(id, txRef, binding, feeUsd) {
410
+ return this.serial(() => finalizeFailureEntry(this.entries, id, txRef, binding, feeUsd));
411
+ }
412
+ beginQuoteAdmission(binding, context, create) {
413
+ return this.serial(() => beginAdmission(this.entries, binding, context, create));
414
+ }
415
+ adoptQuoteAdmission(id, binding, context, quoteJson) {
416
+ return this.serial(() => adoptAdmission(this.entries, id, binding, context, quoteJson));
417
+ }
418
+ closeQuoteAdmission(id, binding, context, terminal, quoteJson) {
419
+ return this.serial(() => closeAdmission(this.entries, id, binding, context, terminal, quoteJson));
420
+ }
421
+ }
422
+ /** Shared cap for processes on one local filesystem; never a cross-host lock. */
84
423
  export class FileSpendLedger {
85
424
  path;
86
425
  constructor(path) {
87
426
  this.path = path;
88
427
  }
89
- /** Default location, one file per agent address. */
90
428
  static forAgent(address) {
91
429
  const base = process.env.AIFINPAY_STATE_DIR || join(homedir(), ".aifinpay");
92
430
  return new FileSpendLedger(join(base, "spend", `${address.toLowerCase()}.json`));
93
431
  }
94
432
  async withLock(fn) {
95
- await mkdir(dirname(this.path), { recursive: true });
433
+ const directoryPath = resolve(dirname(this.path));
434
+ await mkdir(directoryPath, { recursive: true, mode: 0o700 });
435
+ // Another process, or an earlier failed sync, may have just created these
436
+ // entries. Existing directories therefore also need ancestor durability.
437
+ for (let current = directoryPath;; current = dirname(current)) {
438
+ const directory = await open(current, "r");
439
+ try {
440
+ await directory.sync();
441
+ }
442
+ finally {
443
+ await directory.close();
444
+ }
445
+ if (dirname(current) === current)
446
+ break;
447
+ }
96
448
  const lockPath = `${this.path}.lock`;
97
- // Spin briefly rather than failing: contention here is two of our own
98
- // calls, and they are milliseconds apart.
99
449
  let handle;
100
- const deadline = Date.now() + 5_000;
450
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
101
451
  for (;;) {
102
452
  try {
103
- handle = await open(lockPath, "wx");
453
+ handle = await open(lockPath, "wx", 0o600);
104
454
  break;
105
455
  }
106
456
  catch (e) {
107
457
  if (e.code !== "EEXIST")
108
458
  throw e;
109
- if (Date.now() > deadline) {
110
- // A lock this old belongs to a process that died holding it. Breaking
111
- // it risks a lost update; refusing to ever break it means one crash
112
- // disables the cap permanently, which is worse.
113
- await unlink(lockPath).catch(() => { });
114
- continue;
115
- }
116
- await new Promise((r) => setTimeout(r, 5 + Math.floor(Math.random() * 15)));
459
+ if (Date.now() >= deadline)
460
+ throw new Error("Spending ledger lock is held; stop all users and reconcile before owner lock recovery");
461
+ await new Promise((resolve) => setTimeout(resolve, 5 + Math.floor(Math.random() * 15)));
117
462
  }
118
463
  }
464
+ const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
119
465
  try {
120
- let entries = [];
466
+ let entries;
121
467
  try {
122
- entries = JSON.parse(await readFile(this.path, "utf8"));
123
- if (!Array.isArray(entries))
124
- entries = [];
468
+ entries = validateEntries(JSON.parse(await readFile(this.path, "utf8")));
125
469
  }
126
- catch {
127
- // Absent or unreadable. Starting from empty is the only option, and it
128
- // errs toward letting a payment through rather than blocking one — the
129
- // same direction the old in-memory behaviour erred, every restart.
470
+ catch (e) {
471
+ if (e.code !== "ENOENT")
472
+ throw e;
130
473
  entries = [];
131
474
  }
132
- const { entries: next, result } = fn(entries);
133
- // Write to a sibling and rename: a crash mid-write leaves the previous
134
- // ledger intact rather than a truncated one.
135
- const tmp = `${this.path}.${process.pid}.tmp`;
136
- await writeFile(tmp, JSON.stringify(next), "utf8");
137
- await rename(tmp, this.path);
475
+ const { entries: next, result } = await fn(entries);
476
+ validateEntries(next);
477
+ const file = await open(temporary, "wx", 0o600);
478
+ try {
479
+ await file.writeFile(JSON.stringify(next), "utf8");
480
+ await file.sync();
481
+ }
482
+ finally {
483
+ await file.close();
484
+ }
485
+ await rename(temporary, this.path);
486
+ const directory = await open(dirname(this.path), "r");
487
+ try {
488
+ await directory.sync();
489
+ }
490
+ finally {
491
+ await directory.close();
492
+ }
138
493
  return result;
139
494
  }
140
495
  finally {
141
- await handle.close().catch(() => { });
142
- await unlink(lockPath).catch(() => { });
496
+ try {
497
+ await unlink(temporary).catch((e) => {
498
+ if (e.code !== "ENOENT")
499
+ throw e;
500
+ });
501
+ }
502
+ finally {
503
+ try {
504
+ await handle.close();
505
+ }
506
+ finally {
507
+ // Only this acquired lock is removed, never one guessed stale from age.
508
+ await unlink(lockPath);
509
+ }
510
+ }
143
511
  }
144
512
  }
145
- async reserve(usd, cap, windowMs) {
146
- return this.withLock((entries) => {
147
- const now = Date.now();
148
- const live = prune(entries, windowMs, now);
149
- if (liveTotal(live, windowMs, now) + usd > cap) {
150
- return { entries: live, result: null };
151
- }
152
- const id = randomUUID();
153
- live.push({ id, usd, at: now, expiresAt: now + RESERVATION_TTL_MS });
154
- return { entries: live, result: id };
155
- });
513
+ async reserve(usd, cap, windowMs, binding, admissionId) {
514
+ return this.withLock((entries) => reserveEntry(entries, usd, cap, windowMs, binding, admissionId));
156
515
  }
157
516
  async commit(id, actualUsd) {
158
517
  await this.withLock((entries) => {
159
- for (const e of entries) {
160
- if (e.id === id) {
161
- if (typeof actualUsd === "number" && Number.isFinite(actualUsd))
162
- e.usd = actualUsd;
163
- delete e.expiresAt;
164
- }
165
- }
518
+ commitEntry(entries, id, actualUsd);
166
519
  return { entries, result: undefined };
167
520
  });
168
521
  }
169
522
  async release(id) {
170
- await this.withLock((entries) => ({
171
- entries: entries.filter((e) => e.id !== id),
172
- result: undefined,
173
- }));
523
+ await this.withLock((entries) => ({ entries: releaseEntries(entries, id), result: undefined }));
174
524
  }
175
525
  async total(windowMs) {
176
526
  return this.withLock((entries) => {
177
- const now = Date.now();
178
- const live = prune(entries, windowMs, now);
179
- return { entries: live, result: liveTotal(live, windowMs, now) };
527
+ const live = prune(entries, windowMs, Date.now());
528
+ return { entries: live, result: liveTotal(live, windowMs, Date.now()) };
529
+ });
530
+ }
531
+ async prepare(id, txRef, binding) {
532
+ await this.withLock((entries) => {
533
+ prepareEntry(entries, id, txRef, binding);
534
+ return { entries, result: undefined };
535
+ });
536
+ }
537
+ async assertRecovery(id, txRef, binding) {
538
+ await this.withLock((entries) => {
539
+ boundEntry(entries, id, txRef, binding, true);
540
+ return { entries, result: undefined };
541
+ });
542
+ }
543
+ async complete(id, txRef, binding) {
544
+ await this.withLock((entries) => {
545
+ const entry = boundEntry(entries, id, txRef, binding);
546
+ commitEntry(entries, id);
547
+ delete entry.receiptPending;
548
+ return { entries, result: undefined };
549
+ });
550
+ }
551
+ async finalizeFailure(id, txRef, binding, feeUsd) {
552
+ await this.withLock((entries) => {
553
+ finalizeFailureEntry(entries, id, txRef, binding, feeUsd);
554
+ return { entries, result: undefined };
555
+ });
556
+ }
557
+ async beginQuoteAdmission(binding, context, create) {
558
+ return this.withLock(async (entries) => ({
559
+ entries,
560
+ result: await beginAdmission(entries, binding, context, create),
561
+ }));
562
+ }
563
+ async adoptQuoteAdmission(id, binding, context, quoteJson) {
564
+ await this.withLock((entries) => {
565
+ adoptAdmission(entries, id, binding, context, quoteJson);
566
+ return { entries, result: undefined };
567
+ });
568
+ }
569
+ async closeQuoteAdmission(id, binding, context, terminal, quoteJson) {
570
+ await this.withLock((entries) => {
571
+ closeAdmission(entries, id, binding, context, terminal, quoteJson);
572
+ return { entries, result: undefined };
180
573
  });
181
574
  }
182
575
  }