@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/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