@blockrun/llm 3.19.0 → 3.20.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.cjs CHANGED
@@ -35,8 +35,12 @@ __export(index_exports, {
35
35
  ARC_CHAIN_ID: () => ARC_CHAIN_ID,
36
36
  AnthropicClient: () => AnthropicClient,
37
37
  BASE_CHAIN_ID: () => BASE_CHAIN_ID,
38
+ BLOCKRUN_SOL_OPERATOR: () => BLOCKRUN_SOL_OPERATOR,
39
+ BatchCloseDeferredError: () => BatchCloseDeferredError,
40
+ BatchPaymentUnresolvedError: () => BatchPaymentUnresolvedError,
38
41
  BlockrunClient: () => BlockrunClient,
39
42
  BlockrunError: () => BlockrunError,
43
+ DEFAULT_BATCH_RATE_LIMIT: () => DEFAULT_BATCH_RATE_LIMIT,
40
44
  EVM_NETWORKS: () => EVM_NETWORKS,
41
45
  ImageClient: () => ImageClient,
42
46
  KNOWN_PROVIDERS: () => KNOWN_PROVIDERS,
@@ -99,6 +103,7 @@ __export(index_exports, {
99
103
  loadWallet: () => loadWallet,
100
104
  logCost: () => logCost,
101
105
  parsePaymentRequired: () => parsePaymentRequired,
106
+ retryDisposition: () => retryDisposition,
102
107
  saveSolanaWallet: () => saveSolanaWallet,
103
108
  saveToCache: () => saveToCache,
104
109
  saveWallet: () => saveWallet,
@@ -120,6 +125,21 @@ __export(index_exports, {
120
125
  module.exports = __toCommonJS(index_exports);
121
126
 
122
127
  // src/types.ts
128
+ var RETRY_DISPOSITION = /* @__PURE__ */ Symbol.for("@blockrun/llm/retry-disposition/v1");
129
+ function retryDisposition(err) {
130
+ if (!err || typeof err !== "object" && typeof err !== "function") return void 0;
131
+ const value = err[RETRY_DISPOSITION];
132
+ return value === "unpaid" || value === "paid-or-in-doubt" ? value : void 0;
133
+ }
134
+ function withDisposition(err, disposition) {
135
+ if (!err || typeof err !== "object") return err;
136
+ if (retryDisposition(err) === "paid-or-in-doubt") return err;
137
+ try {
138
+ Object.defineProperty(err, RETRY_DISPOSITION, { value: disposition, configurable: true, writable: true, enumerable: false });
139
+ } catch {
140
+ }
141
+ return err;
142
+ }
123
143
  var BlockrunError = class extends Error {
124
144
  constructor(message) {
125
145
  super(message);
@@ -311,6 +331,9 @@ function resolveApiKeyAuth(options) {
311
331
  const base = options.apiUrl ?? (typeof process !== "undefined" ? process.env?.BLOCKRUN_API_BASE_URL : void 0) ?? API_KEY_URL;
312
332
  return new ApiKeyAuth(key.trim(), base);
313
333
  }
334
+ function accountErrorDisposition(err) {
335
+ return err instanceof APIError && err.statusCode >= 400 && err.statusCode < 500 ? "unpaid" : "paid-or-in-doubt";
336
+ }
314
337
  function requireWallet(value) {
315
338
  if (value === void 0) throw new Error("This operation requires a wallet; the client uses API key account billing.");
316
339
  return value;
@@ -4177,6 +4200,7 @@ var AUTO_ROUTING_PROFILES = {
4177
4200
  var BASE_MINIMUM_PAYMENT_USD = 2e-3;
4178
4201
  var SOLANA_MINIMUM_PAYMENT_USD = 1e-3;
4179
4202
  function isTransientError(err) {
4203
+ if (retryDisposition(err) !== "unpaid") return false;
4180
4204
  if (err instanceof PaymentError) return false;
4181
4205
  if (err instanceof APIError) {
4182
4206
  return [429, 502, 503, 504, 522, 524].includes(err.statusCode);
@@ -4660,7 +4684,7 @@ function getCostSummary() {
4660
4684
  }
4661
4685
 
4662
4686
  // src/version.ts
4663
- var SDK_VERSION = "3.19.0";
4687
+ var SDK_VERSION = "3.20.0";
4664
4688
  var USER_AGENT = `blockrun-ts/${SDK_VERSION}`;
4665
4689
 
4666
4690
  // src/client.ts
@@ -4932,10 +4956,13 @@ var LLMClient = class _LLMClient {
4932
4956
  * Full chat completion interface (OpenAI-compatible).
4933
4957
  *
4934
4958
  * When `fallbackModels` is set, transient failures (timeouts, network
4935
- * errors, 5xx) on the primary model trigger a retry against the next
4936
- * model in the list before raising. 4xx errors and PaymentError
4937
- * propagate immediately — those aren't "swap upstream and retry"
4938
- * situations. Each fallback hop logs one stderr line.
4959
+ * errors, 429, 5xx) on the primary model trigger a retry against the next
4960
+ * model in the list before raising, but only while no payment has been
4961
+ * sent for it (the error's {@link RetryDisposition} is `"unpaid"`). Once a
4962
+ * payment was sent, any failure propagates: the next model would be a
4963
+ * second charge. 4xx errors and PaymentError also propagate immediately —
4964
+ * those aren't "swap upstream and retry" situations. Each fallback hop
4965
+ * logs one stderr line.
4939
4966
  *
4940
4967
  * @param model - Primary model ID
4941
4968
  * @param messages - Array of messages with role and content
@@ -5038,21 +5065,25 @@ var LLMClient = class _LLMClient {
5038
5065
  }
5039
5066
  /**
5040
5067
  * Make a request with automatic x402 payment handling.
5068
+ *
5069
+ * Every error it throws carries a {@link RetryDisposition}, which is what
5070
+ * {@link LLMClient.chatCompletion}'s fallback walk honours: errors of the
5071
+ * unpaid first request (and its one 502/503 retry), of the 402 challenge
5072
+ * and of signing are `"unpaid"`; anything after the signed payment was sent
5073
+ * is `"paid-or-in-doubt"`. With an API key the request itself is billed, so
5074
+ * only the account API's explicit 4xx refusal is `"unpaid"`.
5041
5075
  */
5042
5076
  async requestWithPayment(endpoint, body) {
5043
5077
  const url = `${this.apiUrl}${endpoint}`;
5044
- const response = await this.fetchWithTimeout(url, {
5078
+ const unpaid = {
5045
5079
  method: "POST",
5046
5080
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
5047
5081
  body: JSON.stringify(body)
5048
- });
5082
+ };
5083
+ const response = await this.sendUnpaid(url, unpaid);
5049
5084
  if (response.status === 502 || response.status === 503) {
5050
5085
  await new Promise((r) => setTimeout(r, 1e3));
5051
- const retryResp = await this.fetchWithTimeout(url, {
5052
- method: "POST",
5053
- headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
5054
- body: JSON.stringify(body)
5055
- });
5086
+ const retryResp = await this.sendUnpaid(url, unpaid);
5056
5087
  if (retryResp.status !== 502 && retryResp.status !== 503) {
5057
5088
  if (retryResp.status === 402) return this.handlePaymentAndRetry(url, body, retryResp);
5058
5089
  if (!retryResp.ok) {
@@ -5062,9 +5093,12 @@ var LLMClient = class _LLMClient {
5062
5093
  } catch {
5063
5094
  errorBody = { error: "Request failed" };
5064
5095
  }
5065
- throw new APIError(`API error: ${retryResp.status}`, retryResp.status, sanitizeErrorResponse(errorBody));
5096
+ throw withDisposition(
5097
+ new APIError(`API error: ${retryResp.status}`, retryResp.status, sanitizeErrorResponse(errorBody)),
5098
+ "unpaid"
5099
+ );
5066
5100
  }
5067
- return this.parseChatResponse(retryResp);
5101
+ return this.parseUnpaidChatResponse(retryResp);
5068
5102
  }
5069
5103
  }
5070
5104
  if (response.status === 402) {
@@ -5077,18 +5111,61 @@ var LLMClient = class _LLMClient {
5077
5111
  } catch {
5078
5112
  errorBody = { error: "Request failed" };
5079
5113
  }
5080
- throw new APIError(
5081
- `API error: ${response.status}`,
5082
- response.status,
5083
- sanitizeErrorResponse(errorBody)
5114
+ throw withDisposition(
5115
+ new APIError(
5116
+ `API error: ${response.status}`,
5117
+ response.status,
5118
+ sanitizeErrorResponse(errorBody)
5119
+ ),
5120
+ "unpaid"
5084
5121
  );
5085
5122
  }
5086
- return this.parseChatResponse(response);
5123
+ return this.parseUnpaidChatResponse(response);
5124
+ }
5125
+ /**
5126
+ * Send a request that carries no payment, marking what it throws with its
5127
+ * {@link RetryDisposition}: `"unpaid"` with a wallet; with an API key, see
5128
+ * {@link accountErrorDisposition} (the request itself is billed).
5129
+ */
5130
+ async sendUnpaid(url, init) {
5131
+ try {
5132
+ return await this.fetchWithTimeout(url, init);
5133
+ } catch (err) {
5134
+ throw withDisposition(err, this.apiAuth ? accountErrorDisposition(err) : "unpaid");
5135
+ }
5136
+ }
5137
+ /**
5138
+ * Parse a response to a request that carried no payment: the free tier,
5139
+ * or, with an API key, the billed request itself.
5140
+ */
5141
+ async parseUnpaidChatResponse(response) {
5142
+ try {
5143
+ return await this.parseChatResponse(response);
5144
+ } catch (err) {
5145
+ throw withDisposition(err, this.apiAuth ? "paid-or-in-doubt" : "unpaid");
5146
+ }
5087
5147
  }
5088
5148
  /**
5089
5149
  * Handle 402 response: parse requirements, sign payment, retry.
5150
+ *
5151
+ * Anything thrown before the signed payment is sent is `"unpaid"`; anything
5152
+ * after it, `"paid-or-in-doubt"` (see {@link RetryDisposition}).
5090
5153
  */
5091
5154
  async handlePaymentAndRetry(url, body, response) {
5155
+ let signed;
5156
+ try {
5157
+ signed = await this.signFrom402(url, response);
5158
+ } catch (err) {
5159
+ throw withDisposition(err, "unpaid");
5160
+ }
5161
+ try {
5162
+ return await this.sendPaid(url, body, signed.paymentPayload, signed.details);
5163
+ } catch (err) {
5164
+ throw withDisposition(err, "paid-or-in-doubt");
5165
+ }
5166
+ }
5167
+ /** Read a 402's requirements and sign the exact payment for them. Sends nothing. */
5168
+ async signFrom402(url, response) {
5092
5169
  let paymentHeader = response.headers.get("payment-required");
5093
5170
  if (!paymentHeader) {
5094
5171
  try {
@@ -5124,6 +5201,10 @@ var LLMClient = class _LLMClient {
5124
5201
  extensions
5125
5202
  }
5126
5203
  );
5204
+ return { paymentPayload, details };
5205
+ }
5206
+ /** Send the signed payment and read the answer. The payment may settle from here on. */
5207
+ async sendPaid(url, body, paymentPayload, details) {
5127
5208
  const retryResponse = await this.fetchWithTimeout(url, {
5128
5209
  method: "POST",
5129
5210
  headers: {
@@ -5260,25 +5341,46 @@ var LLMClient = class _LLMClient {
5260
5341
  const cached = this.preAuthCache.get(cacheKey2);
5261
5342
  const now = Date.now();
5262
5343
  if (cached && now - cached.cachedAt < _LLMClient.PRE_AUTH_TTL_MS) {
5344
+ let signed;
5263
5345
  try {
5264
- const { paymentPayload: paymentPayload2, costUsd: costUsd2 } = await this.signPayment(cached.paymentHeader);
5265
- const preAuthResp = await this.fetchWithTimeout(url, {
5266
- method: "POST",
5267
- headers: {
5268
- "Content-Type": "application/json",
5269
- "User-Agent": USER_AGENT,
5270
- "PAYMENT-SIGNATURE": paymentPayload2
5271
- },
5272
- body: JSON.stringify(body)
5273
- });
5274
- if (preAuthResp.status !== 402 && preAuthResp.ok) {
5346
+ signed = await this.signPayment(cached.paymentHeader);
5347
+ } catch {
5348
+ this.preAuthCache.delete(cacheKey2);
5349
+ }
5350
+ if (signed) {
5351
+ let preAuthResp;
5352
+ try {
5353
+ preAuthResp = await this.fetchWithTimeout(url, {
5354
+ method: "POST",
5355
+ headers: {
5356
+ "Content-Type": "application/json",
5357
+ "User-Agent": USER_AGENT,
5358
+ "PAYMENT-SIGNATURE": signed.paymentPayload
5359
+ },
5360
+ body: JSON.stringify(body)
5361
+ });
5362
+ } catch (err) {
5363
+ this.preAuthCache.delete(cacheKey2);
5364
+ throw withDisposition(err, "paid-or-in-doubt");
5365
+ }
5366
+ if (preAuthResp.ok) {
5275
5367
  this.sessionCalls += 1;
5276
- this.sessionTotalUsd += costUsd2;
5368
+ this.sessionTotalUsd += signed.costUsd;
5277
5369
  return preAuthResp;
5278
5370
  }
5279
5371
  this.preAuthCache.delete(cacheKey2);
5280
- } catch {
5281
- this.preAuthCache.delete(cacheKey2);
5372
+ if (preAuthResp.status !== 402) {
5373
+ let errorBody;
5374
+ try {
5375
+ errorBody = await preAuthResp.json();
5376
+ } catch {
5377
+ errorBody = { error: "Request failed" };
5378
+ }
5379
+ throw withDisposition(
5380
+ new APIError(`API error after payment: ${preAuthResp.status}`, preAuthResp.status, sanitizeErrorResponse(errorBody)),
5381
+ "paid-or-in-doubt"
5382
+ );
5383
+ }
5282
5384
  }
5283
5385
  }
5284
5386
  const firstResp = await this.fetchWithTimeout(url, {
@@ -9347,11 +9449,11 @@ var WALLET_DIR = path4.join(os3.homedir(), ".blockrun");
9347
9449
  var SOLANA_WALLET_FILE = path4.join(WALLET_DIR, ".solana-session");
9348
9450
  async function createSolanaWallet() {
9349
9451
  const { Keypair } = await loadSolanaWeb3("Solana wallet operations");
9350
- const bs58 = await import("bs58");
9452
+ const bs582 = await import("bs58");
9351
9453
  const keypair = Keypair.generate();
9352
9454
  return {
9353
9455
  address: keypair.publicKey.toBase58(),
9354
- privateKey: (bs58.default ?? bs58).encode(keypair.secretKey)
9456
+ privateKey: (bs582.default ?? bs582).encode(keypair.secretKey)
9355
9457
  };
9356
9458
  }
9357
9459
  async function solanaKeyToBytes(privateKey) {
@@ -9389,8 +9491,8 @@ async function solanaKeyToBytes(privateKey) {
9389
9491
  return bytes;
9390
9492
  }
9391
9493
  try {
9392
- const bs58 = await import("bs58");
9393
- const bytes = (bs58.default ?? bs58).decode(key);
9494
+ const bs582 = await import("bs58");
9495
+ const bytes = (bs582.default ?? bs582).decode(key);
9394
9496
  if (bytes.length !== 64) {
9395
9497
  throw new Error(`Invalid Solana key length: expected 64 bytes, got ${bytes.length}`);
9396
9498
  }
@@ -9570,9 +9672,59 @@ async function getOrCreateSolanaWallet() {
9570
9672
  }
9571
9673
 
9572
9674
  // src/solana-batch.ts
9675
+ var import_async_hooks = require("async_hooks");
9676
+ var import_crypto = require("crypto");
9573
9677
  var fs6 = __toESM(require("fs"), 1);
9574
9678
  var path5 = __toESM(require("path"), 1);
9679
+ var import_bs58 = __toESM(require("bs58"), 1);
9575
9680
  var BATCH_SCHEME = "batch-settlement";
9681
+ var BLOCKRUN_SOL_OPERATOR = "5YKPQUFjw5WQqhSUkEGKNNfYYVqnRRNbpYyL71qQ1vm3";
9682
+ var DEFAULT_BATCH_RATE_LIMIT = { maxAttempts: 3, maxWaitMs: 6e4 };
9683
+ var BatchPaymentUnresolvedError = class extends PaymentError {
9684
+ /** The {@link SolanaBatchEvent.reason} of the `unresolved` event reported with it. */
9685
+ reason;
9686
+ /** The paying wallet's address. */
9687
+ wallet;
9688
+ /** The request id of the payment that may have been charged. */
9689
+ requestId;
9690
+ /** The channel it paid into. */
9691
+ channelId;
9692
+ /** What it carried. */
9693
+ payloadKind;
9694
+ /** HTTP status of the gateway's last answer to it, when there was one. */
9695
+ status;
9696
+ /** Whether it carried a deposit, which is now in doubt as well. */
9697
+ depositInDoubt;
9698
+ constructor(init) {
9699
+ super(
9700
+ `batch-settlement payment ${init.requestId ?? "<unknown>"} from wallet ${init.wallet} may have been charged and is not paid again${init.detail ? `: ${init.detail}` : ""}`
9701
+ );
9702
+ this.name = "BatchPaymentUnresolvedError";
9703
+ withDisposition(this, "paid-or-in-doubt");
9704
+ this.reason = init.reason;
9705
+ this.wallet = init.wallet;
9706
+ this.requestId = init.requestId;
9707
+ this.channelId = init.channelId;
9708
+ this.payloadKind = init.payloadKind;
9709
+ this.status = init.status;
9710
+ this.depositInDoubt = init.payloadKind === "open" || init.payloadKind === "top-up";
9711
+ if (init.cause !== void 0) this.cause = init.cause;
9712
+ }
9713
+ };
9714
+ var BatchCloseDeferredError = class extends BlockrunError {
9715
+ reason;
9716
+ /** The paying wallet's address. */
9717
+ wallet;
9718
+ /** The channels whose deposit is in doubt (`deposit_in_doubt`). */
9719
+ channelIds;
9720
+ constructor(init) {
9721
+ super(`batch-settlement channel close deferred for wallet ${init.wallet}: ${init.detail}`);
9722
+ this.name = "BatchCloseDeferredError";
9723
+ this.reason = init.reason;
9724
+ this.wallet = init.wallet;
9725
+ this.channelIds = init.channelIds ?? [];
9726
+ }
9727
+ };
9576
9728
  var INSTALL_HINT2 = "npm install @x402/core@~2.28.0 @x402/svm@~2.28.0 @solana/kit";
9577
9729
  async function load(pkg, importer) {
9578
9730
  try {
@@ -9622,6 +9774,10 @@ var FileChannelStorage = class {
9622
9774
  delete all[key];
9623
9775
  this.write(all);
9624
9776
  }
9777
+ /** The keys of every stored channel. */
9778
+ async keys() {
9779
+ return Object.keys(this.read());
9780
+ }
9625
9781
  /** Forget every channel in this file. */
9626
9782
  clear() {
9627
9783
  try {
@@ -9631,6 +9787,192 @@ var FileChannelStorage = class {
9631
9787
  }
9632
9788
  }
9633
9789
  };
9790
+ var MemoryChannelStorage = class {
9791
+ records = /* @__PURE__ */ new Map();
9792
+ async get(key) {
9793
+ const raw = this.records.get(key);
9794
+ return raw === void 0 ? void 0 : JSON.parse(raw);
9795
+ }
9796
+ async set(key, record) {
9797
+ this.records.set(key, JSON.stringify(record));
9798
+ }
9799
+ async delete(key) {
9800
+ this.records.delete(key);
9801
+ }
9802
+ async keys() {
9803
+ return [...this.records.keys()];
9804
+ }
9805
+ clear() {
9806
+ this.records.clear();
9807
+ }
9808
+ };
9809
+ function isPlainObject(value) {
9810
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9811
+ }
9812
+ function intentProblem(key, intent) {
9813
+ const atomic = (value) => typeof value === "string" && /^\d+$/.test(value);
9814
+ if (!isPlainObject(intent)) return "not an object";
9815
+ if (intent.key !== key || !key) return "key does not match its entry";
9816
+ if (typeof intent.channelId !== "string" || !intent.channelId) return "channelId missing";
9817
+ if (!isPlainObject(intent.channelConfig)) return "channelConfig missing";
9818
+ if (intent.kind !== "open" && intent.kind !== "top-up") return "kind is neither open nor top-up";
9819
+ if (!atomic(intent.cumulative)) return "cumulative is not an atomic amount";
9820
+ if (!atomic(intent.expectDeposit)) return "expectDeposit is not an atomic amount";
9821
+ if (intent.knownDeposit !== void 0 && !atomic(intent.knownDeposit)) return "knownDeposit is not an atomic amount";
9822
+ if (intent.requestId !== void 0 && typeof intent.requestId !== "string") return "requestId is not a string";
9823
+ if (intent.anchorHeight !== void 0 && (!Number.isSafeInteger(intent.anchorHeight) || intent.anchorHeight < 0)) {
9824
+ return "anchorHeight is not a block height";
9825
+ }
9826
+ if (typeof intent.at !== "number" || !Number.isFinite(intent.at)) return "at is not a time";
9827
+ return void 0;
9828
+ }
9829
+ function fsyncDirectory(dir) {
9830
+ let fd;
9831
+ try {
9832
+ fd = fs6.openSync(dir, "r");
9833
+ fs6.fsyncSync(fd);
9834
+ } catch (err) {
9835
+ const code = err.code;
9836
+ if (!["EISDIR", "EPERM", "EACCES", "EINVAL", "ENOTSUP", "EBADF"].includes(code ?? "")) throw err;
9837
+ } finally {
9838
+ if (fd !== void 0) fs6.closeSync(fd);
9839
+ }
9840
+ }
9841
+ var FileIntentJournal = class _FileIntentJournal {
9842
+ constructor(file) {
9843
+ this.file = file;
9844
+ }
9845
+ file;
9846
+ durable = true;
9847
+ /** The journal file for a channel store file: its full path plus `.deposit-intents`, one per store. */
9848
+ static beside(channelFile) {
9849
+ return new _FileIntentJournal(`${channelFile}.deposit-intents`);
9850
+ }
9851
+ /**
9852
+ * Where earlier builds kept a store's journal: the store's name without
9853
+ * `.json`, plus `.deposit-intents.json`. Two stores could share it
9854
+ * (`/x/channels` and `/x/channels.json`), so its intents are moved by payer
9855
+ * ({@link adoptLegacy}). No current journal path ever ends in `.json`, so
9856
+ * this never names one.
9857
+ */
9858
+ static legacyBeside(channelFile) {
9859
+ const base = path5.basename(channelFile).replace(/\.json$/i, "");
9860
+ return path5.join(path5.dirname(channelFile), `${base}.deposit-intents.json`);
9861
+ }
9862
+ /**
9863
+ * Move `payer`'s intents from a journal an earlier build wrote at
9864
+ * `legacyFile` into this one, before this one is used: written here first
9865
+ * (durably), then removed there (the file goes once empty). Intents of other
9866
+ * payers are left where they are, for the store they belong to. An intent
9867
+ * already here is kept (a migration interrupted between the two writes).
9868
+ *
9869
+ * @throws when the old journal cannot be read or is not valid, or holds an
9870
+ * intent naming no payer: the wallet then keeps batch off, as for an
9871
+ * unreadable journal, and nothing is dropped.
9872
+ */
9873
+ adoptLegacy(legacyFile, payer) {
9874
+ if (legacyFile === this.file) return;
9875
+ const legacy = new _FileIntentJournal(legacyFile);
9876
+ const found = legacy.read();
9877
+ const mine = [];
9878
+ const others = {};
9879
+ for (const [key, intent] of Object.entries(found)) {
9880
+ const owner = intent.channelConfig.payer;
9881
+ if (typeof owner !== "string") {
9882
+ throw new Error(`deposit-intent journal ${legacyFile} holds an intent naming no payer (${JSON.stringify(key)}); move it by hand`);
9883
+ }
9884
+ if (owner === payer) mine.push(intent);
9885
+ else others[key] = intent;
9886
+ }
9887
+ if (mine.length === 0) return;
9888
+ const intents = this.read();
9889
+ for (const intent of mine) intents[intent.key] ??= intent;
9890
+ this.write(intents);
9891
+ legacy.write(others);
9892
+ }
9893
+ /**
9894
+ * The journaled intents. Only a missing file is an empty journal: anything
9895
+ * else that is not a version-1 journal of well-formed intents throws, so a
9896
+ * deposit in doubt is never lost to a damaged or unknown file (the wallet
9897
+ * then keeps batch off, `deposit_journal_unreadable`).
9898
+ */
9899
+ read() {
9900
+ let text;
9901
+ try {
9902
+ text = fs6.readFileSync(this.file, "utf8");
9903
+ } catch (err) {
9904
+ if (err.code === "ENOENT") return {};
9905
+ throw err;
9906
+ }
9907
+ const invalid = (why) => new Error(`deposit-intent journal ${this.file} is not valid (${why})`);
9908
+ let parsed;
9909
+ try {
9910
+ parsed = JSON.parse(text);
9911
+ } catch (err) {
9912
+ throw invalid(err instanceof Error ? err.message : String(err));
9913
+ }
9914
+ if (!isPlainObject(parsed)) throw invalid("not a JSON object");
9915
+ if (parsed.version !== 1) throw invalid(`unsupported version ${JSON.stringify(parsed.version)}`);
9916
+ const { intents } = parsed;
9917
+ if (!isPlainObject(intents)) throw invalid("intents is not an object");
9918
+ for (const [key, intent] of Object.entries(intents)) {
9919
+ const why = intentProblem(key, intent);
9920
+ if (why) throw invalid(`intent ${JSON.stringify(key)}: ${why}`);
9921
+ }
9922
+ return intents;
9923
+ }
9924
+ write(intents) {
9925
+ const dir = path5.dirname(this.file);
9926
+ fs6.mkdirSync(dir, { recursive: true, mode: 448 });
9927
+ if (Object.keys(intents).length === 0) {
9928
+ try {
9929
+ fs6.unlinkSync(this.file);
9930
+ } catch (err) {
9931
+ if (err.code !== "ENOENT") throw err;
9932
+ return;
9933
+ }
9934
+ fsyncDirectory(dir);
9935
+ return;
9936
+ }
9937
+ const tmp = `${this.file}.${process.pid}.${(0, import_crypto.randomUUID)()}.tmp`;
9938
+ const fd = fs6.openSync(tmp, "w", 384);
9939
+ try {
9940
+ fs6.writeSync(fd, JSON.stringify({ version: 1, intents }, null, 2));
9941
+ fs6.fsyncSync(fd);
9942
+ } finally {
9943
+ fs6.closeSync(fd);
9944
+ }
9945
+ fs6.renameSync(tmp, this.file);
9946
+ fsyncDirectory(dir);
9947
+ }
9948
+ list() {
9949
+ return Object.values(this.read());
9950
+ }
9951
+ put(intent) {
9952
+ const intents = this.read();
9953
+ intents[intent.key] = intent;
9954
+ this.write(intents);
9955
+ }
9956
+ remove(key) {
9957
+ const intents = this.read();
9958
+ if (!(key in intents)) return;
9959
+ delete intents[key];
9960
+ this.write(intents);
9961
+ }
9962
+ };
9963
+ var MemoryIntentJournal = class {
9964
+ durable = false;
9965
+ intents = /* @__PURE__ */ new Map();
9966
+ list() {
9967
+ return [...this.intents.values()];
9968
+ }
9969
+ put(intent) {
9970
+ this.intents.set(intent.key, { ...intent });
9971
+ }
9972
+ remove(key) {
9973
+ this.intents.delete(key);
9974
+ }
9975
+ };
9634
9976
  function processAlive(pid) {
9635
9977
  try {
9636
9978
  process.kill(pid, 0);
@@ -9639,71 +9981,438 @@ function processAlive(pid) {
9639
9981
  return err.code === "EPERM";
9640
9982
  }
9641
9983
  }
9642
- var heldLocks = /* @__PURE__ */ new Set();
9643
- var exitHookInstalled = false;
9984
+ var BATCH_REGISTRY = /* @__PURE__ */ Symbol.for("@blockrun/llm/batch-registry/v1");
9985
+ var registryHost = globalThis;
9986
+ var registry2 = registryHost[BATCH_REGISTRY] ??= {
9987
+ wallets: /* @__PURE__ */ new Map(),
9988
+ locks: /* @__PURE__ */ new Map(),
9989
+ exitHook: false
9990
+ };
9991
+ var lockIds = registry2.lockIds ??= /* @__PURE__ */ new Map();
9992
+ function fileIdentity(file) {
9993
+ try {
9994
+ const stat = fs6.statSync(file);
9995
+ return `${stat.dev}:${stat.ino}:${stat.mtimeMs}`;
9996
+ } catch {
9997
+ return void 0;
9998
+ }
9999
+ }
10000
+ function lockPredatesProcess(lock) {
10001
+ try {
10002
+ return fs6.statSync(lock).mtimeMs < performance.timeOrigin;
10003
+ } catch {
10004
+ return false;
10005
+ }
10006
+ }
10007
+ function holdsChannelFile(file) {
10008
+ const token = registry2.locks.get(file);
10009
+ if (!token) return false;
10010
+ const lock = `${file}.lock`;
10011
+ if (!ownsLock(lock, token)) return false;
10012
+ const id = lockIds.get(file);
10013
+ return id === void 0 || fileIdentity(lock) === id;
10014
+ }
10015
+ function lockTokenFile(lock) {
10016
+ return `${lock}.owner`;
10017
+ }
10018
+ function readLockOwner(lock) {
10019
+ let raw;
10020
+ try {
10021
+ raw = fs6.readFileSync(lock, "utf8");
10022
+ } catch (err) {
10023
+ if (err.code === "ENOENT") return void 0;
10024
+ throw err;
10025
+ }
10026
+ let pid = Number(raw.trim());
10027
+ if (!Number.isInteger(pid)) {
10028
+ try {
10029
+ const parsed = JSON.parse(raw);
10030
+ pid = parsed && typeof parsed === "object" && Number.isInteger(parsed.pid) ? parsed.pid : 0;
10031
+ } catch {
10032
+ pid = 0;
10033
+ }
10034
+ }
10035
+ return { pid, raw };
10036
+ }
10037
+ function readLockToken(lock) {
10038
+ try {
10039
+ const parsed = JSON.parse(fs6.readFileSync(lockTokenFile(lock), "utf8"));
10040
+ return parsed && typeof parsed === "object" && typeof parsed.token === "string" ? parsed.token : void 0;
10041
+ } catch {
10042
+ return void 0;
10043
+ }
10044
+ }
10045
+ function ownsLock(lock, token) {
10046
+ return readLockOwner(lock)?.pid === process.pid && readLockToken(lock) === token;
10047
+ }
10048
+ function releaseHeldLocks() {
10049
+ for (const [file, token] of registry2.locks) {
10050
+ const lock = `${file}.lock`;
10051
+ try {
10052
+ if (!ownsLock(lock, token)) continue;
10053
+ fs6.unlinkSync(lockTokenFile(lock));
10054
+ fs6.unlinkSync(lock);
10055
+ } catch {
10056
+ }
10057
+ }
10058
+ }
9644
10059
  function lockChannelFile(file) {
9645
- if (heldLocks.has(file)) return void 0;
10060
+ if (registry2.locks.has(file)) return void 0;
9646
10061
  const lock = `${file}.lock`;
9647
10062
  fs6.mkdirSync(path5.dirname(lock), { recursive: true, mode: 448 });
10063
+ const token = (0, import_crypto.randomUUID)();
9648
10064
  for (let attempt = 0; attempt < 2; attempt += 1) {
10065
+ const tmp = `${lock}.${process.pid}.${token}.tmp`;
9649
10066
  try {
9650
- fs6.writeFileSync(lock, String(process.pid), { flag: "wx", mode: 384 });
9651
- heldLocks.add(file);
9652
- if (!exitHookInstalled) {
9653
- exitHookInstalled = true;
9654
- process.once("exit", () => {
9655
- for (const held of heldLocks) {
9656
- try {
9657
- fs6.unlinkSync(`${held}.lock`);
9658
- } catch {
9659
- }
9660
- }
9661
- });
10067
+ fs6.writeFileSync(tmp, String(process.pid), { mode: 384 });
10068
+ try {
10069
+ fs6.linkSync(tmp, lock);
10070
+ } catch (err) {
10071
+ const code = err.code;
10072
+ if (code !== "EPERM" && code !== "ENOTSUP" && code !== "EXDEV" && code !== "EOPNOTSUPP") throw err;
10073
+ fs6.writeFileSync(lock, String(process.pid), { flag: "wx", mode: 384 });
10074
+ }
10075
+ try {
10076
+ writeLockToken(lock, token);
10077
+ } catch (err) {
10078
+ try {
10079
+ fs6.unlinkSync(lock);
10080
+ } catch {
10081
+ }
10082
+ throw err;
10083
+ }
10084
+ registry2.locks.set(file, token);
10085
+ const id = fileIdentity(lock);
10086
+ if (id) lockIds.set(file, id);
10087
+ else lockIds.delete(file);
10088
+ if (!registry2.exitHook) {
10089
+ registry2.exitHook = true;
10090
+ process.once("exit", releaseHeldLocks);
9662
10091
  }
9663
10092
  return void 0;
9664
10093
  } catch (err) {
9665
10094
  if (err.code !== "EEXIST") throw err;
10095
+ } finally {
10096
+ try {
10097
+ fs6.unlinkSync(tmp);
10098
+ } catch {
10099
+ }
10100
+ }
10101
+ const owner = readLockOwner(lock);
10102
+ if (!owner) continue;
10103
+ if (owner.pid === process.pid) {
10104
+ if (!lockPredatesProcess(lock)) return process.pid;
10105
+ } else if (owner.pid > 0 && processAlive(owner.pid)) {
10106
+ return owner.pid;
9666
10107
  }
9667
- const owner = Number(fs6.readFileSync(lock, "utf8").trim());
9668
- if (Number.isInteger(owner) && owner > 0 && owner !== process.pid && processAlive(owner)) return owner;
9669
10108
  try {
9670
- fs6.unlinkSync(lock);
10109
+ if (readLockOwner(lock)?.raw === owner.raw) fs6.unlinkSync(lock);
9671
10110
  } catch {
9672
10111
  }
9673
10112
  }
9674
10113
  return -1;
9675
10114
  }
10115
+ function writeLockToken(lock, token) {
10116
+ const sidecar = lockTokenFile(lock);
10117
+ const tmp = `${sidecar}.${process.pid}.${token}.tmp`;
10118
+ try {
10119
+ fs6.writeFileSync(tmp, JSON.stringify({ pid: process.pid, token }), { mode: 384 });
10120
+ fs6.renameSync(tmp, sidecar);
10121
+ } finally {
10122
+ try {
10123
+ fs6.unlinkSync(tmp);
10124
+ } catch {
10125
+ }
10126
+ }
10127
+ }
9676
10128
  function pendingDeposit(pending) {
9677
10129
  const entries = Array.isArray(pending) ? pending : [pending];
9678
10130
  return entries.some(
9679
10131
  (entry) => entry?.payment?.payload?.type === "deposit"
9680
10132
  );
9681
10133
  }
9682
- function dropOrphanedPending(storage) {
9683
- return {
9684
- get: async (key) => {
9685
- const record = await storage.get(key);
9686
- if (!record?.pending || record.channelConfig?.voucherSigner !== "server") return record;
9687
- if (!record.hasConfirmedState || pendingDeposit(record.pending)) {
9688
- await storage.delete(key);
9689
- return void 0;
9690
- }
10134
+ var ChannelResyncRequiredError = class extends Error {
10135
+ constructor(channelId) {
10136
+ super(`batch-settlement channel ${channelId ?? "<unknown>"} needs a chain re-read before it is used`);
10137
+ this.channelId = channelId;
10138
+ this.name = "ChannelResyncRequiredError";
10139
+ }
10140
+ channelId;
10141
+ };
10142
+ function isResyncRequired(err) {
10143
+ return err instanceof Error && err.name === "ChannelResyncRequiredError";
10144
+ }
10145
+ function parseAtomic(value) {
10146
+ return typeof value === "string" && /^\d+$/.test(value) ? BigInt(value) : 0n;
10147
+ }
10148
+ function confirmedCumulative(record, channelId) {
10149
+ if (!record || record.channelId !== channelId) return 0n;
10150
+ return record.pending === void 0 || record.hasConfirmedState ? parseAtomic(record.chargedCumulativeAmount) : 0n;
10151
+ }
10152
+ var ChannelBook = class {
10153
+ constructor(base, onUnverified = () => {
10154
+ }) {
10155
+ this.base = base;
10156
+ this.onUnverified = onUnverified;
10157
+ }
10158
+ base;
10159
+ onUnverified;
10160
+ seen = /* @__PURE__ */ new Map();
10161
+ async get(key) {
10162
+ const record = await this.base.get(key);
10163
+ if (record) this.seen.set(key, record);
10164
+ if (!record?.pending || record.channelConfig?.voucherSigner !== "server") return record;
10165
+ if (record.hasConfirmedState && !pendingDeposit(record.pending)) {
9691
10166
  const { pending: _pending, hasConfirmedState: _confirmed, ...confirmed } = record;
9692
- await storage.set(key, confirmed);
10167
+ await this.set(key, confirmed);
9693
10168
  return confirmed;
9694
- },
9695
- set: (key, record) => storage.set(key, record),
9696
- delete: (key) => storage.delete(key)
10169
+ }
10170
+ if (typeof record.channelId === "string") {
10171
+ const pendingEntries = Array.isArray(record.pending) ? record.pending : [record.pending];
10172
+ const inDoubt = pendingEntries.find((entry) => entry?.payment?.payload?.type === "deposit");
10173
+ this.onUnverified({
10174
+ key,
10175
+ channelId: record.channelId,
10176
+ channelConfig: record.channelConfig,
10177
+ cumulative: record.hasConfirmedState ? parseAtomic(record.chargedCumulativeAmount) : 0n,
10178
+ ...record.hasConfirmedState ? { knownDeposit: parseAtomic(record.deposit) } : {},
10179
+ ...inDoubt ? { expectDeposit: parseAtomic(inDoubt.deposit) } : {},
10180
+ reason: "orphaned_deposit"
10181
+ });
10182
+ }
10183
+ throw new ChannelResyncRequiredError(record.channelId);
10184
+ }
10185
+ /**
10186
+ * Store a record, and only once it is stored remember it as the last one
10187
+ * seen. If storing fails (say, while the scheme saves a reconciled
10188
+ * receipt), the scheme reports the receipt unreconciled, and the SDK must
10189
+ * still see the state from before it: `distrust()` adds that call's charge
10190
+ * to it, and a remembered post-receipt cumulative would count it twice.
10191
+ */
10192
+ async set(key, record) {
10193
+ await this.base.set(key, record);
10194
+ this.seen.set(key, record);
10195
+ }
10196
+ async delete(key) {
10197
+ await this.base.delete(key);
10198
+ }
10199
+ /** The keys of every stored channel. */
10200
+ async keys() {
10201
+ return await this.base.keys?.() ?? [];
10202
+ }
10203
+ /** The last record seen for a channel, even one the scheme has deleted since. */
10204
+ find(channelId) {
10205
+ for (const [key, record] of this.seen) {
10206
+ if (record.channelId === channelId) return { key, record };
10207
+ }
10208
+ return void 0;
10209
+ }
10210
+ clear() {
10211
+ this.seen.clear();
10212
+ this.base.clear?.();
10213
+ }
10214
+ /** Forget one channel: its stored record and the last record seen for it. An emptied store is removed. */
10215
+ async forget(key) {
10216
+ this.seen.delete(key);
10217
+ await this.base.delete(key);
10218
+ if ((await this.keys()).length === 0) this.clear();
10219
+ }
10220
+ };
10221
+ var PAYMENT_CHANNELS_PROGRAM_ID = "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX";
10222
+ var CHANNEL = {
10223
+ size: 256,
10224
+ rentPayer: 216,
10225
+ discriminator: 1,
10226
+ status: 3,
10227
+ deposit: 12,
10228
+ settled: 20,
10229
+ closureStartedAt: 36,
10230
+ payer: 88,
10231
+ authorizedSigner: 152,
10232
+ mint: 184
10233
+ };
10234
+ function decodeChannelAccount(owner, data) {
10235
+ if (owner !== PAYMENT_CHANNELS_PROGRAM_ID || data.length < CHANNEL.size || data[0] !== CHANNEL.discriminator) {
10236
+ return void 0;
10237
+ }
10238
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
10239
+ const key = (offset) => import_bs58.default.encode(data.subarray(offset, offset + 32));
10240
+ return {
10241
+ deposit: view.getBigUint64(CHANNEL.deposit, true),
10242
+ settled: view.getBigUint64(CHANNEL.settled, true),
10243
+ open: data[CHANNEL.status] === 0 && view.getBigInt64(CHANNEL.closureStartedAt, true) === 0n,
10244
+ payer: key(CHANNEL.payer),
10245
+ authorizedSigner: key(CHANNEL.authorizedSigner),
10246
+ mint: key(CHANNEL.mint)
10247
+ };
10248
+ }
10249
+ var ChannelUnreadableError = class extends Error {
10250
+ constructor(channelId, why) {
10251
+ super(`account ${channelId} is on chain but is not a readable payment channel (${why})`);
10252
+ this.name = "ChannelUnreadableError";
10253
+ }
10254
+ };
10255
+ var ChannelLayoutError = class extends Error {
10256
+ constructor(message, options) {
10257
+ super(message, options);
10258
+ this.name = "ChannelLayoutError";
10259
+ }
10260
+ };
10261
+ async function readChannelAccount(rpcUrl, channelId, rpcHeaders, options = {}) {
10262
+ await checkChannelLayout();
10263
+ const config = { encoding: "base64", commitment: "finalized" };
10264
+ if (options.minContextSlot !== void 0) config.minContextSlot = options.minContextSlot;
10265
+ const response = await fetch(rpcUrl, {
10266
+ method: "POST",
10267
+ headers: { ...rpcHeaders, "Content-Type": "application/json" },
10268
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getAccountInfo", params: [channelId, config] }),
10269
+ signal: AbortSignal.timeout(15e3),
10270
+ // rpcHeaders may carry an API key: never let a redirect take it elsewhere.
10271
+ redirect: "error"
10272
+ });
10273
+ if (!response.ok) throw new Error(`getAccountInfo answered HTTP ${response.status}`);
10274
+ const body = await response.json();
10275
+ if (body.error) throw new Error(`getAccountInfo failed: ${body.error.message ?? JSON.stringify(body.error)}`);
10276
+ if (!body.result || typeof body.result !== "object" || !("value" in body.result)) {
10277
+ throw new Error("getAccountInfo returned no result");
10278
+ }
10279
+ const value = body.result.value;
10280
+ if (value === null) return void 0;
10281
+ if (!value || typeof value !== "object") throw new ChannelUnreadableError(channelId, "malformed account value");
10282
+ if (!Array.isArray(value.data) || typeof value.data[0] !== "string" || value.data[1] !== "base64") {
10283
+ throw new ChannelUnreadableError(channelId, "unsupported data encoding");
10284
+ }
10285
+ const channel = decodeChannelAccount(value.owner, Buffer.from(value.data[0], "base64"));
10286
+ if (!channel) throw new ChannelUnreadableError(channelId, "wrong owner or layout");
10287
+ return channel;
10288
+ }
10289
+ async function rpcRequest(rpcUrl, method, params, rpcHeaders) {
10290
+ const response = await fetch(rpcUrl, {
10291
+ method: "POST",
10292
+ headers: { ...rpcHeaders, "Content-Type": "application/json" },
10293
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
10294
+ signal: AbortSignal.timeout(15e3),
10295
+ // rpcHeaders may carry an API key: never let a redirect take it elsewhere.
10296
+ redirect: "error"
10297
+ });
10298
+ if (!response.ok) throw new Error(`${method} answered HTTP ${response.status}`);
10299
+ const body = await response.json();
10300
+ if (body.error) throw new Error(`${method} failed: ${body.error.message ?? JSON.stringify(body.error)}`);
10301
+ if (body.result === void 0 || body.result === null) throw new Error(`${method} returned no result`);
10302
+ return body.result;
10303
+ }
10304
+ function rpcInteger(value, what) {
10305
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error(`${what} is not a block height or slot`);
10306
+ return value;
10307
+ }
10308
+ var layoutChecked;
10309
+ function checkChannelLayout() {
10310
+ layoutChecked ??= (async () => {
10311
+ const svm = await load("@x402/svm", () => import("@x402/svm")).catch((err) => {
10312
+ throw new ChannelLayoutError(
10313
+ `cannot check the payment-channel account layout: ${err instanceof Error ? err.message : String(err)}`,
10314
+ { cause: err }
10315
+ );
10316
+ });
10317
+ if (svm.CHANNEL_ACCOUNT_SIZE !== BigInt(CHANNEL.size) || svm.CHANNEL_RENT_PAYER_OFFSET !== BigInt(CHANNEL.rentPayer)) {
10318
+ throw new ChannelLayoutError("@x402/svm changed the payment-channel account layout; this SDK cannot read channels from it");
10319
+ }
10320
+ })();
10321
+ layoutChecked.catch(() => {
10322
+ layoutChecked = void 0;
10323
+ });
10324
+ return layoutChecked;
10325
+ }
10326
+ var LANDING_MARGIN_BLOCKS = 300;
10327
+ function maxOf(a, b) {
10328
+ if (a === void 0) return b;
10329
+ if (b === void 0) return a;
10330
+ return a > b ? a : b;
10331
+ }
10332
+ var ResyncError = class extends Error {
10333
+ constructor(reason, message) {
10334
+ super(message);
10335
+ this.reason = reason;
10336
+ this.name = "ResyncError";
10337
+ }
10338
+ reason;
10339
+ };
10340
+ function payloadChannelId(payload) {
10341
+ return payload.payload?.authorization?.channelId ?? payload.payload?.voucher?.channelId;
10342
+ }
10343
+ function clientSignedBatch(accept) {
10344
+ return accept.scheme === BATCH_SCHEME && accept.extra?.voucherSigner !== "server";
10345
+ }
10346
+ function batchUnavailable(paymentRequired) {
10347
+ const batch = (paymentRequired.accepts ?? []).filter((accept) => accept.scheme === BATCH_SCHEME);
10348
+ if (batch.length === 0) return { reason: "not_offered" };
10349
+ if (!batch.every(clientSignedBatch)) return void 0;
10350
+ return {
10351
+ reason: "client_signed_not_supported",
10352
+ detail: "the 402 offers only client-signed batch-settlement accepts; this SDK pays batch only into server-signed (operator) channels"
9697
10353
  };
9698
10354
  }
9699
- function offersBatch(paymentRequired) {
9700
- return (paymentRequired.accepts ?? []).some((accept) => accept.scheme === BATCH_SCHEME);
10355
+ function withoutClientSignedBatch(paymentRequired) {
10356
+ const accepts = paymentRequired.accepts ?? [];
10357
+ return accepts.some(clientSignedBatch) ? { ...paymentRequired, accepts: accepts.filter((accept) => !clientSignedBatch(accept)) } : paymentRequired;
10358
+ }
10359
+ function trustedBatchAccepts(paymentRequired, operators) {
10360
+ return (paymentRequired.accepts ?? []).filter((candidate) => {
10361
+ const extra = candidate.extra;
10362
+ return candidate.scheme === BATCH_SCHEME && extra?.voucherSigner === "server" && operators.includes(extra.operator);
10363
+ });
10364
+ }
10365
+ function trustedBatchAccept(paymentRequired, operators) {
10366
+ return trustedBatchAccepts(paymentRequired, operators)[0];
9701
10367
  }
9702
10368
  function atomicToUsd(amount) {
9703
- return amount !== void 0 && /^\d+$/.test(amount) ? Number(amount) / 1e6 : void 0;
10369
+ return typeof amount === "string" && /^\d+$/.test(amount) ? Number(amount) / 1e6 : void 0;
9704
10370
  }
9705
- async function isBatchRefusal(response) {
9706
- if (response.status === 402) return "payment_required";
10371
+ function chargedUsd(getReceipt) {
10372
+ let receipt;
10373
+ try {
10374
+ receipt = getReceipt();
10375
+ } catch {
10376
+ return 0;
10377
+ }
10378
+ return atomicToUsd(receipt?.extra?.chargedAmount) ?? atomicToUsd(receipt?.amount) ?? 0;
10379
+ }
10380
+ function rebuiltCharge(settled, payload) {
10381
+ if (settled?.success !== true) return 0n;
10382
+ const charged = parseAtomic(typeof settled.extra?.chargedAmount === "string" ? settled.extra.chargedAmount : settled.amount);
10383
+ const ceiling = parseAtomic(payload.accepted?.amount);
10384
+ return charged > ceiling ? ceiling : charged;
10385
+ }
10386
+ function confirmedState(record) {
10387
+ if (!record || !(record.pending === void 0 || record.hasConfirmedState)) return void 0;
10388
+ return { cumulative: parseAtomic(record.chargedCumulativeAmount), deposit: parseAtomic(record.deposit) };
10389
+ }
10390
+ function recordAdvanced(before, after, payload, receipt) {
10391
+ const deposited = payload.payload?.type === "deposit" ? parseAtomic(payload.payload.deposit?.amount) : 0n;
10392
+ const charged = rebuiltCharge(receipt, payload);
10393
+ if (deposited === 0n && charged === 0n) return true;
10394
+ if (!after) return false;
10395
+ const prior = before ?? { cumulative: 0n, deposit: 0n };
10396
+ return after.deposit >= prior.deposit + deposited && after.cumulative >= prior.cumulative + charged;
10397
+ }
10398
+ var BATCH_REFUSALS = /* @__PURE__ */ new Set([
10399
+ "batch_payer_not_allowed",
10400
+ "batch_payer_not_admitted",
10401
+ "batch_admission_paused",
10402
+ "batch_server_signed_only",
10403
+ "batch_unavailable",
10404
+ "batch_channel_limit",
10405
+ "PAYMENT_VERIFICATION_UNAVAILABLE"
10406
+ ]);
10407
+ var PRE_REQUEST_REFUSALS = /* @__PURE__ */ new Set([
10408
+ "batch_payer_not_allowed",
10409
+ "batch_payer_not_admitted",
10410
+ "batch_admission_paused",
10411
+ "batch_server_signed_only",
10412
+ "batch_unavailable",
10413
+ "batch_channel_limit"
10414
+ ]);
10415
+ async function batchRefusal(response) {
9707
10416
  if (![400, 403, 409, 503].includes(response.status)) return void 0;
9708
10417
  let body;
9709
10418
  try {
@@ -9712,20 +10421,254 @@ async function isBatchRefusal(response) {
9712
10421
  return void 0;
9713
10422
  }
9714
10423
  const { error, code } = body ?? {};
9715
- if (code === "PAYMENT_VERIFICATION_UNAVAILABLE") return code;
9716
- return typeof error === "string" && error.startsWith("batch_") ? error : void 0;
9717
- }
9718
- var wallets = /* @__PURE__ */ new Map();
9719
- var SolanaBatchPayer = class {
9720
- constructor(init) {
9721
- this.init = init;
9722
- const { operators } = init.options;
9723
- if (!Array.isArray(operators) || operators.length === 0 || operators.some((o) => typeof o !== "string" || !o)) {
9724
- throw new Error("batch.operators must list at least one base58 operator public key");
9725
- }
10424
+ for (const value of [code, error]) {
10425
+ if (typeof value === "string" && BATCH_REFUSALS.has(value)) return value;
10426
+ }
10427
+ return void 0;
10428
+ }
10429
+ async function duplicateAnswer(response) {
10430
+ const codes = [];
10431
+ try {
10432
+ const body = await response.clone().json();
10433
+ if (body && typeof body === "object") codes.push(body.error, body.code, body.reason, body.invalidReason);
10434
+ } catch {
10435
+ }
10436
+ const header = response.headers.get("PAYMENT-REQUIRED");
10437
+ if (header) {
10438
+ try {
10439
+ const challenge = parsePaymentRequired(header);
10440
+ codes.push(challenge.error, challenge.invalidReason);
10441
+ } catch {
10442
+ }
10443
+ }
10444
+ for (const code of codes) {
10445
+ if (typeof code === "string" && /duplicate/i.test(code)) return code;
10446
+ }
10447
+ return void 0;
10448
+ }
10449
+ function cleanRefusalReceipt(receipt) {
10450
+ if (receipt === void 0) return true;
10451
+ return receipt.success === false && !receipt.transaction && receipt.errorReason !== "settlement_pending";
10452
+ }
10453
+ async function afterPaymentError(response) {
10454
+ let body;
10455
+ try {
10456
+ body = await response.clone().json();
10457
+ } catch {
10458
+ body = { error: "Request failed" };
10459
+ }
10460
+ return withDisposition(
10461
+ new APIError(`API error after payment: ${response.status}`, response.status, sanitizeErrorResponse(body)),
10462
+ "paid-or-in-doubt"
10463
+ );
10464
+ }
10465
+ function cancelledReceipt(receipt) {
10466
+ return receipt?.success === false && receipt.errorReason === "batch_cancelled" && !receipt.transaction;
10467
+ }
10468
+ async function cancelledError(response) {
10469
+ let body;
10470
+ try {
10471
+ body = await response.clone().json();
10472
+ } catch {
10473
+ body = { error: "Request failed" };
10474
+ }
10475
+ return withDisposition(new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(body)), "unpaid");
10476
+ }
10477
+ function describeReceipt(receipt) {
10478
+ if (!receipt) return "no receipt";
10479
+ const parts = [`success=${String(receipt.success)}`];
10480
+ if (receipt.errorReason) parts.push(`errorReason=${receipt.errorReason}`);
10481
+ if (receipt.transaction) parts.push(`transaction=${receipt.transaction}`);
10482
+ return `receipt ${parts.join(" ")}`;
10483
+ }
10484
+ var MAX_RETRY_AFTER_MS = 24 * 60 * 60 * 1e3;
10485
+ function parseRetryAfter(value, now = Date.now()) {
10486
+ const text = value?.trim();
10487
+ if (!text) return void 0;
10488
+ let ms;
10489
+ if (/^\d+(\.\d+)?$/.test(text)) {
10490
+ ms = Math.round(Number(text) * 1e3);
10491
+ } else {
10492
+ const at = Date.parse(text);
10493
+ if (Number.isNaN(at)) return void 0;
10494
+ ms = Math.max(0, at - now);
10495
+ }
10496
+ return Number.isFinite(ms) && ms <= MAX_RETRY_AFTER_MS ? ms : void 0;
10497
+ }
10498
+ var NOT_BROADCAST_429 = /* @__PURE__ */ new Set([
10499
+ "batch_account_channel_capacity_exhausted",
10500
+ "batch_channel_capacity_exhausted",
10501
+ "batch_deposit_rate_limited"
10502
+ ]);
10503
+ function provesNothingBroadcast(receipt) {
10504
+ return receipt?.success === false && !receipt.transaction && typeof receipt.errorReason === "string" && NOT_BROADCAST_429.has(receipt.errorReason);
10505
+ }
10506
+ function backoffDelay(attempt) {
10507
+ const base = 1e3 * 2 ** Math.max(0, attempt - 1);
10508
+ return Math.round(base * (0.75 + Math.random() * 0.5));
10509
+ }
10510
+ async function rateLimitReason(response, settled) {
10511
+ let body;
10512
+ try {
10513
+ body = await response.clone().json();
10514
+ } catch {
10515
+ body = void 0;
10516
+ }
10517
+ const fields = body ?? {};
10518
+ for (const value of [fields.errorReason, fields.error, fields.code, fields.reason, settled?.errorReason]) {
10519
+ if (typeof value === "string" && value) return value;
10520
+ }
10521
+ return void 0;
10522
+ }
10523
+ function creationFailure(err) {
10524
+ const detail = err instanceof Error ? err.message : String(err);
10525
+ if (err instanceof Error && err.name === "UntrustedOperatorError") return { reason: "untrusted_operator", detail };
10526
+ if (detail.includes("serverSignedChannelsPolicy maxDeposit")) return { reason: "deposit_over_cap", detail };
10527
+ if (detail.includes("has a pending request")) return { reason: "channel_pending", detail };
10528
+ if (detail.includes("requires the optional peer dependency")) return { reason: "peer_dependency_missing", detail };
10529
+ return { reason: "payment_creation_failed", detail };
10530
+ }
10531
+ function logValue(value) {
10532
+ const text = String(value);
10533
+ return /^[\w.:/@+-]+$/.test(text) ? text : JSON.stringify(text);
10534
+ }
10535
+ function formatEvent(event) {
10536
+ const fields = [
10537
+ ["event", event.type],
10538
+ ["reason", event.reason],
10539
+ ["status", event.status],
10540
+ ["errorReason", event.errorReason],
10541
+ ["retryAfterMs", event.retryAfterMs],
10542
+ ["attempt", event.attempt],
10543
+ ["wallet", event.wallet],
10544
+ ["next", event.type === "fallback" ? "exact" : event.type === "backoff" ? "retry" : event.type === "unresolved" ? "raise" : void 0],
10545
+ ["detail", event.detail]
10546
+ ];
10547
+ const parts = fields.filter(([, v]) => v !== void 0 && v !== "").map(([k, v]) => `${k}=${logValue(v)}`);
10548
+ return `[@blockrun/llm] batch-settlement ${parts.join(" ")}`;
10549
+ }
10550
+ var realSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
10551
+ var sleep2 = realSleep;
10552
+ var warnedNoSizer = false;
10553
+ function channelKeyOf(requirements) {
10554
+ const accept = requirements;
10555
+ const extra = accept?.extra ?? {};
10556
+ if (typeof extra.feePayer !== "string" || typeof extra.withdrawDelay !== "number") return void 0;
10557
+ return [
10558
+ accept.network,
10559
+ accept.asset,
10560
+ accept.payTo,
10561
+ extra.feePayer,
10562
+ extra.withdrawDelay,
10563
+ extra.receiverAuthorizer ?? "",
10564
+ extra.voucherSigner ?? "client",
10565
+ extra.operator ?? ""
10566
+ ].join(":");
10567
+ }
10568
+ function capOutstandingEscrow(scheme, wallet) {
10569
+ const target = scheme;
10570
+ const original = target.resolveDepositAmount;
10571
+ if (typeof original !== "function") {
10572
+ if (!warnedNoSizer) {
10573
+ warnedNoSizer = true;
10574
+ console.error(
10575
+ "[@blockrun/llm] batch-settlement: this @x402/svm does not expose resolveDepositAmount; maxDeposit caps lifetime deposits"
10576
+ );
10577
+ }
10578
+ return;
10579
+ }
10580
+ target.resolveDepositAmount = function(requirements, requestAmount, needed, context, trust, existingDeposit) {
10581
+ const topUp = wallet.topUp;
10582
+ const widened = topUp && existingDeposit > 0n && existingDeposit === topUp.deposit && channelKeyOf(requirements) === topUp.key && typeof trust?.maxDeposit === "bigint" ? { ...trust, maxDeposit: trust.maxDeposit + topUp.settled } : trust;
10583
+ return original.call(this, requirements, requestAmount, needed, context, widened, existingDeposit);
10584
+ };
10585
+ }
10586
+ var RPC_SCOPE_STORE = /* @__PURE__ */ Symbol.for("@blockrun/llm/batch-rpc-scope/v1");
10587
+ var RPC_HEADER_HOOK = /* @__PURE__ */ Symbol.for("@blockrun/llm/batch-rpc-headers/v1");
10588
+ var sharedGlobal = globalThis;
10589
+ var rpcScope = sharedGlobal[RPC_SCOPE_STORE] ??= new import_async_hooks.AsyncLocalStorage();
10590
+ function requestUrl(input) {
10591
+ return typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
10592
+ }
10593
+ function installRpcHeaderHook() {
10594
+ const current = globalThis.fetch;
10595
+ if (current[RPC_HEADER_HOOK]) return;
10596
+ const hooked = (input, init) => {
10597
+ const scope = rpcScope.getStore();
10598
+ if (!scope || requestUrl(input) !== scope.url) return current(input, init);
10599
+ const headers = new Headers(input instanceof Request ? input.headers : void 0);
10600
+ new Headers(scope.headers).forEach((value, name) => headers.set(name, value));
10601
+ new Headers(init?.headers).forEach((value, name) => headers.set(name, value));
10602
+ return current(input, { ...init, headers, redirect: "error" });
10603
+ };
10604
+ hooked[RPC_HEADER_HOOK] = true;
10605
+ globalThis.fetch = hooked;
10606
+ }
10607
+ function addResyncTarget(wallet, target) {
10608
+ const existing = wallet.resyncs.get(target.key);
10609
+ if (!existing) {
10610
+ wallet.resyncs.set(target.key, target);
10611
+ return;
10612
+ }
10613
+ wallet.resyncs.set(target.key, {
10614
+ ...existing,
10615
+ channelConfig: target.channelConfig,
10616
+ cumulative: maxOf(existing.cumulative, target.cumulative) ?? 0n,
10617
+ expectDeposit: maxOf(existing.expectDeposit, target.expectDeposit),
10618
+ knownDeposit: maxOf(existing.knownDeposit, target.knownDeposit),
10619
+ anchorHeight: existing.anchorHeight === void 0 || target.anchorHeight === void 0 ? existing.anchorHeight ?? target.anchorHeight : Math.max(existing.anchorHeight, target.anchorHeight)
10620
+ });
10621
+ }
10622
+ function intentTarget(intent) {
10623
+ return {
10624
+ key: intent.key,
10625
+ channelId: intent.channelId,
10626
+ channelConfig: intent.channelConfig,
10627
+ cumulative: parseAtomic(intent.cumulative),
10628
+ expectDeposit: parseAtomic(intent.expectDeposit),
10629
+ ...intent.knownDeposit !== void 0 ? { knownDeposit: parseAtomic(intent.knownDeposit) } : {},
10630
+ ...intent.anchorHeight !== void 0 ? { anchorHeight: intent.anchorHeight } : {},
10631
+ reason: "orphaned_deposit"
10632
+ };
10633
+ }
10634
+ var SolanaBatchPayer = class {
10635
+ constructor(init) {
10636
+ this.init = init;
10637
+ const { operators, rateLimit } = init.options;
10638
+ if (!Array.isArray(operators) || operators.length === 0 || operators.some((o) => typeof o !== "string" || !o)) {
10639
+ throw new Error("batch.operators must list at least one base58 operator public key");
10640
+ }
10641
+ this.maxAttempts = rateLimit?.maxAttempts ?? DEFAULT_BATCH_RATE_LIMIT.maxAttempts;
10642
+ this.maxWaitMs = rateLimit?.maxWaitMs ?? DEFAULT_BATCH_RATE_LIMIT.maxWaitMs;
10643
+ if (!Number.isInteger(this.maxAttempts) || this.maxAttempts < 1) {
10644
+ throw new Error("batch.rateLimit.maxAttempts must be an integer >= 1");
10645
+ }
10646
+ if (!Number.isFinite(this.maxWaitMs) || this.maxWaitMs < 0) {
10647
+ throw new Error("batch.rateLimit.maxWaitMs must be a finite number of ms >= 0");
10648
+ }
9726
10649
  }
9727
10650
  init;
9728
10651
  state;
10652
+ maxAttempts;
10653
+ maxWaitMs;
10654
+ counters = {
10655
+ fallbacks: 0,
10656
+ fallbacksByReason: {},
10657
+ backoffs: 0,
10658
+ retries: 0,
10659
+ recoveries: 0,
10660
+ resyncs: 0,
10661
+ unresolved: 0,
10662
+ unresolvedByReason: {}
10663
+ };
10664
+ /** A copy of this client's batch counters. */
10665
+ stats() {
10666
+ return {
10667
+ ...this.counters,
10668
+ fallbacksByReason: { ...this.counters.fallbacksByReason },
10669
+ unresolvedByReason: { ...this.counters.unresolvedByReason }
10670
+ };
10671
+ }
9729
10672
  async storeFile() {
9730
10673
  const { channelStore } = this.init.options;
9731
10674
  if (channelStore === false) return void 0;
@@ -9741,20 +10684,63 @@ var SolanaBatchPayer = class {
9741
10684
  const address = await this.init.address();
9742
10685
  const file = await this.storeFile();
9743
10686
  const { operators, maxDeposit } = this.init.options;
9744
- const config = JSON.stringify([[...operators].sort(), String(maxDeposit ?? ""), file ?? null, this.init.rpcUrl]);
9745
- const existing = wallets.get(address);
10687
+ const headers = Object.entries(this.init.rpcHeaders ?? {}).sort(([a], [b]) => a.localeCompare(b));
10688
+ const config = JSON.stringify([[...operators].sort(), String(maxDeposit ?? ""), file ?? null, this.init.rpcUrl, headers]);
10689
+ const existing = registry2.wallets.get(address);
9746
10690
  if (existing) {
9747
- return existing.config === config ? existing : "another client in this process uses this wallet with different batch options";
10691
+ return existing.config === config ? existing : {
10692
+ reason: "wallet_config_conflict",
10693
+ detail: "another client in this process uses this wallet with different batch options"
10694
+ };
9748
10695
  }
9749
10696
  if (file) {
9750
10697
  const owner = lockChannelFile(file);
9751
- if (owner !== void 0) return `channel store ${file} is in use by process ${owner}`;
10698
+ if (owner !== void 0) {
10699
+ return {
10700
+ reason: "channel_store_locked",
10701
+ detail: owner === process.pid ? `channel store ${file} is in use by another copy of @blockrun/llm in this process (or by a process that had this pid and died: remove ${file}.lock if nothing uses it)` : `channel store ${file} is in use by process ${owner}`
10702
+ };
10703
+ }
10704
+ }
10705
+ const resyncs = /* @__PURE__ */ new Map();
10706
+ const base = file ? new FileChannelStorage(file) : new MemoryChannelStorage();
10707
+ const book = new ChannelBook(base, (target) => addResyncTarget(created, target));
10708
+ const intents = file ? FileIntentJournal.beside(file) : new MemoryIntentJournal();
10709
+ let journaled;
10710
+ try {
10711
+ if (file) intents.adoptLegacy(FileIntentJournal.legacyBeside(file), address);
10712
+ journaled = intents.list();
10713
+ } catch (err) {
10714
+ return {
10715
+ reason: "deposit_journal_unreadable",
10716
+ detail: `cannot read ${intents.file}: ${err instanceof Error ? err.message : String(err)}`
10717
+ };
9752
10718
  }
9753
- const created = { config, busy: false, warned: /* @__PURE__ */ new Set() };
9754
- wallets.set(address, created);
10719
+ const created = {
10720
+ config,
10721
+ busy: false,
10722
+ cooldownUntil: 0,
10723
+ active: 0,
10724
+ ...file ? { file } : {},
10725
+ book,
10726
+ resyncs,
10727
+ intents,
10728
+ closes: 0
10729
+ };
10730
+ for (const intent of journaled) addResyncTarget(created, intentTarget(intent));
10731
+ registry2.wallets.set(address, created);
9755
10732
  return created;
9756
10733
  })();
9757
- return this.state;
10734
+ const state = this.state;
10735
+ state.then(
10736
+ (result) => {
10737
+ if ("reason" in result && result.reason !== "wallet_config_conflict" && this.state === state) this.state = void 0;
10738
+ },
10739
+ () => {
10740
+ if (this.state === state) this.state = void 0;
10741
+ }
10742
+ );
10743
+ return state;
9758
10744
  }
9759
10745
  build(wallet) {
9760
10746
  wallet.client ??= (async () => {
@@ -9764,21 +10750,33 @@ var SolanaBatchPayer = class {
9764
10750
  load("@solana/kit", () => import("@solana/kit"))
9765
10751
  ]);
9766
10752
  const signer = await kit.createKeyPairSignerFromBytes(await this.init.secretKey());
9767
- const file = await this.storeFile();
9768
- const storage = file ? dropOrphanedPending(new FileChannelStorage(file)) : void 0;
9769
10753
  const scheme = new svm.BatchSvmScheme(signer, {
9770
10754
  rpcUrl: this.init.rpcUrl,
9771
10755
  serverSignedChannelsPolicy: {
9772
10756
  allowedOperators: this.init.options.operators,
9773
10757
  maxDeposit: this.init.options.maxDeposit ?? svm.DEFAULT_SERVER_SIGNED_MAX_DEPOSIT
9774
10758
  },
9775
- ...storage ? { channelStorage: storage } : {}
10759
+ // Always a store, memory included: a record the SDK repairs from the
10760
+ // chain survives the scheme being rebuilt to pick it up.
10761
+ channelStorage: wallet.book
9776
10762
  });
10763
+ capOutstandingEscrow(scheme, wallet);
9777
10764
  const client = new core.x402Client().register("solana:*", scheme).registerPolicy(scheme.paymentPolicy);
9778
10765
  client.setSpendControls({ maxAmountPerPayment: false });
10766
+ const raw = new core.x402HTTPClient(client);
10767
+ const http = {
10768
+ getPaymentSettleResponse: (getHeader) => raw.getPaymentSettleResponse(getHeader),
10769
+ createPaymentPayload: (paymentRequired) => this.withRpcHeaders(() => raw.createPaymentPayload(paymentRequired)),
10770
+ encodePaymentSignatureHeader: (payload) => raw.encodePaymentSignatureHeader(payload),
10771
+ processPaymentResult: (payload, getHeader, status2) => this.withRpcHeaders(() => raw.processPaymentResult(payload, getHeader, status2))
10772
+ };
10773
+ const loader = scheme.loadChannel;
9779
10774
  return {
9780
- http: new core.x402HTTPClient(client),
9781
- refund: (url) => scheme.refund(url)
10775
+ http,
10776
+ refund: (url, requirements) => this.withRpcHeaders(() => scheme.refund(url, { requirements })),
10777
+ load: async (key) => {
10778
+ if (typeof loader === "function") await loader.call(scheme, key);
10779
+ }
9782
10780
  };
9783
10781
  })();
9784
10782
  wallet.client.catch(() => {
@@ -9786,73 +10784,863 @@ var SolanaBatchPayer = class {
9786
10784
  });
9787
10785
  return wallet.client;
9788
10786
  }
10787
+ /** Run a scheme call so its RPC requests carry this client's `rpcHeaders`. */
10788
+ withRpcHeaders(call) {
10789
+ const headers = this.init.rpcHeaders;
10790
+ if (!headers || Object.keys(headers).length === 0) return call();
10791
+ installRpcHeaderHook();
10792
+ return rpcScope.run({ url: this.init.rpcUrl, headers }, call);
10793
+ }
9789
10794
  /**
9790
- * Drop everything this wallet knows about its channel, on disk and in
9791
- * memory. The next call rebuilds the scheme, which reads the channel's real
9792
- * state (deposit, open or closed) from the chain.
10795
+ * Drop everything this wallet knows about one channel, on disk and in
10796
+ * memory, leaving its other channels' records alone. The scheme is rebuilt
10797
+ * on the next call, without the closed channel in its memory.
9793
10798
  */
9794
- async forget(wallet) {
10799
+ async forget(wallet, key) {
9795
10800
  wallet.client = void 0;
9796
- const file = await this.storeFile();
9797
- if (file) new FileChannelStorage(file).clear();
10801
+ wallet.resyncs.delete(key);
10802
+ await wallet.book.forget(key);
10803
+ this.forgetIntent(wallet, key);
9798
10804
  }
9799
- warn(wallet, reason) {
9800
- if (wallet?.warned.has(reason)) return;
9801
- wallet?.warned.add(reason);
9802
- console.error(`[@blockrun/llm] batch-settlement unavailable (${reason}); paying with exact`);
10805
+ /**
10806
+ * Stop trusting the record of the channel a payload paid into: the next
10807
+ * payment re-reads that channel from the chain first. The scheme has
10808
+ * already restored (or, for an open, dropped) its record; the book still
10809
+ * knows the channel and the cumulative it last confirmed.
10810
+ */
10811
+ distrust(wallet, sent, reason, charged = 0n) {
10812
+ const { payload } = sent;
10813
+ const channelId = payloadChannelId(payload);
10814
+ const known = channelId ? wallet.book.find(channelId) : void 0;
10815
+ if (!channelId || !known) {
10816
+ wallet.client = void 0;
10817
+ let intent;
10818
+ try {
10819
+ intent = sent.key ? wallet.intents.list().find((candidate) => candidate.key === sent.key) : void 0;
10820
+ } catch {
10821
+ intent = void 0;
10822
+ }
10823
+ if (intent) addResyncTarget(wallet, { ...intentTarget(intent), reason });
10824
+ return;
10825
+ }
10826
+ const { key, record } = known;
10827
+ const hasConfirmed = record.pending === void 0 || record.hasConfirmedState;
10828
+ const confirmed = hasConfirmed ? parseAtomic(record.chargedCumulativeAmount) : 0n;
10829
+ const expectDeposit = sent.expectDeposit ?? this.depositExpectation(wallet, payload).expectDeposit;
10830
+ addResyncTarget(wallet, {
10831
+ key,
10832
+ channelId,
10833
+ channelConfig: record.channelConfig,
10834
+ cumulative: confirmed + charged,
10835
+ ...hasConfirmed ? { knownDeposit: parseAtomic(record.deposit) } : {},
10836
+ ...expectDeposit !== void 0 ? { expectDeposit } : {},
10837
+ reason
10838
+ });
10839
+ }
10840
+ /**
10841
+ * The channel's total deposit if a deposit payload landed: the confirmed
10842
+ * deposit plus this one (for an open, nothing plus this one). Undefined for
10843
+ * a payment without a deposit, or a channel the SDK has no record of.
10844
+ */
10845
+ depositExpectation(wallet, payload) {
10846
+ if (payload.payload?.type !== "deposit") return {};
10847
+ const channelId = payloadChannelId(payload);
10848
+ const record = channelId ? wallet.book.find(channelId)?.record : void 0;
10849
+ if (!record) return {};
10850
+ const hasConfirmed = record.pending === void 0 || record.hasConfirmedState;
10851
+ return { expectDeposit: (hasConfirmed ? parseAtomic(record.deposit) : 0n) + parseAtomic(payload.payload.deposit?.amount) };
10852
+ }
10853
+ /**
10854
+ * Re-read every distrusted channel from the chain, at `finalized`
10855
+ * commitment, and rewrite its record: the on-chain deposit, and the highest
10856
+ * of the cumulative the SDK can vouch for, the cumulative the stored record
10857
+ * already confirmed, and the on-chain settled amount. A closing or closed
10858
+ * channel is dropped. The scheme is rebuilt to load the repaired record.
10859
+ *
10860
+ * The stored cumulative is never moved backwards. A deposit's intent can
10861
+ * outlive the receipt that reconciled it (the process died, or the journal
10862
+ * could not be cleared, after the scheme saved the new state), so its
10863
+ * cumulative may predate vouchers the scheme has since confirmed; and
10864
+ * vouchers are redeemed on-chain asynchronously, so `settled` can lag too.
10865
+ * Rewriting the record with either would leave every later voucher
10866
+ * unreconcilable.
10867
+ *
10868
+ * A deposit in doubt is settled by the chain alone:
10869
+ * - landed: the finalized channel holds at least the deposit it would
10870
+ * bring (and at least what the record already claimed);
10871
+ * - never landed: the finalized chain is {@link LANDING_MARGIN_BLOCKS}
10872
+ * past the target's anchor height, and a read at least that recent still
10873
+ * does not show it. An open's record is then dropped; a top-up's channel
10874
+ * is rewritten with the deposit it really has.
10875
+ * Until one of those holds, it is pending. This settles only the deposit:
10876
+ * whether the gateway charged the payment that carried it is never read
10877
+ * from the chain.
10878
+ *
10879
+ * @throws ResyncError when the chain cannot be read, or a deposit may still
10880
+ * land: the caller pays exact and the re-read is tried again next call.
10881
+ * Nothing opens or tops up a channel while any re-read is pending.
10882
+ */
10883
+ async resync(wallet) {
10884
+ for (const target of [...wallet.resyncs.values()]) {
10885
+ const failed = (err) => err instanceof ResyncError ? err : err instanceof ChannelUnreadableError ? new ResyncError("channel_unreadable", err.message) : err instanceof ChannelLayoutError ? new ResyncError("channel_unreadable", `channel ${target.channelId}: ${err.message}`) : new ResyncError(
10886
+ "channel_resync_failed",
10887
+ `could not read channel ${target.channelId} from the chain: ${err instanceof Error ? err.message : String(err)}`
10888
+ );
10889
+ let channel;
10890
+ let expired = false;
10891
+ let stored;
10892
+ try {
10893
+ channel = await this.readOwnChannel(target);
10894
+ const required = maxOf(target.expectDeposit, target.knownDeposit) ?? 0n;
10895
+ if (channel === void 0 || channel.open && channel.deposit < required) {
10896
+ if (target.anchorHeight === void 0) {
10897
+ target.anchorHeight = rpcInteger(
10898
+ await rpcRequest(this.init.rpcUrl, "getBlockHeight", [{ commitment: "confirmed" }], this.init.rpcHeaders),
10899
+ "getBlockHeight"
10900
+ );
10901
+ try {
10902
+ const intent = wallet.intents.list().find((candidate) => candidate.key === target.key);
10903
+ if (intent) wallet.intents.put({ ...intent, anchorHeight: target.anchorHeight });
10904
+ } catch {
10905
+ }
10906
+ }
10907
+ const epoch = await rpcRequest(this.init.rpcUrl, "getEpochInfo", [{ commitment: "finalized" }], this.init.rpcHeaders);
10908
+ const finalizedHeight = rpcInteger(epoch.blockHeight, "getEpochInfo blockHeight");
10909
+ const finalizedSlot = rpcInteger(epoch.absoluteSlot, "getEpochInfo absoluteSlot");
10910
+ if (finalizedHeight <= target.anchorHeight + LANDING_MARGIN_BLOCKS) {
10911
+ throw new ResyncError(
10912
+ "channel_resync_pending",
10913
+ channel === void 0 ? `channel ${target.channelId} is not finalized on chain, and its deposit may still land` : `channel ${target.channelId} holds ${channel.deposit} finalized, short of the ${required} it should hold, and a deposit may still land`
10914
+ );
10915
+ }
10916
+ channel = await this.readOwnChannel(target, finalizedSlot);
10917
+ expired = true;
10918
+ }
10919
+ stored = await wallet.book.base.get(target.key);
10920
+ } catch (err) {
10921
+ throw failed(err);
10922
+ }
10923
+ let outcome;
10924
+ if (typeof stored?.channelId === "string" && stored.channelId !== target.channelId) {
10925
+ outcome = `the record under its key now names channel ${stored.channelId}; left untouched`;
10926
+ } else if (channel === void 0) {
10927
+ await wallet.book.delete(target.key);
10928
+ outcome = "no such channel at finalized commitment, past its deposit's last valid block; record dropped";
10929
+ } else if (!channel.open) {
10930
+ await wallet.book.delete(target.key);
10931
+ outcome = "channel is closing or closed; record dropped";
10932
+ } else {
10933
+ const cumulative = maxOf(maxOf(target.cumulative, channel.settled), confirmedCumulative(stored, target.channelId)) ?? 0n;
10934
+ await wallet.book.set(target.key, {
10935
+ channelConfig: target.channelConfig,
10936
+ channelId: target.channelId,
10937
+ chargedCumulativeAmount: cumulative.toString(),
10938
+ deposit: channel.deposit.toString()
10939
+ });
10940
+ outcome = `record rewritten from finalized chain: deposit=${channel.deposit} settled=${channel.settled} cumulative=${cumulative}` + (expired ? " (the deposit in doubt never landed)" : "");
10941
+ }
10942
+ wallet.resyncs.delete(target.key);
10943
+ this.forgetIntent(wallet, target.key);
10944
+ wallet.client = void 0;
10945
+ await this.report({ type: "resync", reason: target.reason, detail: `channel ${target.channelId}: ${outcome}` });
10946
+ }
10947
+ }
10948
+ /**
10949
+ * A distrusted channel's account at `finalized` commitment, checked to be
10950
+ * this wallet's channel for the record's operator and mint.
10951
+ *
10952
+ * @throws ResyncError (`channel_unreadable`) for any other payment channel.
10953
+ */
10954
+ async readOwnChannel(target, minContextSlot) {
10955
+ const channel = await readChannelAccount(this.init.rpcUrl, target.channelId, this.init.rpcHeaders, { minContextSlot });
10956
+ if (channel !== void 0 && (channel.payer !== await this.init.address() || channel.authorizedSigner !== target.channelConfig.payerAuthorizer || channel.mint !== target.channelConfig.token)) {
10957
+ throw new ResyncError("channel_unreadable", `account ${target.channelId} is a payment channel for another payer, operator or mint`);
10958
+ }
10959
+ return channel;
10960
+ }
10961
+ /**
10962
+ * Make one event observable three ways: a stderr line (every time, never
10963
+ * deduplicated), this client's counters, and the caller's `onEvent`.
10964
+ */
10965
+ async report(event) {
10966
+ let wallet;
10967
+ try {
10968
+ wallet = await this.init.address();
10969
+ } catch {
10970
+ wallet = "unknown";
10971
+ }
10972
+ const full = { ...event, wallet, at: Date.now() };
10973
+ if (full.type === "fallback") {
10974
+ this.counters.fallbacks += 1;
10975
+ this.counters.fallbacksByReason[full.reason] = (this.counters.fallbacksByReason[full.reason] ?? 0) + 1;
10976
+ } else if (full.type === "backoff") {
10977
+ this.counters.backoffs += 1;
10978
+ } else if (full.type === "recovered") {
10979
+ this.counters.recoveries += 1;
10980
+ } else if (full.type === "unresolved") {
10981
+ this.counters.unresolved += 1;
10982
+ this.counters.unresolvedByReason[full.reason] = (this.counters.unresolvedByReason[full.reason] ?? 0) + 1;
10983
+ } else {
10984
+ this.counters.resyncs += 1;
10985
+ }
10986
+ console.error(formatEvent(full));
10987
+ const failed = (err) => console.error(
10988
+ `[@blockrun/llm] batch-settlement onEvent callback threw: ${JSON.stringify(err instanceof Error ? err.message : String(err))}`
10989
+ );
10990
+ try {
10991
+ const result = this.init.options.onEvent?.(full);
10992
+ if (result && typeof result.then === "function") {
10993
+ result.then(void 0, failed);
10994
+ }
10995
+ } catch (err) {
10996
+ failed(err);
10997
+ }
10998
+ }
10999
+ /** Report a fallback and tell the caller to pay exact. */
11000
+ async fallback(event) {
11001
+ await this.report({ type: "fallback", ...event });
11002
+ return { kind: "fallback", reason: event.reason };
9803
11003
  }
9804
11004
  /**
9805
11005
  * Pay one 402 with batch-settlement, or say why the caller should use exact.
9806
11006
  *
9807
- * `send` replays the original request with the payment headers. The
9808
- * returned response is 2xx; any other answer either becomes a fallback (the
9809
- * gateway charged nothing) or is returned to the caller's own error path.
11007
+ * `send` replays the original request with the payment headers. Every send
11008
+ * goes through {@link sendOnce}, the one place an answer is classified as
11009
+ * charged, not charged, or in doubt:
11010
+ *
11011
+ * - charged: the 2xx is returned (`paid`);
11012
+ * - not charged (the gateway's explicit answer to a first send): a 429
11013
+ * whose receipt proves nothing was broadcast is backed off
11014
+ * (`Retry-After`, else 1s, 2s, 4s with jitter) and paid again with a new
11015
+ * payment built from a fresh 402 (`rechallenge`: the original's
11016
+ * `recentBlockhash` / `recentSlot` can be stale by then), within
11017
+ * `rateLimit.maxAttempts` / `maxWaitMs`, then with exact; any other is
11018
+ * paid with exact (`fallback`). A fresh challenge served with a 2xx is
11019
+ * the call's result (`served`, nothing paid); one that fails otherwise
11020
+ * is the call's error (`failed`, `"unpaid"`), never an exact payment
11021
+ * against the stale challenge;
11022
+ * - in doubt: ends only with a definitive success receipt from the one
11023
+ * replay a receipt-less 429 is allowed ({@link resolveInDoubt}), or a
11024
+ * thrown {@link BatchPaymentUnresolvedError}. No new authorization, no
11025
+ * new deposit, no exact payment and no fallback model is ever paid for a
11026
+ * call in doubt.
11027
+ *
11028
+ * The wallet stays in cooldown after a 429, so its other calls wait
11029
+ * instead of sending opens of their own.
11030
+ *
11031
+ * @param closesAtStart - {@link closeFence}, taken before the call's first
11032
+ * (unpaid) request. If a close completed since, the call pays exact
11033
+ * (`closed_during_call`): it started before the close, so it must not
11034
+ * open a channel the caller believes is closed.
9810
11035
  */
9811
- async pay(paymentRequired, send) {
9812
- if (!offersBatch(paymentRequired)) return { kind: "fallback", reason: "not_offered" };
9813
- const wallet = await this.wallet();
9814
- if (typeof wallet === "string") {
9815
- this.warn(void 0, wallet);
9816
- return { kind: "fallback", reason: wallet };
11036
+ async pay(paymentRequired, send, rechallenge, closesAtStart) {
11037
+ const unavailable = batchUnavailable(paymentRequired);
11038
+ if (unavailable) return this.fallback(unavailable);
11039
+ let wallet;
11040
+ try {
11041
+ wallet = await this.wallet();
11042
+ } catch (err) {
11043
+ return this.fallback(creationFailure(err));
9817
11044
  }
9818
- if (wallet.busy) return { kind: "fallback", reason: "channel_busy" };
9819
- wallet.busy = true;
11045
+ if ("reason" in wallet) return this.fallback({ reason: wallet.reason, detail: wallet.detail });
11046
+ if (closesAtStart !== void 0 && wallet.closes !== closesAtStart) {
11047
+ return this.fallback({
11048
+ reason: "closed_during_call",
11049
+ detail: "closeBatchChannel() closed this wallet's channel after this call started; it opens no channel"
11050
+ });
11051
+ }
11052
+ wallet.active += 1;
9820
11053
  try {
9821
- let http;
9822
- let payload;
9823
- try {
9824
- http = (await this.build(wallet)).http;
9825
- payload = await http.createPaymentPayload(paymentRequired);
9826
- } catch (err) {
9827
- const reason = err instanceof Error ? err.message : String(err);
9828
- this.warn(wallet, reason);
9829
- return { kind: "fallback", reason };
11054
+ return await this.payWith(wallet, paymentRequired, send, rechallenge, closesAtStart ?? wallet.closes);
11055
+ } finally {
11056
+ wallet.active -= 1;
11057
+ }
11058
+ }
11059
+ /**
11060
+ * The close fence for a call about to send its first request: how many
11061
+ * closes this wallet has completed so far, to hand to {@link pay}.
11062
+ * `close()` refuses while a call is inside `pay()`, but a call still
11063
+ * waiting for its 402 is not there yet; the fence covers that window.
11064
+ * Undefined when the wallet cannot be named (no fence then; `pay()`
11065
+ * reports why batch is unavailable).
11066
+ */
11067
+ async closeFence() {
11068
+ try {
11069
+ return registry2.wallets.get(await this.init.address())?.closes ?? 0;
11070
+ } catch {
11071
+ return void 0;
11072
+ }
11073
+ }
11074
+ /** {@link pay}, for a wallet this client may use. */
11075
+ async payWith(wallet, paymentRequired, send, rechallenge, fence) {
11076
+ let current = paymentRequired;
11077
+ const fallback = async (event) => {
11078
+ const result = await this.fallback(event);
11079
+ return current === paymentRequired ? result : { ...result, paymentRequired: current };
11080
+ };
11081
+ const budget = { attempt: 1, waited: 0 };
11082
+ let waitedUntil = 0;
11083
+ let backedOff;
11084
+ let holding = false;
11085
+ let sent;
11086
+ const release = () => {
11087
+ if (holding) wallet.busy = false;
11088
+ holding = false;
11089
+ };
11090
+ try {
11091
+ for (; ; ) {
11092
+ const now = Date.now();
11093
+ if (wallet.cooldownUntil > now && wallet.cooldownUntil !== waitedUntil) {
11094
+ const wait = wallet.cooldownUntil - now;
11095
+ if (budget.waited + wait > this.maxWaitMs) {
11096
+ return fallback({
11097
+ reason: "rate_limited",
11098
+ retryAfterMs: wait,
11099
+ attempt: budget.attempt,
11100
+ detail: "wallet is in a 429 cooldown longer than batch.rateLimit.maxWaitMs allows"
11101
+ });
11102
+ }
11103
+ await this.report({ type: "backoff", reason: "cooldown", retryAfterMs: wait, attempt: budget.attempt });
11104
+ backedOff ??= "cooldown";
11105
+ waitedUntil = wallet.cooldownUntil;
11106
+ budget.waited += wait;
11107
+ await sleep2(wait);
11108
+ continue;
11109
+ }
11110
+ if (budget.attempt > 1 || budget.waited > 0) {
11111
+ let fresh;
11112
+ try {
11113
+ fresh = await rechallenge();
11114
+ } catch (err) {
11115
+ return { kind: "failed", error: withDisposition(err instanceof Error ? err : new Error(String(err)), "unpaid") };
11116
+ }
11117
+ if (fresh.kind === "served") {
11118
+ await this.report({
11119
+ type: "recovered",
11120
+ reason: "served_unpaid_on_rechallenge",
11121
+ status: fresh.response.status,
11122
+ attempt: budget.attempt,
11123
+ detail: "the fresh challenge after the wait was served without a payment; nothing was paid"
11124
+ });
11125
+ return { kind: "served", response: fresh.response };
11126
+ }
11127
+ current = fresh.paymentRequired;
11128
+ const unavailable = batchUnavailable(current);
11129
+ if (unavailable) return fallback({ ...unavailable, attempt: budget.attempt });
11130
+ }
11131
+ if (wallet.closes !== fence) {
11132
+ return fallback({
11133
+ reason: "closed_during_call",
11134
+ attempt: budget.attempt,
11135
+ detail: "closeBatchChannel() closed this wallet's channel after this call started; it opens no channel"
11136
+ });
11137
+ }
11138
+ if (wallet.busy) return fallback({ reason: "channel_busy", attempt: budget.attempt });
11139
+ if (wallet.file && !holdsChannelFile(wallet.file)) {
11140
+ return fallback({
11141
+ reason: "channel_store_locked",
11142
+ attempt: budget.attempt,
11143
+ detail: `this process no longer holds the lock on ${wallet.file}; another owner may be paying into its channels`
11144
+ });
11145
+ }
11146
+ wallet.busy = true;
11147
+ holding = true;
11148
+ let built;
11149
+ try {
11150
+ built = await this.buildPayment(wallet, current);
11151
+ } catch (err) {
11152
+ release();
11153
+ if (err instanceof ResyncError) return fallback({ reason: err.reason, detail: err.message, attempt: budget.attempt });
11154
+ return fallback({ ...creationFailure(err), attempt: budget.attempt });
11155
+ }
11156
+ if (built.kind !== "authorization") {
11157
+ try {
11158
+ this.journalDeposit(wallet, built);
11159
+ } catch (err) {
11160
+ await this.settle(built.http, built.payload, () => null, 0);
11161
+ release();
11162
+ return fallback({
11163
+ reason: "deposit_journal_failed",
11164
+ attempt: budget.attempt,
11165
+ detail: err instanceof Error ? err.message : String(err)
11166
+ });
11167
+ }
11168
+ }
11169
+ sent = built;
11170
+ const outcome = await this.sendOnce(wallet, built, send, backedOff === "rate_limited", false);
11171
+ if (outcome.kind === "charged") {
11172
+ if (built.kind !== "authorization" && !(built.key && wallet.resyncs.has(built.key))) this.forgetIntent(wallet, built.key);
11173
+ sent = void 0;
11174
+ release();
11175
+ if (backedOff) await this.report({ type: "recovered", reason: backedOff, attempt: budget.attempt });
11176
+ return { kind: "paid", response: outcome.response, chargedUsd: outcome.chargedUsd };
11177
+ }
11178
+ if (outcome.kind === "not_charged") {
11179
+ if (built.kind !== "authorization") {
11180
+ if (outcome.depositSafe) this.forgetIntent(wallet, built.key);
11181
+ else this.distrust(wallet, built, "deposit_refused");
11182
+ }
11183
+ sent = void 0;
11184
+ release();
11185
+ if (!outcome.rateLimited) {
11186
+ const { kind: _kind, rateLimited: _limited, depositSafe: _safe, ...event } = outcome;
11187
+ return fallback({ ...event, attempt: budget.attempt });
11188
+ }
11189
+ const wait = this.coolDown(wallet, outcome.rateLimited.retryAfterMs ?? backoffDelay(budget.attempt));
11190
+ if (budget.attempt >= this.maxAttempts || budget.waited + wait > this.maxWaitMs) {
11191
+ return fallback({
11192
+ reason: "rate_limited",
11193
+ status: 429,
11194
+ errorReason: outcome.errorReason,
11195
+ retryAfterMs: wait,
11196
+ attempt: budget.attempt,
11197
+ detail: budget.attempt >= this.maxAttempts ? "batch.rateLimit.maxAttempts reached" : "Retry-After exceeds what is left of batch.rateLimit.maxWaitMs"
11198
+ });
11199
+ }
11200
+ await this.report({
11201
+ type: "backoff",
11202
+ reason: "rate_limited",
11203
+ status: 429,
11204
+ errorReason: outcome.errorReason,
11205
+ retryAfterMs: wait,
11206
+ attempt: budget.attempt
11207
+ });
11208
+ backedOff = "rate_limited";
11209
+ waitedUntil = wallet.cooldownUntil;
11210
+ budget.waited += wait;
11211
+ await sleep2(wait);
11212
+ budget.attempt += 1;
11213
+ continue;
11214
+ }
11215
+ if (outcome.kind === "cancelled") {
11216
+ sent = void 0;
11217
+ release();
11218
+ await this.report({
11219
+ type: "recovered",
11220
+ reason: "batch_cancelled",
11221
+ status: outcome.status,
11222
+ errorReason: "batch_cancelled",
11223
+ attempt: budget.attempt,
11224
+ detail: "the gateway cancelled the payment: nothing was charged, and its error is raised unpaid"
11225
+ });
11226
+ return { kind: "failed", error: outcome.error };
11227
+ }
11228
+ const resolved = await this.resolveInDoubt(wallet, built, outcome, send, budget);
11229
+ if (resolved.kind === "charged") {
11230
+ if (built.kind !== "authorization" && !(built.key && wallet.resyncs.has(built.key))) this.forgetIntent(wallet, built.key);
11231
+ sent = void 0;
11232
+ release();
11233
+ await this.report({ type: "recovered", reason: "rate_limited", attempt: budget.attempt });
11234
+ return { kind: "paid", response: resolved.response, chargedUsd: resolved.chargedUsd };
11235
+ }
11236
+ return await this.raiseUnresolved(wallet, built, resolved, budget.attempt);
9830
11237
  }
9831
- const deposit = payload.payload?.type === "deposit";
9832
- let response;
11238
+ } catch (err) {
11239
+ if (err instanceof Error && err.name === "BatchPaymentUnresolvedError") throw err;
11240
+ if (sent) {
11241
+ try {
11242
+ return await this.raiseUnresolved(
11243
+ wallet,
11244
+ sent,
11245
+ {
11246
+ kind: "in_doubt",
11247
+ reason: "outcome_unknown",
11248
+ replayable: false,
11249
+ settled: false,
11250
+ detail: `an error after the payment was sent: ${err instanceof Error ? err.message : String(err)}`,
11251
+ cause: err
11252
+ },
11253
+ budget.attempt
11254
+ );
11255
+ } catch (raised) {
11256
+ throw withDisposition(raised, "paid-or-in-doubt");
11257
+ }
11258
+ }
11259
+ return fallback({ ...creationFailure(err), attempt: budget.attempt });
11260
+ } finally {
11261
+ release();
11262
+ }
11263
+ }
11264
+ /**
11265
+ * Put the wallet in a 429 cooldown for `delay` ms (`Retry-After`, or the
11266
+ * default backoff), and return how long this call would have to wait.
11267
+ *
11268
+ * The shared cooldown is capped at `rateLimit.maxWaitMs`, so one header,
11269
+ * however long, never keeps the wallet's calls off batch for longer than
11270
+ * the retry budget. This call still weighs the full delay: one it cannot
11271
+ * wait out within its budget is not waited at all, and the caller pays
11272
+ * exact (or raises a payment in doubt) at once.
11273
+ */
11274
+ coolDown(wallet, delay) {
11275
+ const limitedAt = Date.now();
11276
+ wallet.cooldownUntil = Math.max(wallet.cooldownUntil, limitedAt + Math.min(delay, this.maxWaitMs));
11277
+ return Math.max(delay, wallet.cooldownUntil - limitedAt);
11278
+ }
11279
+ /**
11280
+ * Try to end a payment in doubt with the gateway's own word.
11281
+ *
11282
+ * The one source of evidence this SDK accepts today is a definitive
11283
+ * success receipt on the single byte-identical replay a 429 without a
11284
+ * receipt is allowed, after its backoff (the owner's bounded safety
11285
+ * attempt). Any other answer to it (a 402 or `duplicate_settlement`, which
11286
+ * mean the original reached the gateway; another 429; a 5xx; a timeout)
11287
+ * leaves it in doubt. Every other in-doubt outcome is never replayed.
11288
+ *
11289
+ * Chain state is not evidence here: it can settle a deposit (see
11290
+ * `resync`), never whether the gateway charged. Gateway-side evidence (a
11291
+ * receipt on every response, a request-status endpoint with a fence)
11292
+ * plugs in here, before the caller raises.
11293
+ *
11294
+ * @returns the charged replay, or the doubt to raise.
11295
+ */
11296
+ async resolveInDoubt(wallet, sent, doubt, send, budget) {
11297
+ if (!doubt.replayable) return doubt;
11298
+ const wait = this.coolDown(wallet, doubt.retryAfterMs ?? backoffDelay(budget.attempt));
11299
+ if (budget.attempt >= this.maxAttempts || budget.waited + wait > this.maxWaitMs) {
11300
+ return {
11301
+ ...doubt,
11302
+ detail: `${doubt.detail}; batch.rateLimit leaves no room for its one replay`
11303
+ };
11304
+ }
11305
+ await this.report({
11306
+ type: "backoff",
11307
+ reason: "rate_limited",
11308
+ status: 429,
11309
+ errorReason: doubt.errorReason,
11310
+ retryAfterMs: wait,
11311
+ attempt: budget.attempt
11312
+ });
11313
+ budget.waited += wait;
11314
+ await sleep2(wait);
11315
+ budget.attempt += 1;
11316
+ const replayed = await this.sendOnce(wallet, sent, send, true, true);
11317
+ if (replayed.kind === "charged") return replayed;
11318
+ return replayed.kind === "in_doubt" ? replayed : { kind: "in_doubt", reason: "replay_unresolved", replayable: false, settled: true, detail: "the replay was refused" };
11319
+ }
11320
+ /**
11321
+ * Raise a payment in doubt: release the scheme's hold on it (the channel
11322
+ * goes back to its confirmed state), put a deposit's channel in doubt so
11323
+ * it is re-read from the chain before anything pays into it again, report
11324
+ * the `unresolved` event, and throw {@link BatchPaymentUnresolvedError}.
11325
+ */
11326
+ async raiseUnresolved(wallet, sent, doubt, attempt) {
11327
+ if (!doubt.settled) await this.settle(sent.http, sent.payload, () => null, 0);
11328
+ if (sent.kind !== "authorization") {
11329
+ this.distrust(
11330
+ wallet,
11331
+ sent,
11332
+ doubt.reason === "no_response" ? "deposit_unanswered" : doubt.reason === "replay_unresolved" ? "deposit_rate_limited" : "deposit_failed"
11333
+ );
11334
+ }
11335
+ await this.report({
11336
+ type: "unresolved",
11337
+ reason: doubt.reason,
11338
+ status: doubt.status,
11339
+ errorReason: doubt.errorReason,
11340
+ attempt,
11341
+ detail: `${doubt.detail}; it may have been charged, so it is not paid again`
11342
+ });
11343
+ throw new BatchPaymentUnresolvedError({
11344
+ reason: doubt.reason,
11345
+ wallet: await this.init.address(),
11346
+ requestId: sent.payload.payload?.authorization?.requestId,
11347
+ channelId: payloadChannelId(sent.payload),
11348
+ payloadKind: sent.kind,
11349
+ status: doubt.status,
11350
+ detail: doubt.detail,
11351
+ cause: doubt.cause
11352
+ });
11353
+ }
11354
+ /** Build a new batch payment for a 402: the payload, its headers and its kind. */
11355
+ async buildPayment(wallet, paymentRequired) {
11356
+ const { http, payload } = await this.createPayload(wallet, paymentRequired);
11357
+ const headers = http.encodePaymentSignatureHeader(payload);
11358
+ const channelId = payloadChannelId(payload);
11359
+ const known = channelId ? wallet.book.find(channelId) : void 0;
11360
+ let kind = "authorization";
11361
+ if (payload.payload?.type === "deposit") {
11362
+ const record = known?.record;
11363
+ kind = record && (record.pending === void 0 || record.hasConfirmedState) ? "top-up" : "open";
11364
+ }
11365
+ return { http, payload, headers, kind, key: known?.key, sentAt: Date.now(), sends: 0 };
11366
+ }
11367
+ /**
11368
+ * Write a deposit's intent to the wallet's journal before it is sent
11369
+ * (with the file store: the file and its directory fsynced).
11370
+ *
11371
+ * @throws when it cannot be recorded; the deposit must then not be sent.
11372
+ */
11373
+ journalDeposit(wallet, sent) {
11374
+ const channelId = payloadChannelId(sent.payload);
11375
+ const record = channelId ? wallet.book.find(channelId)?.record : void 0;
11376
+ const { expectDeposit } = this.depositExpectation(wallet, sent.payload);
11377
+ if (!sent.key || !channelId || !record || expectDeposit === void 0 || sent.kind === "authorization") {
11378
+ throw new Error("the deposit's channel record is missing");
11379
+ }
11380
+ const hasConfirmed = record.pending === void 0 || record.hasConfirmedState;
11381
+ sent.expectDeposit = expectDeposit;
11382
+ wallet.intents.put({
11383
+ key: sent.key,
11384
+ channelId,
11385
+ channelConfig: record.channelConfig,
11386
+ requestId: sent.payload.payload?.authorization?.requestId,
11387
+ kind: sent.kind,
11388
+ cumulative: hasConfirmed ? String(parseAtomic(record.chargedCumulativeAmount)) : "0",
11389
+ expectDeposit: expectDeposit.toString(),
11390
+ ...hasConfirmed ? { knownDeposit: String(parseAtomic(record.deposit)) } : {},
11391
+ at: Date.now()
11392
+ });
11393
+ }
11394
+ /**
11395
+ * Remove a deposit's intent once it is reconciled. A failure is logged,
11396
+ * not thrown: the intent then outlives its deposit, and after a restart
11397
+ * the chain re-read settles it again.
11398
+ */
11399
+ forgetIntent(wallet, key) {
11400
+ if (!key) return;
11401
+ try {
11402
+ wallet.intents.remove(key);
11403
+ } catch (err) {
11404
+ console.error(
11405
+ `[@blockrun/llm] batch-settlement could not clear a deposit intent: ${JSON.stringify(err instanceof Error ? err.message : String(err))}`
11406
+ );
11407
+ }
11408
+ }
11409
+ /**
11410
+ * THE choke point: send a batch payment once and classify the answer.
11411
+ *
11412
+ * The only caller of `send` for a batch payment, and the only place its
11413
+ * outcome is decided (see {@link SendOutcome}). Every exception once the
11414
+ * send has started is `in_doubt`, whatever its type or `cause.code`: the
11415
+ * request may have reached the gateway. "Not charged" is only ever minted
11416
+ * here, and only for a first send.
11417
+ *
11418
+ * @param retry - count this send in `retries` (it follows this call's own 429).
11419
+ * @param replay - this is the one replay of a receipt-less 429: only a 2xx
11420
+ * with a success receipt counts, everything else stays in doubt.
11421
+ */
11422
+ async sendOnce(wallet, sent, send, retry, replay) {
11423
+ if (retry) this.counters.retries += 1;
11424
+ sent.sends += 1;
11425
+ let response;
11426
+ try {
11427
+ response = await send(sent.headers);
11428
+ } catch (err) {
11429
+ return {
11430
+ kind: "in_doubt",
11431
+ reason: replay ? "replay_unresolved" : "no_response",
11432
+ replayable: false,
11433
+ settled: false,
11434
+ detail: `${replay ? "the replay" : "the payment"} got no answer (${err instanceof Error ? `${err.name}: ${err.message}` : String(err)})`,
11435
+ cause: err
11436
+ };
11437
+ }
11438
+ try {
11439
+ return await this.classify(wallet, sent, response, replay);
11440
+ } catch (err) {
11441
+ return {
11442
+ kind: "in_doubt",
11443
+ reason: replay ? "replay_unresolved" : "outcome_unknown",
11444
+ replayable: false,
11445
+ settled: false,
11446
+ status: response.status,
11447
+ detail: `the answer could not be classified (${err instanceof Error ? err.message : String(err)})`,
11448
+ cause: err
11449
+ };
11450
+ }
11451
+ }
11452
+ /** {@link sendOnce}'s classifier, for an HTTP answer. */
11453
+ async classify(wallet, sent, response, replay) {
11454
+ const { http, payload } = sent;
11455
+ const getHeader = (name) => response.headers.get(name);
11456
+ const receipted = Boolean(getHeader("PAYMENT-RESPONSE") || getHeader("X-PAYMENT-RESPONSE"));
11457
+ const status2 = response.status;
11458
+ if (status2 === 429 && !receipted) {
11459
+ return {
11460
+ kind: "in_doubt",
11461
+ reason: "replay_unresolved",
11462
+ replayable: !replay,
11463
+ settled: false,
11464
+ status: status2,
11465
+ retryAfterMs: parseRetryAfter(getHeader("retry-after")),
11466
+ errorReason: await rateLimitReason(response, void 0),
11467
+ detail: replay ? "the replay was answered 429 without a receipt" : "a 429 without a receipt"
11468
+ };
11469
+ }
11470
+ const channelId = payloadChannelId(payload);
11471
+ const before = channelId ? confirmedState(wallet.book.find(channelId)?.record) : void 0;
11472
+ const reconciled = await this.settle(http, payload, getHeader, status2);
11473
+ const receipt = reconciled.settleResponse;
11474
+ const definitive = receipt?.success === true && reconciled.ok && recordAdvanced(before, channelId ? confirmedState(wallet.book.find(channelId)?.record) : void 0, payload, receipt);
11475
+ if (!response.ok && !replay && sent.kind === "authorization" && cancelledReceipt(receipt)) {
11476
+ return { kind: "cancelled", status: status2, error: await cancelledError(response) };
11477
+ }
11478
+ if (response.ok && (!replay || definitive)) {
11479
+ if (!definitive) {
11480
+ this.distrust(wallet, sent, receipt ? "receipt_unreconciled" : "receipt_missing", rebuiltCharge(receipt, payload));
11481
+ }
11482
+ return { kind: "charged", response, chargedUsd: chargedUsd(() => http.getPaymentSettleResponse(getHeader)) };
11483
+ }
11484
+ if (replay) {
11485
+ return {
11486
+ kind: "in_doubt",
11487
+ reason: "replay_unresolved",
11488
+ replayable: false,
11489
+ settled: true,
11490
+ status: status2,
11491
+ errorReason: status2 === 429 ? await rateLimitReason(response, receipt) : receipt?.errorReason,
11492
+ detail: `the replay was answered ${status2} with ${describeReceipt(receipt)}`,
11493
+ cause: await afterPaymentError(response)
11494
+ };
11495
+ }
11496
+ if (status2 === 429) {
11497
+ if (provesNothingBroadcast(receipt)) {
11498
+ return {
11499
+ kind: "not_charged",
11500
+ reason: "rate_limited",
11501
+ status: status2,
11502
+ errorReason: await rateLimitReason(response, receipt),
11503
+ rateLimited: { retryAfterMs: parseRetryAfter(getHeader("retry-after")) },
11504
+ // The facilitator's explicit failed receipt names no transaction: the
11505
+ // deposit was never broadcast (see provesNothingBroadcast).
11506
+ depositSafe: true
11507
+ };
11508
+ }
11509
+ return {
11510
+ kind: "in_doubt",
11511
+ reason: "ambiguous_rate_limit",
11512
+ replayable: false,
11513
+ settled: true,
11514
+ status: status2,
11515
+ errorReason: await rateLimitReason(response, receipt),
11516
+ detail: `a 429 whose ${describeReceipt(receipt)} does not prove nothing was broadcast`,
11517
+ cause: await afterPaymentError(response)
11518
+ };
11519
+ }
11520
+ if (cleanRefusalReceipt(receipt)) {
11521
+ if (status2 === 402) {
11522
+ const duplicate = await duplicateAnswer(response);
11523
+ if (duplicate) {
11524
+ return {
11525
+ kind: "in_doubt",
11526
+ reason: "duplicate_settlement",
11527
+ replayable: false,
11528
+ settled: true,
11529
+ status: status2,
11530
+ errorReason: duplicate,
11531
+ detail: `the payment was answered 402 ${duplicate}: another copy of this request reached the gateway`,
11532
+ cause: await afterPaymentError(response)
11533
+ };
11534
+ }
11535
+ return { kind: "not_charged", reason: "payment_required", status: status2, depositSafe: false };
11536
+ }
11537
+ const refusal = await batchRefusal(response);
11538
+ if (refusal) {
11539
+ return { kind: "not_charged", reason: refusal, status: status2, errorReason: refusal, depositSafe: PRE_REQUEST_REFUSALS.has(refusal) };
11540
+ }
11541
+ }
11542
+ return {
11543
+ kind: "in_doubt",
11544
+ reason: "outcome_unknown",
11545
+ replayable: false,
11546
+ settled: true,
11547
+ status: status2,
11548
+ errorReason: receipt?.errorReason,
11549
+ detail: `the payment was answered ${status2} with ${describeReceipt(receipt)}`,
11550
+ cause: await afterPaymentError(response)
11551
+ };
11552
+ }
11553
+ /**
11554
+ * Build the payment payload, re-reading any distrusted channel from the
11555
+ * chain first. A stored record the book refuses to hand over (a deposit a
11556
+ * dead process never heard back about), or one a top-up's read found
11557
+ * unusable ({@link prepareTopUp}), is re-read and the payload rebuilt
11558
+ * once.
11559
+ */
11560
+ async createPayload(wallet, paymentRequired) {
11561
+ for (let pass = 0; ; pass += 1) {
11562
+ await this.resync(wallet);
11563
+ const http = (await this.build(wallet)).http;
9833
11564
  try {
9834
- response = await send(http.encodePaymentSignatureHeader(payload));
11565
+ await this.prepareTopUp(wallet, paymentRequired);
11566
+ return { http, payload: await http.createPaymentPayload(withoutClientSignedBatch(paymentRequired)) };
9835
11567
  } catch (err) {
9836
- if (deposit) await this.forget(wallet);
9837
- else await this.settle(http, payload, () => null, 0);
9838
- throw err;
11568
+ if (pass > 0 || !isResyncRequired(err) || wallet.resyncs.size === 0) throw err;
11569
+ } finally {
11570
+ wallet.topUp = void 0;
9839
11571
  }
9840
- const getHeader = (name) => response.headers.get(name);
9841
- const settled = await this.settle(http, payload, getHeader, response.status);
9842
- if (deposit && !(response.ok && settled?.success === true)) await this.forget(wallet);
9843
- if (response.ok) {
9844
- const charged = atomicToUsd(settled?.amount) ?? atomicToUsd(payload.accepted?.amount) ?? 0;
9845
- return { kind: "paid", response, chargedUsd: charged };
11572
+ }
11573
+ }
11574
+ /**
11575
+ * When this payment will top up the wallet's channel, read how much of its
11576
+ * deposit is already settled on-chain (at `finalized` commitment, so never
11577
+ * more than will stay settled), so `maxDeposit` caps the escrow still at
11578
+ * stake rather than every deposit the channel ever took.
11579
+ *
11580
+ * Only a top-up costs this read (a channel's balance decides it, from the
11581
+ * stored record). If the read fails, nothing settled is assumed, which is
11582
+ * the stricter, lifetime cap. If the chain shows a larger deposit than the
11583
+ * record, the record is behind (a deposit it never heard about landed):
11584
+ * sizing a top-up from it could exceed `maxDeposit`, so this call pays
11585
+ * exact and the channel is re-read before the next payment. If
11586
+ * `@x402/svm`'s channel layout is not the one this SDK decodes, or the
11587
+ * account at the channel's address cannot be read as a channel, the call
11588
+ * pays exact (`channel_unreadable`), record kept. If
11589
+ * the finalized channel is closing or closed, or is not this wallet's for
11590
+ * the record's operator and mint, no top-up is sized from the record: it
11591
+ * is re-read at once (`channel_unusable`), see {@link createPayload}.
11592
+ */
11593
+ async prepareTopUp(wallet, paymentRequired) {
11594
+ wallet.topUp = void 0;
11595
+ const accept = trustedBatchAccept(paymentRequired, this.init.options.operators);
11596
+ if (!accept) return;
11597
+ const key = channelKeyOf(accept);
11598
+ const record = key ? await wallet.book.get(key) : void 0;
11599
+ if (!key || !record || record.pending || typeof record.channelId !== "string") return;
11600
+ const deposit = parseAtomic(record.deposit);
11601
+ if (parseAtomic(record.chargedCumulativeAmount) + parseAtomic(accept.amount) <= deposit) return;
11602
+ let channel;
11603
+ try {
11604
+ channel = await readChannelAccount(this.init.rpcUrl, record.channelId, this.init.rpcHeaders);
11605
+ } catch (err) {
11606
+ if (err instanceof ChannelLayoutError) {
11607
+ throw new ResyncError("channel_unreadable", `channel ${record.channelId}: ${err.message}; no top-up is sized from it`);
9846
11608
  }
9847
- const refusal = await isBatchRefusal(response);
9848
- if (refusal) {
9849
- this.warn(wallet, refusal);
9850
- return { kind: "fallback", reason: refusal };
11609
+ if (err instanceof ChannelUnreadableError) {
11610
+ throw new ResyncError("channel_unreadable", `${err.message}; no top-up is signed into it`);
9851
11611
  }
9852
- return { kind: "failed", response };
9853
- } finally {
9854
- wallet.busy = false;
11612
+ throw new ResyncError(
11613
+ "channel_resync_failed",
11614
+ `could not read channel ${record.channelId} before topping it up: ${err instanceof Error ? err.message : String(err)}`
11615
+ );
9855
11616
  }
11617
+ if (channel === void 0) return;
11618
+ if (!channel.open || channel.payer !== await this.init.address() || channel.authorizedSigner !== record.channelConfig.payerAuthorizer || channel.mint !== record.channelConfig.token) {
11619
+ addResyncTarget(wallet, {
11620
+ key,
11621
+ channelId: record.channelId,
11622
+ channelConfig: record.channelConfig,
11623
+ cumulative: parseAtomic(record.chargedCumulativeAmount),
11624
+ knownDeposit: deposit,
11625
+ reason: "channel_unusable"
11626
+ });
11627
+ throw new ChannelResyncRequiredError(record.channelId);
11628
+ }
11629
+ if (channel.deposit > deposit) {
11630
+ addResyncTarget(wallet, {
11631
+ key,
11632
+ channelId: record.channelId,
11633
+ channelConfig: record.channelConfig,
11634
+ cumulative: parseAtomic(record.chargedCumulativeAmount),
11635
+ knownDeposit: channel.deposit,
11636
+ reason: "deposit_unrecorded"
11637
+ });
11638
+ throw new ResyncError(
11639
+ "channel_resync_pending",
11640
+ `channel ${record.channelId} holds ${channel.deposit} on chain, more than the ${deposit} its record knows; it is re-read before any top-up`
11641
+ );
11642
+ }
11643
+ wallet.topUp = { key, deposit, settled: channel.settled };
9856
11644
  }
9857
11645
  /**
9858
11646
  * Hand the gateway's answer to the scheme, which verifies the operator's
@@ -9863,36 +11651,143 @@ var SolanaBatchPayer = class {
9863
11651
  */
9864
11652
  async settle(http, payload, getHeader, status2) {
9865
11653
  try {
9866
- return (await http.processPaymentResult(payload, getHeader, status2)).settleResponse;
11654
+ return { settleResponse: (await http.processPaymentResult(payload, getHeader, status2)).settleResponse, ok: true };
9867
11655
  } catch (err) {
9868
11656
  console.error(
9869
- `[@blockrun/llm] batch-settlement receipt not reconciled: ${err instanceof Error ? err.message : String(err)}`
11657
+ `[@blockrun/llm] batch-settlement receipt not reconciled: ${JSON.stringify(err instanceof Error ? err.message : String(err))}`
9870
11658
  );
9871
- return void 0;
11659
+ let settleResponse;
11660
+ try {
11661
+ settleResponse = http.getPaymentSettleResponse(getHeader);
11662
+ } catch {
11663
+ settleResponse = void 0;
11664
+ }
11665
+ return { settleResponse, ok: false };
9872
11666
  }
9873
11667
  }
9874
11668
  /**
9875
- * Close the channel and return its unused escrow to the wallet.
11669
+ * Close the channel the gateway's current challenge names and return its
11670
+ * unused escrow to the wallet.
9876
11671
  *
9877
11672
  * The scheme keeps a closed channel in its records, and would go on paying
9878
- * into it, so a successful close forgets the channel. The next batch call
9879
- * finds no open channel on-chain and opens a new one.
11673
+ * into it, so a successful close forgets that channel. The next batch call
11674
+ * finds no open channel on-chain and opens a new one. Any other channel
11675
+ * (say, one opened with an operator key that is being rotated out) keeps
11676
+ * its record, so it can still be closed without an on-chain scan. A
11677
+ * client-signed channel an earlier SDK version opened is closed too, once
11678
+ * the trusted server-signed one has no record (see {@link refundTarget}).
9880
11679
  *
9881
11680
  * @param url - any batch-enabled route on the gateway the channel was opened with.
11681
+ * @throws BatchCloseDeferredError while a batch call for the wallet is in
11682
+ * flight, or while any of its deposits is in doubt (until a finalized
11683
+ * chain read settles it). Nothing is refunded or forgotten then.
9882
11684
  */
9883
11685
  async close(url) {
9884
11686
  const wallet = await this.wallet();
9885
- if (typeof wallet === "string") throw new Error(`batch-settlement unavailable: ${wallet}`);
9886
- if (wallet.busy) throw new Error("batch-settlement channel has a request in flight; close it when the call returns");
11687
+ if ("reason" in wallet) throw new Error(`batch-settlement unavailable: ${wallet.detail}`);
11688
+ if (wallet.busy || wallet.active > 0) {
11689
+ throw new BatchCloseDeferredError({
11690
+ reason: "call_in_flight",
11691
+ wallet: await this.init.address(),
11692
+ detail: "a batch call is in flight (it may be waiting out a 429); close the channel when it returns"
11693
+ });
11694
+ }
9887
11695
  wallet.busy = true;
9888
11696
  try {
9889
- const result = await (await this.build(wallet)).refund(url);
9890
- await this.forget(wallet);
11697
+ await this.settleDoubtsForClose(wallet);
11698
+ const { accept, key } = await this.refundTarget(wallet, url);
11699
+ const result = await (await this.prepareClose(wallet, key)).refund(url, accept);
11700
+ wallet.closes += 1;
11701
+ await this.forget(wallet, key);
9891
11702
  return result;
9892
11703
  } finally {
9893
11704
  wallet.busy = false;
9894
11705
  }
9895
11706
  }
11707
+ /**
11708
+ * Re-read every distrusted channel before a close. A deposit still in
11709
+ * doubt defers the close: refunding (or forgetting) a channel a deposit
11710
+ * may yet land on could strand that deposit, and after a restart nothing
11711
+ * would remember it. A record re-read that only failed for another reason
11712
+ * (an RPC error on a channel with no deposit in doubt) does not.
11713
+ */
11714
+ async settleDoubtsForClose(wallet) {
11715
+ try {
11716
+ await this.resync(wallet);
11717
+ } catch (err) {
11718
+ if (!(err instanceof ResyncError)) throw err;
11719
+ const inDoubt = [...wallet.resyncs.values()].filter((target) => target.expectDeposit !== void 0);
11720
+ if (inDoubt.length > 0) {
11721
+ throw new BatchCloseDeferredError({
11722
+ reason: "deposit_in_doubt",
11723
+ wallet: await this.init.address(),
11724
+ channelIds: inDoubt.map((target) => target.channelId),
11725
+ detail: `a deposit on channel ${inDoubt.map((target) => target.channelId).join(", ")} is in doubt (${err.message}); close once a finalized chain read settles it`
11726
+ });
11727
+ }
11728
+ }
11729
+ }
11730
+ /**
11731
+ * The channel a close targets: the one the gateway's refund challenge
11732
+ * names, by the scheme's storage key for its trusted server-signed accept
11733
+ * (network, asset, receiver, fee payer, withdraw delay, receiver
11734
+ * authorizer, operator). Probed with an unpaid GET, as `@x402/svm`'s own
11735
+ * refund probes; that accept is then handed to the refund, so upstream
11736
+ * does not probe again and pick another accept.
11737
+ *
11738
+ * A legacy client-signed channel (opened by an earlier SDK version, which
11739
+ * could pay a client-signed accept; this one never opens one) must stay
11740
+ * refundable. When the trusted server-signed accept has no stored record,
11741
+ * or there is none, a client-signed accept in the challenge whose storage
11742
+ * key names a stored client-signed record is the target instead, and the
11743
+ * refund runs through `@x402/svm`'s client-signed path (a refund voucher
11744
+ * signed by the payer at its confirmed cumulative). The signer modes match:
11745
+ * a server-signed record is never refunded under a client-signed accept.
11746
+ */
11747
+ async refundTarget(wallet, url) {
11748
+ const probe = await fetch(url, { method: "GET" });
11749
+ const header = probe.status === 402 ? probe.headers.get("PAYMENT-REQUIRED") : null;
11750
+ if (!header) throw new Error(`refund probe expected a 402 with PAYMENT-REQUIRED from ${url}, got HTTP ${probe.status}`);
11751
+ const paymentRequired = parsePaymentRequired(header);
11752
+ const trustedAccepts = trustedBatchAccepts(paymentRequired, this.init.options.operators).map((accept) => ({ accept, key: channelKeyOf(accept) })).filter((candidate) => !!candidate.key);
11753
+ for (const candidate of trustedAccepts) {
11754
+ if (await wallet.book.base.get(candidate.key)) return candidate;
11755
+ }
11756
+ for (const accept of (paymentRequired.accepts ?? []).filter(clientSignedBatch)) {
11757
+ const key = channelKeyOf(accept);
11758
+ const record = key ? await wallet.book.base.get(key) : void 0;
11759
+ if (key && record && (record.channelConfig?.voucherSigner ?? "client") === "client") return { accept, key };
11760
+ }
11761
+ const first = trustedAccepts[0];
11762
+ if (!first) throw new Error(`${url} offers no batch-settlement accept with a trusted operator to close against`);
11763
+ return first;
11764
+ }
11765
+ /**
11766
+ * Get the stored channel `key` ready to refund.
11767
+ *
11768
+ * `@x402/svm` refunds a server-signed channel only from the scheme's memory,
11769
+ * or after an on-chain scan that many RPCs refuse; it never reads the
11770
+ * stored record. So the target channel is loaded into a freshly built
11771
+ * scheme, and only that one: upstream's refund falls back to the first
11772
+ * channel in memory for the same receiver and asset, whatever its operator.
11773
+ * A record in doubt (a deposit this process never confirmed, or a pending
11774
+ * deposit left by a process that died) is re-read from the chain before it
11775
+ * is loaded. If a re-read cannot settle a record, the refund is still
11776
+ * attempted and may find the channel by its scan.
11777
+ */
11778
+ async prepareClose(wallet, key) {
11779
+ for (let pass = 0; ; pass += 1) {
11780
+ await this.settleDoubtsForClose(wallet);
11781
+ wallet.client = void 0;
11782
+ const built = await this.build(wallet);
11783
+ try {
11784
+ await built.load(key);
11785
+ return built;
11786
+ } catch (err) {
11787
+ if (pass > 0 || !isResyncRequired(err)) return built;
11788
+ }
11789
+ }
11790
+ }
9896
11791
  };
9897
11792
 
9898
11793
  // src/solana-client.ts
@@ -9961,6 +11856,18 @@ async function waitForStaleRetry(attempt) {
9961
11856
  (resolve2) => setTimeout(resolve2, STALE_BLOCKHASH_RETRY_BACKOFFS_MS[attempt])
9962
11857
  );
9963
11858
  }
11859
+ async function unpaidApiError(response) {
11860
+ let errorBody;
11861
+ try {
11862
+ errorBody = await response.json();
11863
+ } catch {
11864
+ errorBody = { error: "Request failed" };
11865
+ }
11866
+ return withDisposition(
11867
+ new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody)),
11868
+ "unpaid"
11869
+ );
11870
+ }
9964
11871
  var DEFAULT_SOLANA_RPC_URL = "https://sol.blockrun.ai/api/v1/solana/rpc";
9965
11872
  function resolveRpcConfig(rpcUrl, rpcHeaders) {
9966
11873
  const env = typeof process !== "undefined" && process.env ? process.env : {};
@@ -10027,21 +11934,42 @@ var SolanaLLMClient = class {
10027
11934
  options: options.batch,
10028
11935
  secretKey: () => solanaKeyToBytes(this.privateKey),
10029
11936
  address: () => this.getWalletAddress(),
10030
- rpcUrl: this.rpcUrl
11937
+ rpcUrl: this.rpcUrl,
11938
+ rpcHeaders: this.rpcHeaders
10031
11939
  });
10032
11940
  }
10033
11941
  }
10034
11942
  /**
10035
11943
  * Close this wallet's batch-settlement channel and return its unused escrow.
10036
11944
  *
10037
- * The gateway closes it cooperatively when it can; otherwise this starts a
10038
- * payer-forced close and the escrow comes back after the channel's grace
10039
- * period. Only meaningful with the `batch` option.
11945
+ * Closes the channel for the operator the gateway's current 402 names; a
11946
+ * channel opened with another trusted operator key (during a key rotation)
11947
+ * keeps its record. The gateway closes it cooperatively when it can;
11948
+ * otherwise this starts a payer-forced close and the escrow comes back
11949
+ * after the channel's grace period. Only meaningful with the `batch`
11950
+ * option.
11951
+ *
11952
+ * @throws BatchCloseDeferredError, closing nothing, while a chat call for
11953
+ * this wallet is still in flight (including one waiting out a 429, so a
11954
+ * retry can never reopen a channel you just closed), or while one of its
11955
+ * deposits is in doubt (until a `finalized` chain read shows it landed or
11956
+ * that it no longer can, usually a few minutes after it was sent). Call
11957
+ * it again later.
10040
11958
  */
10041
11959
  async closeBatchChannel() {
10042
11960
  if (!this.batchPayer) throw new Error("closeBatchChannel() requires the `batch` option in wallet mode");
10043
11961
  return this.batchPayer.close(`${this.apiUrl}/v1/chat/completions`);
10044
11962
  }
11963
+ /**
11964
+ * This client's batch-settlement counters: calls that fell back to `exact`
11965
+ * (in total and by reason), 429 backoffs, batch retries, and recoveries.
11966
+ * Every one of those is also logged to stderr and passed to `batch.onEvent`.
11967
+ * Only meaningful with the `batch` option.
11968
+ */
11969
+ getBatchStats() {
11970
+ if (!this.batchPayer) throw new Error("getBatchStats() requires the `batch` option in wallet mode");
11971
+ return this.batchPayer.stats();
11972
+ }
10045
11973
  /** Get Solana wallet address (public key in base58). */
10046
11974
  async getWalletAddress() {
10047
11975
  if (!this.addressCache) {
@@ -10565,17 +12493,34 @@ var SolanaLLMClient = class {
10565
12493
  isSolana() {
10566
12494
  return this.apiUrl.includes("sol.blockrun.ai");
10567
12495
  }
12496
+ /**
12497
+ * Chat request with automatic x402 payment.
12498
+ *
12499
+ * Every error it throws carries a {@link RetryDisposition}, which is what
12500
+ * {@link SolanaLLMClient.chatCompletion}'s fallback walk honours: errors of
12501
+ * the unpaid first request, its 402 challenge and the signing step are
12502
+ * `"unpaid"`; anything after a payment was sent (exact or batch) is
12503
+ * `"paid-or-in-doubt"`, and so is any error with an API key once the billed
12504
+ * request was sent, except the account API's explicit 4xx refusal.
12505
+ */
10568
12506
  async requestWithPayment(endpoint, body) {
10569
12507
  const url = `${this.apiUrl}${endpoint}`;
12508
+ const closesAtStart = await this.batchPayer?.closeFence();
10570
12509
  for (let staleRetries = 0; ; ) {
10571
- const response = await this.fetchWithTimeout(url, {
12510
+ const response = await this.sendUnpaid(url, {
10572
12511
  method: "POST",
10573
12512
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
10574
12513
  body: JSON.stringify(body)
10575
12514
  });
10576
12515
  if (response.status === 402) {
10577
12516
  try {
10578
- const paymentRequired = await this.readPaymentRequired(response);
12517
+ let paymentRequired;
12518
+ try {
12519
+ paymentRequired = await this.readPaymentRequired(response);
12520
+ } catch (error) {
12521
+ throw withDisposition(error, "unpaid");
12522
+ }
12523
+ let exactRequired = paymentRequired;
10579
12524
  if (this.batchPayer && staleRetries === 0) {
10580
12525
  const batch = await this.batchPayer.pay(
10581
12526
  paymentRequired,
@@ -10583,39 +12528,75 @@ var SolanaLLMClient = class {
10583
12528
  method: "POST",
10584
12529
  headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT, ...paymentHeaders },
10585
12530
  body: JSON.stringify(body)
10586
- })
12531
+ }),
12532
+ // A fresh, unpaid challenge after a 429 wait: the request itself,
12533
+ // sent again without a payment. Every error it throws is unpaid.
12534
+ async () => {
12535
+ const challenge = await this.sendUnpaid(url, {
12536
+ method: "POST",
12537
+ headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
12538
+ body: JSON.stringify(body)
12539
+ });
12540
+ if (challenge.status === 402) {
12541
+ try {
12542
+ return { kind: "challenge", paymentRequired: await this.readPaymentRequired(challenge) };
12543
+ } catch (error) {
12544
+ throw withDisposition(error, "unpaid");
12545
+ }
12546
+ }
12547
+ if (challenge.ok) return { kind: "served", response: challenge };
12548
+ throw await unpaidApiError(challenge);
12549
+ },
12550
+ closesAtStart
10587
12551
  );
10588
12552
  if (batch.kind === "paid") {
10589
12553
  this.recordSettlement(batch.chargedUsd);
10590
- return batch.response.json();
10591
- }
10592
- if (batch.kind === "failed") {
10593
- let errorBody;
10594
- try {
10595
- errorBody = await batch.response.json();
10596
- } catch {
10597
- errorBody = { error: "Request failed" };
10598
- }
10599
- throw new APIError(`API error after payment: ${batch.response.status}`, batch.response.status, sanitizeErrorResponse(errorBody));
12554
+ return await this.readPaidJson(batch.response);
10600
12555
  }
12556
+ if (batch.kind === "served") return await this.readUnpaidJson(batch.response);
12557
+ if (batch.kind === "failed") throw batch.error;
12558
+ exactRequired = batch.paymentRequired ?? paymentRequired;
10601
12559
  }
10602
- return await this.handlePaymentAndRetry(url, body, paymentRequired, staleRetries > 0);
12560
+ return await this.handlePaymentAndRetry(url, body, exactRequired, staleRetries > 0);
10603
12561
  } catch (error) {
10604
12562
  if (!(error instanceof SafeStaleBlockhashError) || staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length) throw error;
10605
12563
  await waitForStaleRetry(staleRetries++);
10606
12564
  continue;
10607
12565
  }
10608
12566
  }
10609
- if (!response.ok) {
10610
- let errorBody;
10611
- try {
10612
- errorBody = await response.json();
10613
- } catch {
10614
- errorBody = { error: "Request failed" };
10615
- }
10616
- throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
10617
- }
10618
- return response.json();
12567
+ if (!response.ok) throw await unpaidApiError(response);
12568
+ return await this.readUnpaidJson(response);
12569
+ }
12570
+ }
12571
+ /**
12572
+ * Read the JSON body of a 2xx served without a payment: the free tier, or
12573
+ * with an API key, where this very request was the billed one.
12574
+ */
12575
+ async readUnpaidJson(response) {
12576
+ try {
12577
+ return await response.json();
12578
+ } catch (error) {
12579
+ throw withDisposition(error, this.apiAuth ? "paid-or-in-doubt" : "unpaid");
12580
+ }
12581
+ }
12582
+ /**
12583
+ * Send a request that carries no payment, marking what it throws with its
12584
+ * {@link RetryDisposition}: `"unpaid"` with a wallet; with an API key, see
12585
+ * {@link accountErrorDisposition} (the request itself is billed).
12586
+ */
12587
+ async sendUnpaid(url, init) {
12588
+ try {
12589
+ return await this.fetchWithTimeout(url, init);
12590
+ } catch (error) {
12591
+ throw withDisposition(error, this.apiAuth ? accountErrorDisposition(error) : "unpaid");
12592
+ }
12593
+ }
12594
+ /** Read a paid 2xx response's JSON body. The call was charged, so a failure here is never retried. */
12595
+ async readPaidJson(response) {
12596
+ try {
12597
+ return await response.json();
12598
+ } catch (error) {
12599
+ throw withDisposition(error, "paid-or-in-doubt");
10619
12600
  }
10620
12601
  }
10621
12602
  /**
@@ -10783,24 +12764,30 @@ var SolanaLLMClient = class {
10783
12764
  return { paymentPayload, costUsd: parseFloat(details.amount) / 1e6 };
10784
12765
  }
10785
12766
  async handlePaymentAndRetry(url, body, paymentRequired, forceFreshBlockhash = false) {
10786
- const { paymentPayload, costUsd } = await this.signExactPayment(
10787
- url,
10788
- paymentRequired,
10789
- forceFreshBlockhash,
10790
- `${this.apiUrl}/v1/chat/completions`
10791
- );
10792
- const retryResponse = await this.fetchWithTimeout(url, {
10793
- method: "POST",
10794
- headers: {
10795
- "Content-Type": "application/json",
10796
- "User-Agent": USER_AGENT,
10797
- "PAYMENT-SIGNATURE": paymentPayload
10798
- },
10799
- body: JSON.stringify(body)
10800
- });
12767
+ let signed;
12768
+ try {
12769
+ signed = await this.signExactPayment(url, paymentRequired, forceFreshBlockhash, `${this.apiUrl}/v1/chat/completions`);
12770
+ } catch (error) {
12771
+ throw withDisposition(error, "unpaid");
12772
+ }
12773
+ const { paymentPayload, costUsd } = signed;
12774
+ let retryResponse;
12775
+ try {
12776
+ retryResponse = await this.fetchWithTimeout(url, {
12777
+ method: "POST",
12778
+ headers: {
12779
+ "Content-Type": "application/json",
12780
+ "User-Agent": USER_AGENT,
12781
+ "PAYMENT-SIGNATURE": paymentPayload
12782
+ },
12783
+ body: JSON.stringify(body)
12784
+ });
12785
+ } catch (error) {
12786
+ throw withDisposition(error, "paid-or-in-doubt");
12787
+ }
10801
12788
  await this.assertPaid(retryResponse);
10802
12789
  this.recordSettlement(costUsd);
10803
- return retryResponse.json();
12790
+ return this.readPaidJson(retryResponse);
10804
12791
  }
10805
12792
  async requestWithPaymentRaw(endpoint, body) {
10806
12793
  const url = `${this.apiUrl}${endpoint}`;
@@ -10907,14 +12894,15 @@ var SolanaLLMClient = class {
10907
12894
  * lives.
10908
12895
  * @param response - the reply to the paid request.
10909
12896
  * @throws SafeStaleBlockhashError when the caller should re-sign, PaymentError
10910
- * when it should not, APIError for any other failure.
12897
+ * when it should not, APIError for any other failure. Every one but the
12898
+ * first is marked `"paid-or-in-doubt"`: the payment may have settled.
10911
12899
  */
10912
12900
  async assertPaid(response) {
10913
12901
  if (response.status === 402) {
10914
12902
  if (await isSafeStaleBlockhashResponse(response)) {
10915
- throw new SafeStaleBlockhashError();
12903
+ throw withDisposition(new SafeStaleBlockhashError(), "unpaid");
10916
12904
  }
10917
- throw new PaymentError("Payment was rejected. Check your Solana USDC balance.");
12905
+ throw withDisposition(new PaymentError("Payment was rejected. Check your Solana USDC balance."), "paid-or-in-doubt");
10918
12906
  }
10919
12907
  if (!response.ok) {
10920
12908
  let errorBody;
@@ -10923,7 +12911,10 @@ var SolanaLLMClient = class {
10923
12911
  } catch {
10924
12912
  errorBody = { error: "Request failed" };
10925
12913
  }
10926
- throw new APIError(`API error after payment: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
12914
+ throw withDisposition(
12915
+ new APIError(`API error after payment: ${response.status}`, response.status, sanitizeErrorResponse(errorBody)),
12916
+ "paid-or-in-doubt"
12917
+ );
10927
12918
  }
10928
12919
  }
10929
12920
  /** Count one settled x402 payment against the session total. */
@@ -11489,8 +13480,12 @@ var AnthropicClient = class {
11489
13480
  ARC_CHAIN_ID,
11490
13481
  AnthropicClient,
11491
13482
  BASE_CHAIN_ID,
13483
+ BLOCKRUN_SOL_OPERATOR,
13484
+ BatchCloseDeferredError,
13485
+ BatchPaymentUnresolvedError,
11492
13486
  BlockrunClient,
11493
13487
  BlockrunError,
13488
+ DEFAULT_BATCH_RATE_LIMIT,
11494
13489
  EVM_NETWORKS,
11495
13490
  ImageClient,
11496
13491
  KNOWN_PROVIDERS,
@@ -11552,6 +13547,7 @@ var AnthropicClient = class {
11552
13547
  loadWallet,
11553
13548
  logCost,
11554
13549
  parsePaymentRequired,
13550
+ retryDisposition,
11555
13551
  saveSolanaWallet,
11556
13552
  saveToCache,
11557
13553
  saveWallet,