@aifinpay/agent 2.3.2 → 2.5.1
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/PAYMENT_RECEIPTS.md +194 -19
- package/README.md +37 -17
- package/dist/agentHistory.d.ts +9 -0
- package/dist/agentHistory.d.ts.map +1 -1
- package/dist/agentHistory.js +110 -16
- package/dist/agentHistory.js.map +1 -1
- package/dist/aifp1.d.ts +124 -17
- package/dist/aifp1.d.ts.map +1 -1
- package/dist/aifp1.js +580 -82
- package/dist/aifp1.js.map +1 -1
- package/dist/deploymentErrors.d.ts +5 -0
- package/dist/deploymentErrors.d.ts.map +1 -0
- package/dist/deploymentErrors.js +8 -0
- package/dist/deploymentErrors.js.map +1 -0
- package/dist/deploymentResolver.d.ts +2 -3
- package/dist/deploymentResolver.d.ts.map +1 -1
- package/dist/deploymentResolver.js +2 -6
- package/dist/deploymentResolver.js.map +1 -1
- package/dist/generated/solanaV14Deployments.generated.d.ts +1 -0
- package/dist/generated/solanaV14Deployments.generated.d.ts.map +1 -1
- package/dist/generated/solanaV14Deployments.generated.js +4 -2
- package/dist/generated/solanaV14Deployments.generated.js.map +1 -1
- package/dist/generated/v14Deployments.generated.d.ts +1 -1
- package/dist/generated/v14Deployments.generated.js +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/paymentChains.d.ts +95 -0
- package/dist/paymentChains.d.ts.map +1 -0
- package/dist/paymentChains.js +116 -0
- package/dist/paymentChains.js.map +1 -0
- package/dist/settlement.d.ts +5 -5
- package/dist/settlementSolanaV14.d.ts +135 -0
- package/dist/settlementSolanaV14.d.ts.map +1 -0
- package/dist/settlementSolanaV14.js +733 -0
- package/dist/settlementSolanaV14.js.map +1 -0
- package/dist/settlementV14.d.ts +9 -2
- package/dist/settlementV14.d.ts.map +1 -1
- package/dist/settlementV14.js +81 -14
- package/dist/settlementV14.js.map +1 -1
- package/dist/solanaDeploymentResolver.d.ts +2 -1
- package/dist/solanaDeploymentResolver.d.ts.map +1 -1
- package/dist/solanaDeploymentResolver.js +1 -32
- package/dist/solanaDeploymentResolver.js.map +1 -1
- package/dist/spendLedger.d.ts +74 -33
- package/dist/spendLedger.d.ts.map +1 -1
- package/dist/spendLedger.js +523 -130
- package/dist/spendLedger.js.map +1 -1
- package/dist/unifiedAgent.d.ts +17 -8
- package/dist/unifiedAgent.d.ts.map +1 -1
- package/dist/unifiedAgent.js +222 -37
- package/dist/unifiedAgent.js.map +1 -1
- package/package.json +4 -4
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -127
- package/dist/cli.js.map +0 -1
- package/dist/solanaV14Deployments.generated.d.ts +0 -35
- package/dist/solanaV14Deployments.generated.d.ts.map +0 -1
- package/dist/solanaV14Deployments.generated.js +0 -61
- package/dist/solanaV14Deployments.generated.js.map +0 -1
- package/dist/splitterRoutes.generated.d.ts +0 -22
- package/dist/splitterRoutes.generated.d.ts.map +0 -1
- package/dist/splitterRoutes.generated.js +0 -459
- package/dist/splitterRoutes.generated.js.map +0 -1
- package/dist/v14Deployments.generated.d.ts +0 -53
- package/dist/v14Deployments.generated.d.ts.map +0 -1
- package/dist/v14Deployments.generated.js +0 -89
- package/dist/v14Deployments.generated.js.map +0 -1
package/dist/spendLedger.js
CHANGED
|
@@ -1,182 +1,575 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
383
|
+
commit(id, actualUsd) {
|
|
384
|
+
return this.serial(() => commitEntry(this.entries, id, actualUsd));
|
|
66
385
|
}
|
|
67
|
-
|
|
68
|
-
return
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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() +
|
|
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()
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
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
|
}
|