@agentkv/client 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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/dist/index.cjs +1263 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +584 -0
- package/dist/index.d.ts +584 -0
- package/dist/index.js +1240 -0
- package/dist/index.js.map +1 -0
- package/package.json +76 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1240 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { fetchWithRetry } from "@agentx402-ai/core";
|
|
3
|
+
import { hexToBytes as hexToBytes2 } from "viem";
|
|
4
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
5
|
+
|
|
6
|
+
// src/account.ts
|
|
7
|
+
import { bytesToHex } from "viem";
|
|
8
|
+
var AK_PREFIX = "ak_";
|
|
9
|
+
var AK_RANDOM_BYTES = 32;
|
|
10
|
+
var AK_FORMAT_RE = /^ak_[0-9a-f]{64}$/;
|
|
11
|
+
function generateAccountKey() {
|
|
12
|
+
const bytes = crypto.getRandomValues(new Uint8Array(AK_RANDOM_BYTES));
|
|
13
|
+
return AK_PREFIX + bytesToHex(bytes).slice(2);
|
|
14
|
+
}
|
|
15
|
+
function isAccountKeyFormat(s) {
|
|
16
|
+
return typeof s === "string" && AK_FORMAT_RE.test(s);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/crypto.ts
|
|
20
|
+
import { hkdf } from "@noble/hashes/hkdf";
|
|
21
|
+
import { hmac } from "@noble/hashes/hmac";
|
|
22
|
+
import { sha256 } from "@noble/hashes/sha2";
|
|
23
|
+
import { hexToBytes } from "viem";
|
|
24
|
+
var KEY_LENGTH = 32;
|
|
25
|
+
var IV_LENGTH = 12;
|
|
26
|
+
var TAG_LENGTH = 16;
|
|
27
|
+
var utf8 = (s) => new TextEncoder().encode(s);
|
|
28
|
+
var MAGIC0 = 97;
|
|
29
|
+
var MAGIC1 = 107;
|
|
30
|
+
var ENVELOPE_VER = 1;
|
|
31
|
+
var SUITE_AES256GCM = 1;
|
|
32
|
+
var KDF_V1 = 1;
|
|
33
|
+
var DIGEST_SCHEME_V1 = 1;
|
|
34
|
+
var HEADER = Uint8Array.of(MAGIC0, MAGIC1, ENVELOPE_VER, SUITE_AES256GCM, KDF_V1);
|
|
35
|
+
var HEADER_LEN = HEADER.length;
|
|
36
|
+
function normalizeEncryptionKey(key) {
|
|
37
|
+
const bytes = typeof key === "string" ? hexToBytes(key) : key;
|
|
38
|
+
if (bytes.length !== KEY_LENGTH) {
|
|
39
|
+
throw new Error(`encryption key must be 32 bytes, got ${bytes.length}`);
|
|
40
|
+
}
|
|
41
|
+
return bytes;
|
|
42
|
+
}
|
|
43
|
+
function deriveKeyMaterial(ikm) {
|
|
44
|
+
return {
|
|
45
|
+
value: hkdf(sha256, ikm, utf8("agentkv/v1/enc"), utf8("value"), KEY_LENGTH),
|
|
46
|
+
keyName: hkdf(sha256, ikm, utf8("agentkv/v1/enc"), utf8("keyname"), KEY_LENGTH),
|
|
47
|
+
mac: hkdf(sha256, ikm, utf8("agentkv/v1/mac"), utf8("lookup"), KEY_LENGTH)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function hashKey(macKey, name) {
|
|
51
|
+
const mac = hmac(sha256, macKey, utf8(name.normalize("NFC")));
|
|
52
|
+
const tagged = new Uint8Array(1 + mac.length);
|
|
53
|
+
tagged[0] = DIGEST_SCHEME_V1;
|
|
54
|
+
tagged.set(mac, 1);
|
|
55
|
+
return toBase64Url(tagged);
|
|
56
|
+
}
|
|
57
|
+
function toBase64(bytes) {
|
|
58
|
+
let binary = "";
|
|
59
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
60
|
+
return btoa(binary);
|
|
61
|
+
}
|
|
62
|
+
function toBase64Url(bytes) {
|
|
63
|
+
return toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
64
|
+
}
|
|
65
|
+
function fromBase64(b64) {
|
|
66
|
+
const binary = atob(b64);
|
|
67
|
+
const out = new Uint8Array(binary.length);
|
|
68
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
function aadBytes(aad) {
|
|
72
|
+
if (!aad) return HEADER;
|
|
73
|
+
const extra = utf8(aad);
|
|
74
|
+
const out = new Uint8Array(HEADER_LEN + extra.length);
|
|
75
|
+
out.set(HEADER, 0);
|
|
76
|
+
out.set(extra, HEADER_LEN);
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
async function importAesKey(key) {
|
|
80
|
+
return crypto.subtle.importKey(
|
|
81
|
+
"raw",
|
|
82
|
+
key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength),
|
|
83
|
+
{ name: "AES-GCM" },
|
|
84
|
+
false,
|
|
85
|
+
["encrypt", "decrypt"]
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
async function gcmDecrypt(key, iv, ct, aad) {
|
|
89
|
+
const cryptoKey = await importAesKey(key);
|
|
90
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
91
|
+
{ name: "AES-GCM", iv, additionalData: aad },
|
|
92
|
+
cryptoKey,
|
|
93
|
+
ct
|
|
94
|
+
);
|
|
95
|
+
return new TextDecoder().decode(plaintext);
|
|
96
|
+
}
|
|
97
|
+
async function encrypt(key, plaintext, aad) {
|
|
98
|
+
const cryptoKey = await importAesKey(key);
|
|
99
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
100
|
+
const data = utf8(plaintext);
|
|
101
|
+
const ct = new Uint8Array(
|
|
102
|
+
await crypto.subtle.encrypt(
|
|
103
|
+
{ name: "AES-GCM", iv, additionalData: aadBytes(aad) },
|
|
104
|
+
cryptoKey,
|
|
105
|
+
data
|
|
106
|
+
)
|
|
107
|
+
);
|
|
108
|
+
const packed = new Uint8Array(HEADER_LEN + iv.length + ct.length);
|
|
109
|
+
packed.set(HEADER, 0);
|
|
110
|
+
packed.set(iv, HEADER_LEN);
|
|
111
|
+
packed.set(ct, HEADER_LEN + iv.length);
|
|
112
|
+
return toBase64(packed);
|
|
113
|
+
}
|
|
114
|
+
async function decrypt(key, packed, aad) {
|
|
115
|
+
const bytes = fromBase64(packed);
|
|
116
|
+
const known = bytes.length >= HEADER_LEN + IV_LENGTH + TAG_LENGTH && bytes[0] === MAGIC0 && bytes[1] === MAGIC1 && bytes[2] === ENVELOPE_VER && bytes[3] === SUITE_AES256GCM && bytes[4] === KDF_V1;
|
|
117
|
+
if (!known) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`decryption failed: not a recognized AgentKV envelope (ver=${bytes[2]}, suite=${bytes[3]}, kdf=${bytes[4]}) \u2014 upgrade @agentkv/client, or the encryption key is wrong / the blob is corrupted`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
return await gcmDecrypt(
|
|
124
|
+
key,
|
|
125
|
+
bytes.slice(HEADER_LEN, HEADER_LEN + IV_LENGTH),
|
|
126
|
+
bytes.slice(HEADER_LEN + IV_LENGTH),
|
|
127
|
+
aadBytes(aad)
|
|
128
|
+
);
|
|
129
|
+
} catch {
|
|
130
|
+
throw new Error(
|
|
131
|
+
"decryption failed: wrong encryption key, corrupted blob, or a value served for a different key"
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/payment.ts
|
|
137
|
+
import {
|
|
138
|
+
buildBearerHeaders,
|
|
139
|
+
buildIdentityHeaders,
|
|
140
|
+
buildPaymentHeader,
|
|
141
|
+
challengePriceUsd,
|
|
142
|
+
decodeBase64Utf8,
|
|
143
|
+
freshNonce,
|
|
144
|
+
nonceFromIdempotencyKey,
|
|
145
|
+
nowSec
|
|
146
|
+
} from "@agentx402-ai/core";
|
|
147
|
+
|
|
148
|
+
// src/types.ts
|
|
149
|
+
import {
|
|
150
|
+
AgentKVError,
|
|
151
|
+
chainIdFromCaip2,
|
|
152
|
+
EIP712_DOMAIN_NAME,
|
|
153
|
+
EIP712_DOMAIN_VERSION,
|
|
154
|
+
SpendCapError
|
|
155
|
+
} from "@agentx402-ai/core";
|
|
156
|
+
var DEFAULT_NETWORK = "eip155:8453";
|
|
157
|
+
|
|
158
|
+
// src/index.ts
|
|
159
|
+
var VERSION = "0.1.0";
|
|
160
|
+
var V1 = "/v1";
|
|
161
|
+
var ENC_DERIVATION_DOMAIN = { name: "AgentKV Encryption", version: "1" };
|
|
162
|
+
var ENC_DERIVATION_TYPES = {
|
|
163
|
+
Derive: [
|
|
164
|
+
{ name: "purpose", type: "string" },
|
|
165
|
+
{ name: "version", type: "string" }
|
|
166
|
+
]
|
|
167
|
+
};
|
|
168
|
+
var ENC_DERIVATION_MESSAGE = { purpose: "encryption-key", version: "v1" };
|
|
169
|
+
var CREDIT_VALUE_USD = 1e-4;
|
|
170
|
+
var ACCOUNT_READ_USD = 3e-4;
|
|
171
|
+
var ACCOUNT_WRITE_USD = 5e-4;
|
|
172
|
+
var DEFAULT_MAX_OP_USD = 0.05;
|
|
173
|
+
function toWholeAtomicUsd(amountUsd) {
|
|
174
|
+
if (!Number.isFinite(amountUsd)) return null;
|
|
175
|
+
const atomic = Math.round(amountUsd * 1e6);
|
|
176
|
+
if (!Number.isInteger(atomic) || atomic <= 0) return null;
|
|
177
|
+
if (Math.abs(amountUsd * 1e6 - atomic) > atomic * 1e-9) return null;
|
|
178
|
+
return atomic;
|
|
179
|
+
}
|
|
180
|
+
var AgentKV = class {
|
|
181
|
+
/**
|
|
182
|
+
* The signing wallet. `undefined` in account-key mode (a managed account has no
|
|
183
|
+
* wallet that can sign) — the `ak_…` bearer token is the identity instead.
|
|
184
|
+
*/
|
|
185
|
+
signer;
|
|
186
|
+
/**
|
|
187
|
+
* The wallet address, the per-wallet namespace. In account-key mode there is no
|
|
188
|
+
* wallet, so this is the zero address (a documented sentinel: the account key —
|
|
189
|
+
* not an address — is the identity; the server names storage by the key's hash).
|
|
190
|
+
*/
|
|
191
|
+
/** The wallet address (its namespace) in wallet/signer mode; `undefined` in account-key mode. */
|
|
192
|
+
address;
|
|
193
|
+
/** The raw `ak_…` bearer token in account-key mode; `undefined` otherwise. */
|
|
194
|
+
accountKey;
|
|
195
|
+
endpoint;
|
|
196
|
+
network;
|
|
197
|
+
maxSpendUsd;
|
|
198
|
+
maxSessionSpendUsd;
|
|
199
|
+
/** Bounded internal retries on transient failures (network error / 5xx). */
|
|
200
|
+
maxRetries;
|
|
201
|
+
timeoutMs;
|
|
202
|
+
fetchImpl;
|
|
203
|
+
_ikm;
|
|
204
|
+
_km;
|
|
205
|
+
_kmPromise;
|
|
206
|
+
sessionSpentUsd = 0;
|
|
207
|
+
// --- Discounted Prepay state (opt-in; undefined => Pay-as-you-go, unchanged) ---
|
|
208
|
+
prepay;
|
|
209
|
+
/** Top-off amount in atomic USDC units (1e6), computed once in the constructor. */
|
|
210
|
+
topoffAtomic = 0;
|
|
211
|
+
/** Account-key top-off hook (account-key mode only; validated in the constructor). */
|
|
212
|
+
topoffPayer;
|
|
213
|
+
/**
|
|
214
|
+
* Account-key inline x402 transport hook (account-key mode only; validated in
|
|
215
|
+
* the constructor). Mutually exclusive with `topoffPayer` PER OP — `topoffPayer`
|
|
216
|
+
* always takes precedence when both are configured (see the call sites in
|
|
217
|
+
* set()/get(), which gate on `!this.topoffPayer`).
|
|
218
|
+
*/
|
|
219
|
+
opInlinePayer;
|
|
220
|
+
/** Last-known credit balance as an EXACT integer credit count (never USD floats). */
|
|
221
|
+
knownCredits;
|
|
222
|
+
/** Synchronous single-flight guard: at most one in-flight top-off at a time. */
|
|
223
|
+
topoffInFlight = false;
|
|
224
|
+
/**
|
|
225
|
+
* The in-flight SYNCHRONOUS top-off deposit (account mode), published so a concurrent op
|
|
226
|
+
* that hits a hard 402 but can't claim the single-flight can await it and retry.
|
|
227
|
+
*/
|
|
228
|
+
topoffPromise;
|
|
229
|
+
/** Cached last `PAYMENT-REQUIRED` header — the template for a proactive single-shot. */
|
|
230
|
+
challengeTemplate;
|
|
231
|
+
constructor(opts) {
|
|
232
|
+
this.endpoint = opts.endpoint.replace(/\/+$/, "");
|
|
233
|
+
this.network = opts.network ?? DEFAULT_NETWORK;
|
|
234
|
+
this.maxSpendUsd = opts.maxSpendUsd;
|
|
235
|
+
this.maxSessionSpendUsd = opts.maxSessionSpendUsd;
|
|
236
|
+
this.maxRetries = Math.max(0, Math.floor(opts.retries ?? 2));
|
|
237
|
+
this.timeoutMs = opts.timeoutMs;
|
|
238
|
+
this.fetchImpl = opts.fetch;
|
|
239
|
+
const isAccountMode = "accountKey" in opts && opts.accountKey != null;
|
|
240
|
+
if (opts.topoffPayer !== void 0) {
|
|
241
|
+
if (typeof opts.topoffPayer !== "function") {
|
|
242
|
+
throw new AgentKVError("topoffPayer must be a function", "invalid_config", 0);
|
|
243
|
+
}
|
|
244
|
+
if (!isAccountMode) {
|
|
245
|
+
throw new AgentKVError(
|
|
246
|
+
"topoffPayer is account-key-mode only; wallet mode pays its own top-offs",
|
|
247
|
+
"invalid_config",
|
|
248
|
+
0
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (!opts.prepay) {
|
|
252
|
+
throw new AgentKVError(
|
|
253
|
+
"topoffPayer requires prepay ({ watermark, topoff }) to control when it fires",
|
|
254
|
+
"invalid_config",
|
|
255
|
+
0
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
this.topoffPayer = opts.topoffPayer;
|
|
259
|
+
}
|
|
260
|
+
if (opts.opInlinePayer !== void 0) {
|
|
261
|
+
if (typeof opts.opInlinePayer !== "function") {
|
|
262
|
+
throw new AgentKVError("opInlinePayer must be a function", "invalid_config", 0);
|
|
263
|
+
}
|
|
264
|
+
if (!isAccountMode) {
|
|
265
|
+
throw new AgentKVError(
|
|
266
|
+
"opInlinePayer is account-key-mode only; wallet mode pays its own x402 challenges",
|
|
267
|
+
"invalid_config",
|
|
268
|
+
0
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
this.opInlinePayer = opts.opInlinePayer;
|
|
272
|
+
}
|
|
273
|
+
if (opts.prepay) {
|
|
274
|
+
if (isAccountMode && !opts.topoffPayer) {
|
|
275
|
+
throw new AgentKVError(
|
|
276
|
+
"prepay in account-key mode requires a topoffPayer hook (or fund via fundAccount() / 'agentkv fund')",
|
|
277
|
+
"invalid_config",
|
|
278
|
+
0
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
const topoffAtomic = toWholeAtomicUsd(opts.prepay.topoff);
|
|
282
|
+
if (topoffAtomic === null || !(opts.prepay.topoff >= 1)) {
|
|
283
|
+
throw new AgentKVError(
|
|
284
|
+
"prepay.topoff must be >= $1 (a whole number of atomic USDC units)",
|
|
285
|
+
"invalid_config",
|
|
286
|
+
0
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (!(opts.prepay.watermark >= 0)) {
|
|
290
|
+
throw new AgentKVError("prepay.watermark must be >= 0", "invalid_config", 0);
|
|
291
|
+
}
|
|
292
|
+
this.prepay = opts.prepay;
|
|
293
|
+
this.topoffAtomic = topoffAtomic;
|
|
294
|
+
}
|
|
295
|
+
if (isAccountMode) {
|
|
296
|
+
if (!isAccountKeyFormat(opts.accountKey)) {
|
|
297
|
+
throw new AgentKVError(
|
|
298
|
+
"accountKey must be a string of the form ak_<64 lowercase hex>",
|
|
299
|
+
"invalid_config",
|
|
300
|
+
0
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
if (!opts.encryptionKey) {
|
|
304
|
+
throw new AgentKVError(
|
|
305
|
+
"account-key mode requires an explicit encryptionKey (there is no wallet to derive one from)",
|
|
306
|
+
"invalid_config",
|
|
307
|
+
0
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
this.accountKey = opts.accountKey;
|
|
311
|
+
this.signer = void 0;
|
|
312
|
+
this.address = void 0;
|
|
313
|
+
this._ikm = normalizeEncryptionKey(opts.encryptionKey);
|
|
314
|
+
} else if ("privateKey" in opts && opts.privateKey != null) {
|
|
315
|
+
if ("encryptionKey" in opts && opts.encryptionKey) {
|
|
316
|
+
throw new AgentKVError(
|
|
317
|
+
"privateKey mode derives its encryption key from the wallet key; do not also pass encryptionKey \u2014 use { signer, encryptionKey } for an explicit AES key",
|
|
318
|
+
"invalid_config",
|
|
319
|
+
0
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
this.signer = privateKeyToAccount(opts.privateKey);
|
|
323
|
+
this._ikm = hexToBytes2(opts.privateKey);
|
|
324
|
+
this.address = this.signer.address;
|
|
325
|
+
} else if ("signer" in opts && opts.signer != null) {
|
|
326
|
+
this.signer = opts.signer;
|
|
327
|
+
if ("encryptionKey" in opts && opts.encryptionKey) {
|
|
328
|
+
this._ikm = normalizeEncryptionKey(opts.encryptionKey);
|
|
329
|
+
}
|
|
330
|
+
this.address = this.signer.address;
|
|
331
|
+
} else {
|
|
332
|
+
throw new AgentKVError(
|
|
333
|
+
"invalid auth config: provide one of { privateKey } | { signer } | { accountKey, encryptionKey }",
|
|
334
|
+
"invalid_config",
|
|
335
|
+
0
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Resolve (and memoize) the AES key. Async only for the sign-to-derive shape
|
|
341
|
+
* (`{signer}` with no encryptionKey): the key is `HKDF` over a fixed-message
|
|
342
|
+
* signature, which is stable ONLY for deterministic ECDSA signers (local keys /
|
|
343
|
+
* RFC-6979). Non-deterministic signers (some MPC/threshold backends) would
|
|
344
|
+
* derive a different key each run and fail to decrypt — those must pass an
|
|
345
|
+
* explicit `encryptionKey`.
|
|
346
|
+
*/
|
|
347
|
+
getKeyMaterial() {
|
|
348
|
+
if (this._km) return Promise.resolve(this._km);
|
|
349
|
+
if (!this._kmPromise) {
|
|
350
|
+
this._kmPromise = (async () => {
|
|
351
|
+
let ikm = this._ikm;
|
|
352
|
+
if (!ikm) {
|
|
353
|
+
if (!this.signer) {
|
|
354
|
+
throw new AgentKVError(
|
|
355
|
+
"no encryption key material: account-key mode requires an explicit encryptionKey",
|
|
356
|
+
"invalid_config",
|
|
357
|
+
0
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
const sig = await this.signer.signTypedData({
|
|
361
|
+
domain: ENC_DERIVATION_DOMAIN,
|
|
362
|
+
types: ENC_DERIVATION_TYPES,
|
|
363
|
+
primaryType: "Derive",
|
|
364
|
+
message: ENC_DERIVATION_MESSAGE
|
|
365
|
+
});
|
|
366
|
+
const sigBytes = hexToBytes2(sig);
|
|
367
|
+
if (sigBytes.length !== 65) {
|
|
368
|
+
throw new AgentKVError(
|
|
369
|
+
`sign-to-derive expected a 65-byte EIP-712 signature but got ${sigBytes.length} bytes; this signer's format is unstable for key derivation \u2014 construct with an explicit encryptionKey`,
|
|
370
|
+
"invalid_config",
|
|
371
|
+
0
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
ikm = sigBytes;
|
|
375
|
+
}
|
|
376
|
+
const km = deriveKeyMaterial(ikm);
|
|
377
|
+
this._km = km;
|
|
378
|
+
return km;
|
|
379
|
+
})().catch((err) => {
|
|
380
|
+
this._kmPromise = void 0;
|
|
381
|
+
throw err;
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return this._kmPromise;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Decrypt a value envelope with the current value key, binding the key's blind-index
|
|
388
|
+
* digest into the AAD so a value the server serves for the wrong key fails the auth tag.
|
|
389
|
+
*/
|
|
390
|
+
async decryptValue(packed, key) {
|
|
391
|
+
const km = await this.getKeyMaterial();
|
|
392
|
+
return decrypt(km.value, packed, hashKey(km.mac, key));
|
|
393
|
+
}
|
|
394
|
+
assertSpend(usd, opts = {}) {
|
|
395
|
+
if (!opts.bypassPerOpCap && this.maxSpendUsd !== void 0 && usd > this.maxSpendUsd) {
|
|
396
|
+
throw new SpendCapError(`spend $${usd} exceeds per-call cap $${this.maxSpendUsd}`);
|
|
397
|
+
}
|
|
398
|
+
if (this.maxSessionSpendUsd !== void 0 && this.sessionSpentUsd + usd > this.maxSessionSpendUsd) {
|
|
399
|
+
throw new SpendCapError(
|
|
400
|
+
`spend $${usd} would exceed session cap $${this.maxSessionSpendUsd} (spent $${this.sessionSpentUsd})`
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
recordSpend(usd) {
|
|
405
|
+
this.sessionSpentUsd += usd;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Reject a SERVER-QUOTED per-op price above a sane ceiling in the DEFAULT (cap-less) config.
|
|
409
|
+
* When `maxSpendUsd` is set, `assertSpend` already bounds the op price; when it is NOT set,
|
|
410
|
+
* a compromised or spoofed worker could otherwise answer a routine $0.005 read with a 402
|
|
411
|
+
* challenge for the wallet's whole balance and the client would sign the EIP-3009
|
|
412
|
+
* authorization. Callers who genuinely need a pricier op opt in via `maxSpendUsd`.
|
|
413
|
+
*/
|
|
414
|
+
assertOpPriceCeiling(usd) {
|
|
415
|
+
if (this.maxSpendUsd === void 0 && usd > DEFAULT_MAX_OP_USD) {
|
|
416
|
+
throw new SpendCapError(
|
|
417
|
+
`server-quoted op price $${usd} exceeds the built-in $${DEFAULT_MAX_OP_USD} op ceiling; set maxSpendUsd to allow a higher per-op charge`
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* The effective per-op ceiling (USD) for an inline-payer op: the caller's `maxSpendUsd`
|
|
423
|
+
* when set (they opted into that bound), else the built-in default ceiling. Handed to the
|
|
424
|
+
* hook as its hard `maxAmountAtomic`, and pre-reserved against the session cap before paying.
|
|
425
|
+
*/
|
|
426
|
+
inlineOpCeilingUsd() {
|
|
427
|
+
return this.maxSpendUsd ?? DEFAULT_MAX_OP_USD;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* The on-chain settlement txHash from a response's PAYMENT-RESPONSE header, or ""
|
|
431
|
+
* when the server served the op from existing credits (so the attached top-off
|
|
432
|
+
* authorization was NEVER settled — it just expires) or the header is absent. The
|
|
433
|
+
* worker emits PAYMENT-RESPONSE = base64(JSON `{ success, payer, amount, txHash }`)
|
|
434
|
+
* on any paid 200, with `txHash: ""` on the credit hot path. A proactive single-shot
|
|
435
|
+
* top-off must only count toward session spend when this is non-empty — otherwise no
|
|
436
|
+
* USDC moved and recording it inflates sessionSpentUsd, prematurely tripping the cap.
|
|
437
|
+
*
|
|
438
|
+
* Accepted trade-off: in a doubly-rare crash window (the server's settle mined on-chain
|
|
439
|
+
* but its ledger row was lost, AND the response was lost so the client retries), the
|
|
440
|
+
* worker's already-used-authorization recovery returns success with txHash "" even
|
|
441
|
+
* though USDC did move. The client then under-counts that top-off by one, making the
|
|
442
|
+
* local session cap marginally lenient — no funds are lost (the amount is still minted
|
|
443
|
+
* as credits). This is unavoidable (the worker cannot distinguish that case from a
|
|
444
|
+
* plain credit-served op) and far cheaper than the systematic L3 over-count it replaces.
|
|
445
|
+
*/
|
|
446
|
+
settledTxHash(res) {
|
|
447
|
+
const header = res.headers.get("PAYMENT-RESPONSE");
|
|
448
|
+
if (!header) return "";
|
|
449
|
+
try {
|
|
450
|
+
const parsed = JSON.parse(decodeBase64Utf8(header));
|
|
451
|
+
return typeof parsed.txHash === "string" ? parsed.txHash : "";
|
|
452
|
+
} catch {
|
|
453
|
+
return "";
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Issue a request with bounded internal retry on TRANSIENT failures only: a
|
|
458
|
+
* thrown fetch (network error / lost response) or a 5xx. `build()` is re-invoked
|
|
459
|
+
* per attempt so the credit path can re-sign identity with a FRESH nonce each
|
|
460
|
+
* time, while the op's stable Idempotency-Key (and pinned EIP-3009 nonce on paid
|
|
461
|
+
* ops) makes a retry of an already-processed request dedupe server-side — so a
|
|
462
|
+
* lost response that the server already charged is recovered without a second
|
|
463
|
+
* charge. NOT retried: any 2xx/4xx (incl. the 402 credit->pay handoff, 401, 404)
|
|
464
|
+
* — those are returned as-is for the caller's normal handling. The bound is kept
|
|
465
|
+
* small so a re-sent paid authorization cannot outlive its validBefore.
|
|
466
|
+
*
|
|
467
|
+
* The retry MECHANICS (transient-status detection, backoff,
|
|
468
|
+
* Retry-After honoring) were extracted to `@agentx402-ai/core`'s `fetchWithRetry`
|
|
469
|
+
* as a pure function parameterized by `maxRetries` — this method is now a
|
|
470
|
+
* thin delegating wrapper so every existing `this.fetchWithRetry(...)` call
|
|
471
|
+
* site above is unchanged.
|
|
472
|
+
*/
|
|
473
|
+
fetchWithRetry(url, build) {
|
|
474
|
+
return fetchWithRetry(url, build, this.maxRetries, {
|
|
475
|
+
timeoutMs: this.timeoutMs,
|
|
476
|
+
fetchImpl: this.fetchImpl
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
// --- Discounted Prepay helpers -------------------------------------------
|
|
480
|
+
/**
|
|
481
|
+
* Update prepay tracking from any server response. Reads the exact integer
|
|
482
|
+
* credit balance (`X-AgentKV-Credits-Remaining`) and caches the most recent
|
|
483
|
+
* `PAYMENT-REQUIRED` challenge as the proactive single-shot template (so a
|
|
484
|
+
* top-off needs no preflight request). Safe to call when prepay is disabled.
|
|
485
|
+
*/
|
|
486
|
+
trackBalance(res) {
|
|
487
|
+
const credits = res.headers.get("X-AgentKV-Credits-Remaining");
|
|
488
|
+
if (credits !== null && credits !== "") {
|
|
489
|
+
const n = Number(credits);
|
|
490
|
+
if (Number.isFinite(n)) this.knownCredits = n;
|
|
491
|
+
}
|
|
492
|
+
const challenge = res.headers.get("PAYMENT-REQUIRED");
|
|
493
|
+
if (challenge) this.challengeTemplate = challenge;
|
|
494
|
+
}
|
|
495
|
+
/** Watermark (USD) expressed in EXACT integer credits (1 credit = CREDIT_VALUE_USD = $0.0001,
|
|
496
|
+
* so $1 = 10,000 credits — matching the worker's mint rate). */
|
|
497
|
+
watermarkCredits() {
|
|
498
|
+
return Math.round(this.prepay.watermark / CREDIT_VALUE_USD);
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Synchronous single-flight claim for a proactive top-off. CRITICAL: there is
|
|
502
|
+
* NO `await` between the watermark check and setting `topoffInFlight = true`,
|
|
503
|
+
* and this must be called at the very top of set/get before any await.
|
|
504
|
+
* Otherwise two concurrent ops both pass the check and each fire a separate
|
|
505
|
+
* top-off with distinct fresh nonces the server can't dedupe (double charge).
|
|
506
|
+
* Returns true for exactly one concurrent op below the watermark; the caller
|
|
507
|
+
* MUST clear `topoffInFlight` in a `finally`. Losers take the identity path.
|
|
508
|
+
*/
|
|
509
|
+
tryClaimTopoff() {
|
|
510
|
+
if (!this.prepay || this.prepay.async || this.topoffInFlight || this.knownCredits === void 0 || this.knownCredits >= this.watermarkCredits()) {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
this.topoffInFlight = true;
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Single-flight claim at a hard 402 (insufficient credits). Unlike
|
|
518
|
+
* `tryClaimTopoff` this ignores the watermark — a 402 already proves credits
|
|
519
|
+
* are short — but still claims the flag synchronously so concurrent 402s don't
|
|
520
|
+
* each fire a top-off. Returns true only if the flag was free; the caller MUST
|
|
521
|
+
* clear it in a `finally`. In `async` mode we leave the 402 to be paid at the
|
|
522
|
+
* op price (the background deposit replenishes credits separately).
|
|
523
|
+
*/
|
|
524
|
+
tryClaimTopoffOnFault() {
|
|
525
|
+
if (!this.prepay || this.prepay.async || this.topoffInFlight) return false;
|
|
526
|
+
this.topoffInFlight = true;
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Run a synchronous account-mode top-off while PUBLISHING its in-flight promise, so a
|
|
531
|
+
* concurrent op that hits a hard 402 (and can't claim the single-flight) can await THIS
|
|
532
|
+
* deposit and retry rather than surfacing the 402. The caller holds the single-flight claim.
|
|
533
|
+
*/
|
|
534
|
+
async runSharedTopoff() {
|
|
535
|
+
const p = this.runAccountTopoff();
|
|
536
|
+
this.topoffPromise = p;
|
|
537
|
+
try {
|
|
538
|
+
await p;
|
|
539
|
+
} finally {
|
|
540
|
+
if (this.topoffPromise === p) this.topoffPromise = void 0;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Detached async top-off (opt-in via `prepay.async`). When below the watermark
|
|
545
|
+
* and no top-off is in flight, fire a deposit WITHOUT awaiting it in the op
|
|
546
|
+
* path. Documented races: the balance read is point-in-time so the trigger can
|
|
547
|
+
* be stale, and a deposit settling between an op's read and this check can make
|
|
548
|
+
* the top-off redundant; the single-flight flag bounds these to at most one
|
|
549
|
+
* outstanding deposit, but cannot serialize against an op already past its own
|
|
550
|
+
* check. Use the (default) synchronous single-shot for exactly-bounded spend.
|
|
551
|
+
*
|
|
552
|
+
* Account-key mode: there is no signing wallet, so the detached
|
|
553
|
+
* deposit is dispatched through `runAccountTopoff()` (the `topoffPayer` hook)
|
|
554
|
+
* instead of `runDeposit` (which throws `no_signer` in account mode). Wallet
|
|
555
|
+
* mode is completely unchanged below.
|
|
556
|
+
*/
|
|
557
|
+
maybeAsyncTopoff() {
|
|
558
|
+
if (!this.prepay?.async || this.topoffInFlight || this.knownCredits === void 0 || this.knownCredits >= this.watermarkCredits()) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (this.maxSessionSpendUsd !== void 0 && this.sessionSpentUsd + this.prepay.topoff > this.maxSessionSpendUsd) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
this.topoffInFlight = true;
|
|
565
|
+
if (this.accountKey) {
|
|
566
|
+
void this.runAccountTopoff().catch(() => {
|
|
567
|
+
}).finally(() => {
|
|
568
|
+
this.topoffInFlight = false;
|
|
569
|
+
});
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
void this.runDeposit(this.prepay.topoff, { bypassPerOpCap: true }).catch(() => {
|
|
573
|
+
}).finally(() => {
|
|
574
|
+
this.topoffInFlight = false;
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Whether a top-off of `prepay.topoff` fits under the cumulative SESSION cap.
|
|
579
|
+
* Top-offs are deliberately NOT gated on the per-op `maxSpendUsd` (which bounds
|
|
580
|
+
* individual pay-per-op charges); when over the session cap we downgrade to
|
|
581
|
+
* pay-per-op rather than throwing.
|
|
582
|
+
*/
|
|
583
|
+
topoffFitsSessionCap() {
|
|
584
|
+
if (this.maxSessionSpendUsd === void 0) return true;
|
|
585
|
+
return this.sessionSpentUsd + this.prepay.topoff <= this.maxSessionSpendUsd;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Single source of truth for BOTH the EIP-712-signed pathname (`path`) and the
|
|
589
|
+
* URL to fetch (`url`), so they can never diverge — a divergence silently
|
|
590
|
+
* breaks identity auth: the worker verifies over the RECEIVED path and
|
|
591
|
+
* recovers a phantom address if it differs from what the client signed.
|
|
592
|
+
* `base` is the un-prefixed pathname; the fetched/signed path is `/v1` + `base`
|
|
593
|
+
* (list-keys overrides it to `/v1/kv`). `query` is appended to `url`
|
|
594
|
+
* ONLY — EIP-712 binds the pathname, never the query string.
|
|
595
|
+
*/
|
|
596
|
+
route(spec) {
|
|
597
|
+
const path = spec.versioned ?? `${V1}${spec.base}`;
|
|
598
|
+
const q = spec.query ? `?${spec.query}` : "";
|
|
599
|
+
return { path, url: `${this.endpoint}${path}${q}` };
|
|
600
|
+
}
|
|
601
|
+
// kv entry route (set/get/delete). `digest` is base64url (from hashKey(), which
|
|
602
|
+
// returns toBase64Url output — URL-safe [A-Za-z0-9_-], not hex), so no extra
|
|
603
|
+
// encoding is needed and the signed path matches the fetched path byte-for-byte.
|
|
604
|
+
kvRoute(digest) {
|
|
605
|
+
return this.route({ base: `/kv/${digest}` });
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Per-op auth headers. In account-key mode this is the `Authorization: Bearer
|
|
609
|
+
* ak_…` header (server hashes it to name storage + debit credits); in wallet
|
|
610
|
+
* mode it is the EIP-712 identity signature. Used by every op so the same call
|
|
611
|
+
* site picks the right scheme. Async to share the signature of `identityHeaders`.
|
|
612
|
+
*/
|
|
613
|
+
async authHeaders(method, path) {
|
|
614
|
+
if (this.accountKey) return buildBearerHeaders(this.accountKey);
|
|
615
|
+
return { ...await this.identityHeaders(method, path) };
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* The signing wallet, asserted present. Every x402 path (set/get top-off + 402
|
|
619
|
+
* fallback, deposit) is gated behind `!this.accountKey` and so always has a
|
|
620
|
+
* signer; this narrows the optional type at those call sites (and fails loudly
|
|
621
|
+
* if that invariant is ever broken).
|
|
622
|
+
*/
|
|
623
|
+
requireSigner() {
|
|
624
|
+
if (!this.signer) {
|
|
625
|
+
throw new AgentKVError(
|
|
626
|
+
"no signer: this operation requires a signing wallet",
|
|
627
|
+
"invalid_config",
|
|
628
|
+
0
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
return this.signer;
|
|
632
|
+
}
|
|
633
|
+
/** EIP-712 identity headers with the deployment host bound into the signature (prevents cross-deployment signature replay). */
|
|
634
|
+
identityHeaders(method, path) {
|
|
635
|
+
if (!this.signer) {
|
|
636
|
+
throw new AgentKVError(
|
|
637
|
+
"no signer: this operation requires a signing wallet",
|
|
638
|
+
"invalid_config",
|
|
639
|
+
0
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
return buildIdentityHeaders(this.signer, {
|
|
643
|
+
method,
|
|
644
|
+
path,
|
|
645
|
+
host: new URL(this.endpoint).host,
|
|
646
|
+
network: this.network
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Shared money/transport orchestrator behind set() and getInternal() — the single copy of
|
|
651
|
+
* the flow both share: account-key (bearer) mode with proactive + hard-402 top-off and the
|
|
652
|
+
* inline-payer path; wallet mode with the proactive single-shot, credit path, and 402
|
|
653
|
+
* pay-and-retry — including the single-flight top-off accounting and settled-txHash spend
|
|
654
|
+
* gating. Per-op differences (method/body, credit cost, 404 handling, success/inline
|
|
655
|
+
* parsing) come from `spec`. The CALLER must claim the single-flight top-off SYNCHRONOUSLY
|
|
656
|
+
* (before any await) and pass it in `flight`; it may be re-claimed here on a cold-start hard
|
|
657
|
+
* 402, and the caller's `finally` releases it.
|
|
658
|
+
*/
|
|
659
|
+
async performOp(flight, spec) {
|
|
660
|
+
const { path, url, idempotencyKey, creditCostUsd, label } = spec;
|
|
661
|
+
if (this.accountKey) {
|
|
662
|
+
let toppedOff = false;
|
|
663
|
+
if (flight.claimed && this.topoffPayer && this.topoffFitsSessionCap()) {
|
|
664
|
+
try {
|
|
665
|
+
await this.runSharedTopoff();
|
|
666
|
+
toppedOff = true;
|
|
667
|
+
} catch {
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
this.maybeAsyncTopoff();
|
|
671
|
+
this.assertSpend(creditCostUsd);
|
|
672
|
+
const sendBearer = () => this.fetchWithRetry(
|
|
673
|
+
url,
|
|
674
|
+
() => spec.buildRequest({
|
|
675
|
+
"Idempotency-Key": idempotencyKey,
|
|
676
|
+
...buildBearerHeaders(this.accountKey)
|
|
677
|
+
})
|
|
678
|
+
);
|
|
679
|
+
let res2 = await sendBearer();
|
|
680
|
+
this.trackBalance(res2);
|
|
681
|
+
if (res2.status === 402 && this.topoffPayer && !toppedOff) {
|
|
682
|
+
if (!flight.claimed) flight.claimed = this.tryClaimTopoffOnFault();
|
|
683
|
+
if (flight.claimed && this.topoffFitsSessionCap()) {
|
|
684
|
+
await this.runSharedTopoff();
|
|
685
|
+
res2 = await sendBearer();
|
|
686
|
+
this.trackBalance(res2);
|
|
687
|
+
} else if (this.topoffPromise) {
|
|
688
|
+
await this.topoffPromise.catch(() => {
|
|
689
|
+
});
|
|
690
|
+
res2 = await sendBearer();
|
|
691
|
+
this.trackBalance(res2);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
if (res2.status === 402 && this.opInlinePayer && !this.topoffPayer) {
|
|
695
|
+
const inlineCeilingUsd = this.inlineOpCeilingUsd();
|
|
696
|
+
this.assertSpend(inlineCeilingUsd);
|
|
697
|
+
const reqInit = spec.buildRequest({
|
|
698
|
+
"Idempotency-Key": idempotencyKey,
|
|
699
|
+
...buildBearerHeaders(this.accountKey)
|
|
700
|
+
});
|
|
701
|
+
const inlineRes = await this.opInlinePayer({
|
|
702
|
+
url,
|
|
703
|
+
method: spec.method,
|
|
704
|
+
body: reqInit.body,
|
|
705
|
+
headers: reqInit.headers,
|
|
706
|
+
// The hook MUST NOT settle more than the effective per-op ceiling.
|
|
707
|
+
maxAmountAtomic: Math.round(inlineCeilingUsd * 1e6)
|
|
708
|
+
});
|
|
709
|
+
if (inlineRes.status === 404 && spec.notFound) return spec.notFound();
|
|
710
|
+
if (inlineRes.status < 200 || inlineRes.status >= 300) {
|
|
711
|
+
throw this.errorFromBody(inlineRes.status, inlineRes.body, label);
|
|
712
|
+
}
|
|
713
|
+
this.recordSpend(this.inlineSettledAmountUsd(inlineRes.headers) ?? creditCostUsd);
|
|
714
|
+
return spec.parseInline(inlineRes);
|
|
715
|
+
}
|
|
716
|
+
if (res2.status === 404 && spec.notFound) return spec.notFound();
|
|
717
|
+
if (!res2.ok) throw await this.asError(res2, label);
|
|
718
|
+
this.recordSpend(creditCostUsd);
|
|
719
|
+
return spec.parseSuccess(res2);
|
|
720
|
+
}
|
|
721
|
+
if (flight.claimed && this.challengeTemplate && this.topoffFitsSessionCap()) {
|
|
722
|
+
let paymentSignature;
|
|
723
|
+
try {
|
|
724
|
+
paymentSignature = await buildPaymentHeader(this.requireSigner(), this.challengeTemplate, {
|
|
725
|
+
amountAtomic: this.topoffAtomic,
|
|
726
|
+
expectedNetwork: this.network,
|
|
727
|
+
// Pin the nonce to the op's idempotency key so a retry reuses the auth and the
|
|
728
|
+
// server dedupes the mint + the op.
|
|
729
|
+
nonce: nonceFromIdempotencyKey(idempotencyKey)
|
|
730
|
+
});
|
|
731
|
+
} catch {
|
|
732
|
+
this.challengeTemplate = void 0;
|
|
733
|
+
}
|
|
734
|
+
if (paymentSignature !== void 0) {
|
|
735
|
+
const res2 = await this.fetchWithRetry(
|
|
736
|
+
url,
|
|
737
|
+
() => spec.buildRequest({
|
|
738
|
+
"Idempotency-Key": idempotencyKey,
|
|
739
|
+
"PAYMENT-SIGNATURE": paymentSignature
|
|
740
|
+
})
|
|
741
|
+
);
|
|
742
|
+
this.trackBalance(res2);
|
|
743
|
+
if (res2.status === 404 && spec.notFound) return spec.notFound();
|
|
744
|
+
if (res2.status !== 402) {
|
|
745
|
+
if (!res2.ok) throw await this.asError(res2, label);
|
|
746
|
+
if (this.settledTxHash(res2)) this.recordSpend(this.prepay.topoff);
|
|
747
|
+
return spec.parseSuccess(res2);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
this.maybeAsyncTopoff();
|
|
752
|
+
let res = await this.fetchWithRetry(
|
|
753
|
+
url,
|
|
754
|
+
async () => spec.buildRequest({
|
|
755
|
+
"Idempotency-Key": idempotencyKey,
|
|
756
|
+
...await this.identityHeaders(spec.method, path)
|
|
757
|
+
})
|
|
758
|
+
);
|
|
759
|
+
this.trackBalance(res);
|
|
760
|
+
if (res.status === 402) {
|
|
761
|
+
const challenge = res.headers.get("PAYMENT-REQUIRED");
|
|
762
|
+
if (!challenge) {
|
|
763
|
+
throw await this.asError(res, "payment required but no PAYMENT-REQUIRED challenge");
|
|
764
|
+
}
|
|
765
|
+
if (!flight.claimed) flight.claimed = this.tryClaimTopoffOnFault();
|
|
766
|
+
const topoffHere = flight.claimed && this.topoffFitsSessionCap();
|
|
767
|
+
const usd = topoffHere ? this.prepay.topoff : challengePriceUsd(challenge, void 0, this.network);
|
|
768
|
+
if (!topoffHere) {
|
|
769
|
+
this.assertOpPriceCeiling(usd);
|
|
770
|
+
this.assertSpend(usd);
|
|
771
|
+
}
|
|
772
|
+
const paymentSignature = await buildPaymentHeader(this.requireSigner(), challenge, {
|
|
773
|
+
amountAtomic: topoffHere ? this.topoffAtomic : void 0,
|
|
774
|
+
expectedNetwork: this.network,
|
|
775
|
+
nonce: nonceFromIdempotencyKey(idempotencyKey)
|
|
776
|
+
});
|
|
777
|
+
res = await this.fetchWithRetry(
|
|
778
|
+
url,
|
|
779
|
+
() => spec.buildRequest({
|
|
780
|
+
"Idempotency-Key": idempotencyKey,
|
|
781
|
+
"PAYMENT-SIGNATURE": paymentSignature
|
|
782
|
+
})
|
|
783
|
+
);
|
|
784
|
+
this.trackBalance(res);
|
|
785
|
+
if (res.ok && (!topoffHere || this.settledTxHash(res))) this.recordSpend(usd);
|
|
786
|
+
}
|
|
787
|
+
if (res.status === 404 && spec.notFound) return spec.notFound();
|
|
788
|
+
if (!res.ok) throw await this.asError(res, label);
|
|
789
|
+
return spec.parseSuccess(res);
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Write an encrypted value. The value is JSON-stringified and AES-256-GCM
|
|
793
|
+
* encrypted client-side; the server only ever stores ciphertext. `null` and
|
|
794
|
+
* `undefined` are rejected (`invalid_value`) so a `null` from get() unambiguously
|
|
795
|
+
* means "missing key" — use delete() to remove a key. Tries the credit path first
|
|
796
|
+
* (EIP-712 identity signature); if credits are insufficient the server returns a 402
|
|
797
|
+
* x402 challenge and the client pays. A stable Idempotency-Key is reused across that
|
|
798
|
+
* retry so the write is exactly-once.
|
|
799
|
+
*/
|
|
800
|
+
async set(key, value, opts = {}) {
|
|
801
|
+
const flight = { claimed: this.tryClaimTopoff() };
|
|
802
|
+
try {
|
|
803
|
+
const plaintext = JSON.stringify(value);
|
|
804
|
+
if (value === null || plaintext === void 0) {
|
|
805
|
+
throw new AgentKVError(
|
|
806
|
+
"cannot store null or undefined; use delete() to remove a key",
|
|
807
|
+
"invalid_value",
|
|
808
|
+
0
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
const km = await this.getKeyMaterial();
|
|
812
|
+
const digest = hashKey(km.mac, key);
|
|
813
|
+
const ciphertext = await encrypt(km.value, plaintext, digest);
|
|
814
|
+
const body = {
|
|
815
|
+
value: ciphertext,
|
|
816
|
+
key_name: await encrypt(km.keyName, key)
|
|
817
|
+
};
|
|
818
|
+
if (opts.ttlDays !== void 0) body.ttl_days = opts.ttlDays;
|
|
819
|
+
if (opts.strictTtl !== void 0) body.strict_ttl = opts.strictTtl;
|
|
820
|
+
const payload = JSON.stringify(body);
|
|
821
|
+
const idempotencyKey = opts.idempotencyKey ?? freshNonce();
|
|
822
|
+
const { path, url } = this.kvRoute(digest);
|
|
823
|
+
return await this.performOp(flight, {
|
|
824
|
+
method: "POST",
|
|
825
|
+
path,
|
|
826
|
+
url,
|
|
827
|
+
idempotencyKey,
|
|
828
|
+
creditCostUsd: ACCOUNT_WRITE_USD,
|
|
829
|
+
label: "set failed",
|
|
830
|
+
buildRequest: (headers) => ({
|
|
831
|
+
method: "POST",
|
|
832
|
+
headers: { "content-type": "application/json", ...headers },
|
|
833
|
+
body: payload
|
|
834
|
+
}),
|
|
835
|
+
parseSuccess: async (res) => await res.json(),
|
|
836
|
+
parseInline: async (inlineRes) => JSON.parse(inlineRes.body)
|
|
837
|
+
});
|
|
838
|
+
} finally {
|
|
839
|
+
if (flight.claimed) this.topoffInFlight = false;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Read and decrypt a value. Tries the credit path first (EIP-712 identity);
|
|
844
|
+
* if credits are insufficient the server returns a 402 x402 challenge and the
|
|
845
|
+
* client pays, then retries. Returns null if the key is missing or expired (404);
|
|
846
|
+
* stored values are never null (set rejects it), so null unambiguously means absent.
|
|
847
|
+
*/
|
|
848
|
+
async get(key, opts = {}) {
|
|
849
|
+
const { value } = await this.getInternal(key, opts);
|
|
850
|
+
return value;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Like `get`, but ALSO surfaces the machine-readable usage envelope the server
|
|
854
|
+
* attaches to a paid read's success body — a separate, additive
|
|
855
|
+
* accessor so `get()` keeps its narrower `T | null` signature (no existing
|
|
856
|
+
* caller breaks). `usage` is absent when the key was missing/expired (404 —
|
|
857
|
+
* the read op itself is never charged on a miss, see the DO's 404-before-charge
|
|
858
|
+
* precheck — though a proactive top-off, if one was triggered for this call,
|
|
859
|
+
* is a separate deposit charge and can still happen alongside a miss) or when
|
|
860
|
+
* talking to a server that predates the usage envelope.
|
|
861
|
+
*/
|
|
862
|
+
async getWithUsage(key, opts = {}) {
|
|
863
|
+
return this.getInternal(key, opts);
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Shared implementation behind `get`/`getWithUsage`. Tries the credit path
|
|
867
|
+
* first (EIP-712 identity); if credits are insufficient the server returns a
|
|
868
|
+
* 402 x402 challenge and the client pays, then retries. `value` is null if
|
|
869
|
+
* the key is missing or expired (404); stored values are never null (set
|
|
870
|
+
* rejects it), so null unambiguously means absent.
|
|
871
|
+
*/
|
|
872
|
+
async getInternal(key, opts = {}) {
|
|
873
|
+
const flight = { claimed: this.tryClaimTopoff() };
|
|
874
|
+
try {
|
|
875
|
+
const digest = hashKey((await this.getKeyMaterial()).mac, key);
|
|
876
|
+
const { path, url } = this.kvRoute(digest);
|
|
877
|
+
const idempotencyKey = opts.idempotencyKey ?? freshNonce();
|
|
878
|
+
const parseBody = async (raw) => {
|
|
879
|
+
const data = JSON.parse(raw);
|
|
880
|
+
const decryptedText = await this.decryptValue(data.value, key);
|
|
881
|
+
return { value: JSON.parse(decryptedText), usage: data.usage };
|
|
882
|
+
};
|
|
883
|
+
return await this.performOp(flight, {
|
|
884
|
+
method: "GET",
|
|
885
|
+
path,
|
|
886
|
+
url,
|
|
887
|
+
idempotencyKey,
|
|
888
|
+
creditCostUsd: ACCOUNT_READ_USD,
|
|
889
|
+
label: "get failed",
|
|
890
|
+
buildRequest: (headers) => ({ method: "GET", headers }),
|
|
891
|
+
parseSuccess: async (res) => parseBody(await res.text()),
|
|
892
|
+
parseInline: async (inlineRes) => parseBody(inlineRes.body),
|
|
893
|
+
notFound: () => ({ value: null })
|
|
894
|
+
});
|
|
895
|
+
} finally {
|
|
896
|
+
if (flight.claimed) this.topoffInFlight = false;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Delete a key. Free operation. Authenticated with the account-key bearer in
|
|
901
|
+
* account mode, else an EIP-712 identity signature (fresh nonce + timestamp).
|
|
902
|
+
* The digest is computed from the local key material either way.
|
|
903
|
+
*/
|
|
904
|
+
async delete(key) {
|
|
905
|
+
const digest = hashKey((await this.getKeyMaterial()).mac, key);
|
|
906
|
+
const { path, url } = this.kvRoute(digest);
|
|
907
|
+
const res = await this.fetchWithRetry(url, async () => ({
|
|
908
|
+
method: "DELETE",
|
|
909
|
+
headers: { ...await this.authHeaders("DELETE", path) }
|
|
910
|
+
}));
|
|
911
|
+
if (!res.ok) {
|
|
912
|
+
throw await this.asError(res, "delete failed");
|
|
913
|
+
}
|
|
914
|
+
return await res.json();
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* List the wallet's keys. The server returns opaque per-wallet digests plus each key's
|
|
918
|
+
* ENCRYPTED name; this decrypts the names locally and returns them — the server never
|
|
919
|
+
* sees a plaintext key name. Free (EIP-712 identity signed). Paginated: pass the returned
|
|
920
|
+
* `cursor` to fetch the next page; `cursor` is null once exhausted.
|
|
921
|
+
*/
|
|
922
|
+
async listKeys(opts = {}) {
|
|
923
|
+
const km = await this.getKeyMaterial();
|
|
924
|
+
const params = new URLSearchParams();
|
|
925
|
+
if (opts.cursor) params.set("cursor", opts.cursor);
|
|
926
|
+
if (opts.limit !== void 0) params.set("limit", String(opts.limit));
|
|
927
|
+
const qs = params.toString();
|
|
928
|
+
const { path, url } = this.route({
|
|
929
|
+
base: "/list-keys",
|
|
930
|
+
versioned: `${V1}/kv`,
|
|
931
|
+
query: qs || void 0
|
|
932
|
+
});
|
|
933
|
+
const res = await this.fetchWithRetry(url, async () => ({
|
|
934
|
+
method: "GET",
|
|
935
|
+
headers: { ...await this.authHeaders("GET", path) }
|
|
936
|
+
}));
|
|
937
|
+
if (!res.ok) throw await this.asError(res, "list-keys failed");
|
|
938
|
+
const data = await res.json();
|
|
939
|
+
const keys = (await Promise.all(
|
|
940
|
+
data.items.filter((i) => i.key_name != null).map(async (i) => {
|
|
941
|
+
try {
|
|
942
|
+
return await decrypt(km.keyName, i.key_name);
|
|
943
|
+
} catch {
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
})
|
|
947
|
+
)).filter((k) => k !== null);
|
|
948
|
+
return { keys, cursor: data.cursor };
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Read the pre-paid credit balance. Free. Account-key bearer in account mode,
|
|
952
|
+
* else an EIP-712 identity signature.
|
|
953
|
+
*/
|
|
954
|
+
async balance() {
|
|
955
|
+
const { path, url } = this.route({ base: "/credits/balance" });
|
|
956
|
+
const res = await this.fetchWithRetry(url, async () => ({
|
|
957
|
+
method: "GET",
|
|
958
|
+
headers: { ...await this.authHeaders("GET", path) }
|
|
959
|
+
}));
|
|
960
|
+
this.trackBalance(res);
|
|
961
|
+
if (!res.ok) {
|
|
962
|
+
throw await this.asError(res, "balance failed");
|
|
963
|
+
}
|
|
964
|
+
const body = await res.json();
|
|
965
|
+
if (this.prepay && Number.isFinite(body.balance)) this.knownCredits = body.balance;
|
|
966
|
+
return body.balance;
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Buy credits with an x402 payment. `amountUsd` must be at least $1; any
|
|
970
|
+
* amount is accepted (no fixed tiers). This settles on-chain once via the
|
|
971
|
+
* facilitator; the returned credits are then spendable by set/get with no
|
|
972
|
+
* further payment.
|
|
973
|
+
*/
|
|
974
|
+
async deposit(amountUsd, opts = {}) {
|
|
975
|
+
if (this.accountKey && this.topoffPayer) {
|
|
976
|
+
const runAccountDeposit = async () => {
|
|
977
|
+
this.assertSpend(amountUsd);
|
|
978
|
+
await this.runAccountTopoff(amountUsd);
|
|
979
|
+
const balance = await this.balance();
|
|
980
|
+
return { credits_added: Math.round(amountUsd / CREDIT_VALUE_USD), balance };
|
|
981
|
+
};
|
|
982
|
+
if (this.topoffInFlight) {
|
|
983
|
+
return runAccountDeposit();
|
|
984
|
+
}
|
|
985
|
+
this.topoffInFlight = true;
|
|
986
|
+
try {
|
|
987
|
+
return await runAccountDeposit();
|
|
988
|
+
} finally {
|
|
989
|
+
this.topoffInFlight = false;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
if (!this.prepay || this.topoffInFlight) {
|
|
993
|
+
return this.runDeposit(amountUsd, opts);
|
|
994
|
+
}
|
|
995
|
+
this.topoffInFlight = true;
|
|
996
|
+
try {
|
|
997
|
+
return await this.runDeposit(amountUsd, opts);
|
|
998
|
+
} finally {
|
|
999
|
+
this.topoffInFlight = false;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Account-key top-off: delegate payment of `${endpoint}/account/deposit` to the
|
|
1004
|
+
* configured `topoffPayer` (a managed account has no signing wallet to sign an
|
|
1005
|
+
* x402 payment). The caller must hold the single-flight claim and have checked
|
|
1006
|
+
* `topoffFitsSessionCap()`. On resolve (= the deposit SETTLED) the top-off is
|
|
1007
|
+
* recorded against the session budget only — top-offs are credit purchases, not
|
|
1008
|
+
* per-op charges, so the per-op cap is deliberately not consulted (mirrors the
|
|
1009
|
+
* wallet-mode top-off budget rules). A rejection is wrapped as
|
|
1010
|
+
* `account_topoff_failed`; the ak_ bearer is never included in the message.
|
|
1011
|
+
*/
|
|
1012
|
+
/**
|
|
1013
|
+
* `amountUsd` generalizes this beyond the fixed `prepay.topoff` amount
|
|
1014
|
+
* so `deposit()` can reuse it for a caller-chosen amount. OMITTED (the no-arg
|
|
1015
|
+
* call from the proactive/hard-402/async paths above), it defaults to
|
|
1016
|
+
* `prepay.topoff` and its precomputed `topoffAtomic` ceiling — BYTE-FOR-BYTE the
|
|
1017
|
+
* stage-1 behavior. An EXPLICIT `amountUsd` (from `deposit()`, which may be
|
|
1018
|
+
* called with no `prepay` configured at all) is validated here the same way
|
|
1019
|
+
* `runDeposit` validates its wallet-mode amount, since it never passed through
|
|
1020
|
+
* the constructor's `prepay.topoff` guard.
|
|
1021
|
+
*/
|
|
1022
|
+
async runAccountTopoff(amountUsd) {
|
|
1023
|
+
let amount;
|
|
1024
|
+
let maxAmountAtomic;
|
|
1025
|
+
if (amountUsd === void 0) {
|
|
1026
|
+
amount = this.prepay.topoff;
|
|
1027
|
+
maxAmountAtomic = this.topoffAtomic;
|
|
1028
|
+
} else {
|
|
1029
|
+
const atomic = toWholeAtomicUsd(amountUsd);
|
|
1030
|
+
if (atomic === null || !(amountUsd >= 1)) {
|
|
1031
|
+
throw new AgentKVError(
|
|
1032
|
+
"amountUsd must be >= $1 and a whole number of atomic USDC units",
|
|
1033
|
+
"invalid_config",
|
|
1034
|
+
0
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
amount = amountUsd;
|
|
1038
|
+
maxAmountAtomic = atomic;
|
|
1039
|
+
}
|
|
1040
|
+
try {
|
|
1041
|
+
await this.topoffPayer({
|
|
1042
|
+
depositUrl: this.route({ base: "/account/deposit" }).url,
|
|
1043
|
+
accountKey: this.accountKey,
|
|
1044
|
+
amountUsd: amount,
|
|
1045
|
+
maxAmountAtomic
|
|
1046
|
+
});
|
|
1047
|
+
} catch (e) {
|
|
1048
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
1049
|
+
throw new AgentKVError(
|
|
1050
|
+
`account top-off failed: ${detail} \u2014 check the payer wallet's USDC balance (e.g. 'awal balance'; fund by sending USDC to its address)`,
|
|
1051
|
+
"account_topoff_failed",
|
|
1052
|
+
0
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
this.recordSpend(amount);
|
|
1056
|
+
}
|
|
1057
|
+
async runDeposit(amountUsd, opts) {
|
|
1058
|
+
if (this.accountKey) {
|
|
1059
|
+
throw new AgentKVError(
|
|
1060
|
+
`Account-key mode has no signing wallet. Fund this account with fundAccount(payerKeyOrSigner, amountUsd), or via 'agentkv fund', or awal: awal x402 pay ${this.route({ base: "/account/deposit" }).url} --headers '{"Authorization":"Bearer <ak>"}'.`,
|
|
1061
|
+
"no_signer",
|
|
1062
|
+
0
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
const amountAtomic = toWholeAtomicUsd(amountUsd);
|
|
1066
|
+
if (amountAtomic === null || !(amountUsd >= 1)) {
|
|
1067
|
+
throw new AgentKVError(
|
|
1068
|
+
"deposit amountUsd must be >= $1 and a whole number of atomic USDC units",
|
|
1069
|
+
"invalid_config",
|
|
1070
|
+
0
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
this.assertSpend(amountUsd, opts);
|
|
1074
|
+
const opKey = opts.idempotencyKey ?? freshNonce();
|
|
1075
|
+
const { url } = this.route({ base: "/credits/deposit" });
|
|
1076
|
+
let res = await this.fetchWithRetry(url, () => ({
|
|
1077
|
+
method: "POST",
|
|
1078
|
+
headers: { "Idempotency-Key": opKey }
|
|
1079
|
+
}));
|
|
1080
|
+
this.trackBalance(res);
|
|
1081
|
+
if (res.status === 402) {
|
|
1082
|
+
const challenge = res.headers.get("PAYMENT-REQUIRED");
|
|
1083
|
+
if (!challenge) {
|
|
1084
|
+
throw await this.asError(res, "payment required but no PAYMENT-REQUIRED challenge");
|
|
1085
|
+
}
|
|
1086
|
+
const paymentSignature = await buildPaymentHeader(this.requireSigner(), challenge, {
|
|
1087
|
+
amountAtomic,
|
|
1088
|
+
expectedNetwork: this.network,
|
|
1089
|
+
expectedPayTo: opts.expectedPayTo,
|
|
1090
|
+
nonce: nonceFromIdempotencyKey(opKey)
|
|
1091
|
+
});
|
|
1092
|
+
res = await this.fetchWithRetry(url, () => ({
|
|
1093
|
+
method: "POST",
|
|
1094
|
+
headers: { "Idempotency-Key": opKey, "PAYMENT-SIGNATURE": paymentSignature }
|
|
1095
|
+
}));
|
|
1096
|
+
this.trackBalance(res);
|
|
1097
|
+
}
|
|
1098
|
+
if (!res.ok) {
|
|
1099
|
+
throw await this.asError(res, "deposit failed");
|
|
1100
|
+
}
|
|
1101
|
+
this.recordSpend(amountUsd);
|
|
1102
|
+
const result = await res.json();
|
|
1103
|
+
if (this.prepay && Number.isFinite(result.balance)) this.knownCredits = result.balance;
|
|
1104
|
+
return result;
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Fund an ACCOUNT-KEY namespace — "payer funds, bearer owns". A CALLER-supplied
|
|
1108
|
+
* `signer` pays via x402 to add prepaid credits to THIS client's account (the
|
|
1109
|
+
* one named by its `ak_…` bearer). The payer and the owner are deliberately
|
|
1110
|
+
* DECOUPLED: the payer wallet signs the on-chain EIP-3009 authorization, while
|
|
1111
|
+
* the bearer — not the payer's address — owns the credited namespace. This is the
|
|
1112
|
+
* SDK counterpart of the server's `/account/deposit` route.
|
|
1113
|
+
*
|
|
1114
|
+
* Account-key mode ONLY. In WALLET mode the paying wallet IS the namespace, so
|
|
1115
|
+
* use `deposit()` instead — calling this throws `wrong_mode` before any network.
|
|
1116
|
+
*
|
|
1117
|
+
* `signer` is the PAYER: a viem account (must expose `address` + `signTypedData`)
|
|
1118
|
+
* or a raw `0x` private key (built into a viem account internally, mirroring the
|
|
1119
|
+
* constructor). `amountUsd` must be a whole number of dollars >= $1. UNLIKE
|
|
1120
|
+
* `deposit()` (which IS gated by both spend caps and counts toward session spend),
|
|
1121
|
+
* this explicit funding call is NOT gated by `maxSpendUsd`/`maxSessionSpendUsd` and
|
|
1122
|
+
* does not count toward session spend — the payer is an EXTERNAL wallet, not this
|
|
1123
|
+
* client's tracked budget. The local encryption key is never touched (funding does not encrypt).
|
|
1124
|
+
*/
|
|
1125
|
+
async fundAccount(signer, amountUsd, opts = {}) {
|
|
1126
|
+
if (!this.accountKey) {
|
|
1127
|
+
throw new AgentKVError(
|
|
1128
|
+
"fundAccount funds an account-key namespace; in wallet mode use deposit()",
|
|
1129
|
+
"wrong_mode",
|
|
1130
|
+
0
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
const payer = typeof signer === "string" ? privateKeyToAccount(signer) : signer;
|
|
1134
|
+
if (!payer?.address || typeof payer.signTypedData !== "function") {
|
|
1135
|
+
throw new AgentKVError(
|
|
1136
|
+
"fundAccount: signer must be a 0x private key or a viem account (with address + signTypedData)",
|
|
1137
|
+
"invalid_config",
|
|
1138
|
+
0
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
if (!Number.isInteger(amountUsd) || amountUsd < 1) {
|
|
1142
|
+
throw new AgentKVError(
|
|
1143
|
+
"fundAccount amountUsd must be a whole number of US dollars >= $1",
|
|
1144
|
+
"invalid_config",
|
|
1145
|
+
0
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
const amountAtomic = amountUsd * 1e6;
|
|
1149
|
+
const { url } = this.route({ base: "/account/deposit" });
|
|
1150
|
+
const bearer = buildBearerHeaders(this.accountKey);
|
|
1151
|
+
const idempotencyKey = opts.idempotencyKey ?? freshNonce();
|
|
1152
|
+
const nonce = nonceFromIdempotencyKey(idempotencyKey);
|
|
1153
|
+
let res = await this.fetchWithRetry(url, () => ({
|
|
1154
|
+
method: "POST",
|
|
1155
|
+
headers: { ...bearer, "Idempotency-Key": idempotencyKey }
|
|
1156
|
+
}));
|
|
1157
|
+
this.trackBalance(res);
|
|
1158
|
+
if (res.status === 402) {
|
|
1159
|
+
const challenge = res.headers.get("PAYMENT-REQUIRED");
|
|
1160
|
+
if (!challenge) {
|
|
1161
|
+
throw await this.asError(res, "payment required but no PAYMENT-REQUIRED challenge");
|
|
1162
|
+
}
|
|
1163
|
+
const paymentSignature = await buildPaymentHeader(payer, challenge, {
|
|
1164
|
+
amountAtomic,
|
|
1165
|
+
expectedNetwork: this.network,
|
|
1166
|
+
expectedPayTo: opts.expectedPayTo,
|
|
1167
|
+
nonce
|
|
1168
|
+
});
|
|
1169
|
+
res = await this.fetchWithRetry(url, () => ({
|
|
1170
|
+
method: "POST",
|
|
1171
|
+
headers: {
|
|
1172
|
+
...bearer,
|
|
1173
|
+
"Idempotency-Key": idempotencyKey,
|
|
1174
|
+
"PAYMENT-SIGNATURE": paymentSignature
|
|
1175
|
+
}
|
|
1176
|
+
}));
|
|
1177
|
+
this.trackBalance(res);
|
|
1178
|
+
}
|
|
1179
|
+
if (!res.ok) {
|
|
1180
|
+
throw await this.asError(res, "fundAccount failed");
|
|
1181
|
+
}
|
|
1182
|
+
const result = await res.json();
|
|
1183
|
+
if (this.prepay && Number.isFinite(result.balance)) this.knownCredits = result.balance;
|
|
1184
|
+
return result;
|
|
1185
|
+
}
|
|
1186
|
+
/** Shared by `asError` (a real `Response`) and the `opInlinePayer` path (a plain `{status,body}`). */
|
|
1187
|
+
errorFromBody(status, bodyText, fallback) {
|
|
1188
|
+
let detail = fallback, code = "request_failed";
|
|
1189
|
+
try {
|
|
1190
|
+
const body = JSON.parse(bodyText);
|
|
1191
|
+
if (body?.error) detail = body.error;
|
|
1192
|
+
if (body?.code) code = body.code;
|
|
1193
|
+
} catch {
|
|
1194
|
+
}
|
|
1195
|
+
return new AgentKVError(`AgentKV ${status}: ${detail}`, code, status);
|
|
1196
|
+
}
|
|
1197
|
+
async asError(res, fallback) {
|
|
1198
|
+
return this.errorFromBody(res.status, await res.text(), fallback);
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* The settled amount (USD) from an `opInlinePayer` response's PAYMENT-RESPONSE
|
|
1202
|
+
* header — the inline-path mirror of `settledTxHash()` above, but reading a
|
|
1203
|
+
* plain `Record<string,string>` (the hook's own headers, not a `Response`) and
|
|
1204
|
+
* returning the `amount` field instead of the `txHash`. Case-insensitive header
|
|
1205
|
+
* lookup: an external transport (e.g. awal) is not guaranteed to preserve the
|
|
1206
|
+
* worker's exact `PAYMENT-RESPONSE` casing. Returns `undefined` when the header
|
|
1207
|
+
* is absent/unparsable OR the op settled nothing (served from existing credits,
|
|
1208
|
+
* `txHash: ""`) — callers fall back to the credit-equivalent op price.
|
|
1209
|
+
*/
|
|
1210
|
+
inlineSettledAmountUsd(headers) {
|
|
1211
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === "payment-response");
|
|
1212
|
+
const header = key ? headers[key] : void 0;
|
|
1213
|
+
if (!header) return void 0;
|
|
1214
|
+
try {
|
|
1215
|
+
const parsed = JSON.parse(decodeBase64Utf8(header));
|
|
1216
|
+
if (typeof parsed.txHash !== "string" || parsed.txHash === "") return void 0;
|
|
1217
|
+
const atomic = Number(parsed.amount);
|
|
1218
|
+
return Number.isFinite(atomic) && atomic > 0 ? atomic / 1e6 : void 0;
|
|
1219
|
+
} catch {
|
|
1220
|
+
return void 0;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
export {
|
|
1225
|
+
ACCOUNT_READ_USD,
|
|
1226
|
+
ACCOUNT_WRITE_USD,
|
|
1227
|
+
AgentKV,
|
|
1228
|
+
AgentKVError,
|
|
1229
|
+
CREDIT_VALUE_USD,
|
|
1230
|
+
DEFAULT_MAX_OP_USD,
|
|
1231
|
+
SpendCapError,
|
|
1232
|
+
VERSION,
|
|
1233
|
+
decrypt,
|
|
1234
|
+
deriveKeyMaterial,
|
|
1235
|
+
encrypt,
|
|
1236
|
+
generateAccountKey,
|
|
1237
|
+
hashKey,
|
|
1238
|
+
isAccountKeyFormat
|
|
1239
|
+
};
|
|
1240
|
+
//# sourceMappingURL=index.js.map
|