@twzrd1111/clawrouter-mpp 0.2.0 → 0.3.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/README.md CHANGED
@@ -3,6 +3,17 @@
3
3
  MPP (Machine Payments Protocol) payment rails for [ClawRouter](https://github.com/BlockRunAI/ClawRouter):
4
4
  pay an MPP seller over **Tempo** or **Solana** instead of x402, under ClawRouter's own spend limits.
5
5
 
6
+ ## Upgrading from 0.2.0
7
+
8
+ - Fulfillment rows now use `twzrd.mpp.fulfillment.v2`. Readers must accept the new schema to retain
9
+ `response_bytes` and `body_sha256`; these fields are null when the recorder does not read a complete
10
+ body within its 1 MiB cap.
11
+ - Credit rows carry an optional `reference` matching the error and spend record. It is a local
12
+ signed-payload hash, not a settlement signature.
13
+ - `payFetch.flush({ timeoutMs })` bounds waiting for both records and ledger persistence. A timeout
14
+ leaves the paid response and background writes intact; an omitted timeout keeps the previous
15
+ unbounded wait. Values above 2,147,483,647 ms are rejected.
16
+
6
17
  ```ts
7
18
  import { startProxy, SpendControl, FileSpendControlStorage } from "@twzrd1111/clawrouter";
8
19
  import { createMppFetch, createSolanaMppFetch } from "@twzrd1111/clawrouter-mpp";
@@ -36,7 +47,13 @@ run asynchronously so they do not hold the response stream. `payFetch.flush?.()`
36
47
  records and fsyncs the local ledger during orderly shutdown. `startProxy` calls the rail's flush hook
37
48
  after closing its listener, and direct rail users must call `payFetch.flush()` themselves. It waits for
38
49
  active SSE responses too, so drain or cancel them before flushing; an open stream can keep the flush
39
- pending. Network contribution remains off unless separately enabled. It blocks unscored
50
+ pending. `payFetch.flush({ timeoutMs })` bounds the entire wait for records and ledger persistence
51
+ (0 through 2,147,483,647 ms). At the deadline it still attempts the ledger flush in the background,
52
+ then rejects with an `AggregateError` holding an `MppFlushTimeoutError`. Its `pending` field counts
53
+ unfinished records; `ledgerPending` indicates whether ledger flushing is still in progress.
54
+ A late ledger failure is reported once: by a flush still waiting on that ledger, otherwise by the next explicit flush. (Ownership is tracked per ledger promise; an integration that returns a fresh wrapper promise for the same failure to each caller can still see it twice.) A timeout does not confirm durability.
55
+ It never cancels or reads a response body, so a late reader still gets the whole paid response,
56
+ and its record lands when that body is drained. Network contribution remains off unless separately enabled. It blocks unscored
40
57
  networks by default, including Tempo mainnet and Moderato, until TWZRD has a reputation corpus for
41
58
  them. The default provider also fails closed on outages; the ledger sink itself makes no network
42
59
  call. An explicit `allowUnscoredNetwork: true` requires `policy.maxAmountUsd` and returns
@@ -45,7 +62,7 @@ fulfillment row omits prompts, responses, credentials, and raw receipt headers;
45
62
  field is explicitly a seller claim. Network contribution remains a separate opt-in.
46
63
 
47
64
  This integration requires `twzrd-x402-gate@0.17.0`, which is not published yet. npm's current
48
- `0.16.1` does not include Tempo charge evaluation or this shared-ledger adapter.
65
+ `0.16.2` does not include Tempo charge evaluation or the shared-ledger adapter.
49
66
 
50
67
  Receipt-safe payment metadata is available through `readPaidCallEvidence(response)` for a paid
51
68
  response from this rail. The
@@ -57,6 +74,12 @@ intent match the challenge; it is not an on-chain verification. `seller_reported
57
74
  compares labels only and does not verify model identity. A failed local write does not change an
58
75
  already-paid response; `payFetch.flush()` surfaces persistence failures to the shutdown caller.
59
76
 
77
+ Rows use schema `twzrd.mpp.fulfillment.v2`. `response_bytes` and `body_sha256` (lowercase hex
78
+ SHA-256) describe the complete response body as the recorder read it: a JSON body read through its
79
+ clone, or an SSE body tapped to its end. Both are `null` when the recorder did not read the body,
80
+ it exceeded 1 MiB, or the stream was cancelled or errored before its end; a partial body is never
81
+ hashed. The digest is of bytes the seller sent and is not a seller attestation.
82
+
60
83
  This candidate requires `@twzrd1111/clawrouter >=0.12.309`, including deferred streaming headers.
61
84
  The fork is the release target. Upstream PRs #545 and #546 are independent of this release.
62
85
 
@@ -121,6 +144,13 @@ synchronous claim gives concurrent duplicates one winner. The claim remains spen
121
144
  model fails. This helper does not verify a payment itself,
122
145
  and installing a buyer rail cannot add replay protection to a remote seller.
123
146
 
147
+ `SpentProofLedger` keeps claims in memory, so a restart or a second process forgets spent
148
+ proofs, and a proof can be redeemed again while its challenge is still valid. A seller with
149
+ more than one process, or one that restarts, needs a durable, atomic uniqueness check shared by
150
+ every worker (for example a unique key on network plus transaction signature, as an
151
+ authoritative settlement table provides). Rejecting expired challenges narrows the window but
152
+ does not close it on its own.
153
+
124
154
  ## Credits for signed calls with no completion
125
155
 
126
156
  ```ts
@@ -155,6 +185,9 @@ and request deadlines cancel the upstream reader and clean up coalesced callers.
155
185
  `CreditLedger` and `creditPaidNoCompletion(ledger, error, fields)` are also public for callers
156
186
  with their own ledger; `readCredits(ledger)` reads that ledger. Retrying the credit operation
157
187
  returns the same immutable row and invokes no signer. It does not retry a model request.
188
+ Each row carries the payment `reference` from the error (or from `fields.reference`) when one
189
+ is known, so a credit can be joined to the error and the spend record. The reference is a hash
190
+ of the signed payload, not a chain signature; an unknown reference is left off the row.
158
191
 
159
192
  Both ledgers are in memory, scoped to one process, and reset on restart. Credits record
160
193
  signed attempts, not confirmed settlement or redeemable balances. There is no refund, chain
package/dist/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { Account } from 'viem';
2
- import { SpendControl, PayFetch, SpendRecord } from '@twzrd1111/clawrouter';
2
+ import { PayFetch, SpendControl, SpendRecord } from '@twzrd1111/clawrouter';
3
3
  export { PayFetch } from '@twzrd1111/clawrouter';
4
4
  import { KeyPairSigner } from '@solana/kit';
5
5
 
6
- declare const MPP_FULFILLMENT_RECORD_SCHEMA = "twzrd.mpp.fulfillment.v1";
6
+ declare const MPP_FULFILLMENT_RECORD_SCHEMA = "twzrd.mpp.fulfillment.v2";
7
7
  declare const MPP_FULFILLMENT_MAX_BODY_BYTES: number;
8
8
  type MppPaymentFacts = {
9
9
  challengeId: string;
@@ -38,6 +38,18 @@ type MppFulfillmentRecord = {
38
38
  /** Equality of seller-reported and requested labels only; this does not verify the served model. */
39
39
  seller_reported_model_matches_request: boolean | null;
40
40
  gate: MppGateDecision | null;
41
+ /**
42
+ * Byte length of the complete response body as received. null when the recorder did not read
43
+ * the body, it exceeded MPP_FULFILLMENT_MAX_BODY_BYTES, or it was cancelled or errored first.
44
+ */
45
+ response_bytes: number | null;
46
+ /** Lowercase hex SHA-256 of the same complete body; null under the same conditions. */
47
+ body_sha256: string | null;
48
+ };
49
+ /** Size and digest of a complete response body, or null fields when it was not fully read. */
50
+ type MppBodyDigest = {
51
+ response_bytes: number | null;
52
+ body_sha256: string | null;
41
53
  };
42
54
  type MppFulfillmentSink = (record: MppFulfillmentRecord) => void | Promise<void>;
43
55
 
@@ -75,6 +87,27 @@ declare class MppGateError extends Error {
75
87
  constructor(code: MppGateError["code"], challengeId: string, message: string, decision?: MppGateDecision);
76
88
  }
77
89
 
90
+ type MppFlushOptions = {
91
+ /**
92
+ * Bound the entire wait for fulfillment records and ledger persistence. At the deadline,
93
+ * ledger flushing is still attempted in the background; neither writes nor response bodies
94
+ * are cancelled. Late ledger failures are retained for the next flush(). Omit to wait forever.
95
+ * Valid range: 0 through 2_147_483_647 milliseconds (Node's maximum timer delay).
96
+ */
97
+ timeoutMs?: number;
98
+ };
99
+ /** A rail fetch whose flush() accepts an optional deadline. Assignable to PayFetch. */
100
+ type MppPayFetch = PayFetch & {
101
+ flush: (options?: MppFlushOptions) => Promise<void>;
102
+ };
103
+ /** The deadline elapsed; persistence may still be running and response bodies remain caller-owned. */
104
+ declare class MppFlushTimeoutError extends Error {
105
+ readonly pending: number;
106
+ readonly timeoutMs: number;
107
+ readonly ledgerPending: boolean;
108
+ constructor(pending: number, timeoutMs: number, ledgerPending?: boolean);
109
+ }
110
+
78
111
  /**
79
112
  * Tempo TIP-20 USD stablecoins priced here, lower-case, all 6 decimals (the challenge carries no
80
113
  * `decimals`). Any other asset cannot be priced, so it is refused while amount limits are set.
@@ -108,7 +141,7 @@ declare function quoteFromChallenge(challenge: ChallengeLike, usdAssets?: Readon
108
141
  amount: string | undefined;
109
142
  priceable: boolean;
110
143
  };
111
- declare function createMppFetch(account: Account, base: typeof fetch | undefined, opts: MppFetchOptions): PayFetch;
144
+ declare function createMppFetch(account: Account, base: typeof fetch | undefined, opts: MppFetchOptions): MppPayFetch;
112
145
 
113
146
  declare const DEFAULT_SOLANA_USD_MINTS: ReadonlySet<string>;
114
147
  type SolanaNetwork = "mainnet" | "devnet" | "localnet";
@@ -123,7 +156,7 @@ type SolanaMppFetchOptions = {
123
156
  /** Injectable transport; defaults to fetch. The SDK's RPC transport is separate. */
124
157
  fetch?: typeof fetch;
125
158
  };
126
- declare function createSolanaMppFetch(opts: SolanaMppFetchOptions): Promise<PayFetch>;
159
+ declare function createSolanaMppFetch(opts: SolanaMppFetchOptions): Promise<MppPayFetch>;
127
160
 
128
161
  /** In-memory credit row. No chain write and no signer. */
129
162
  type CreditEntry = Readonly<{
@@ -131,6 +164,11 @@ type CreditEntry = Readonly<{
131
164
  amount: string;
132
165
  challengeId: string;
133
166
  reason: string;
167
+ /**
168
+ * Payment reference from MppPaidNoCompletionError, when one is known: `mpp-` plus a short hash of
169
+ * the signed payload. Joins the row to the error and the spend record. Not a chain tx signature.
170
+ */
171
+ reference?: string;
134
172
  }>;
135
173
  declare class CreditLedger {
136
174
  private readonly rows;
@@ -149,6 +187,7 @@ declare class MppPaidNoCompletionError extends Error {
149
187
  /**
150
188
  * Record one credit for a paid call that returned no completion.
151
189
  * A retry of the same payer and challenge returns the first row.
190
+ * The row carries `fields.reference`, else the error's reference when one is known.
152
191
  * `sign`, when passed, is never called.
153
192
  */
154
193
  declare function creditPaidNoCompletion(ledger: CreditLedger, err: MppPaidNoCompletionError, fields: CreditEntry, hooks?: {
@@ -206,5 +245,5 @@ declare function fulfillPaidProof<T>(opts: {
206
245
  upstream: () => Promise<T>;
207
246
  }): Promise<T>;
208
247
 
209
- export { CreditLedger, DEFAULT_SOLANA_USD_MINTS, DEFAULT_TEMPO_USD_ASSETS, MPP_FULFILLMENT_MAX_BODY_BYTES, MPP_FULFILLMENT_RECORD_SCHEMA, MppGateError, MppPaidNoCompletionError, ReplayRejectedError, SpentProofLedger, createMppFetch, createSolanaMppFetch, creditPaidNoCompletion, fulfillPaidProof, quoteFromChallenge, readCredits, readPaidCallEvidence };
210
- export type { CreditEntry, MppFetchOptions, MppFulfillmentRecord, MppFulfillmentSink, MppGateChallenge, MppGateDecision, MppGateEvaluator, MppIntegration, MppPaymentFacts, SolanaMppFetchOptions };
248
+ export { CreditLedger, DEFAULT_SOLANA_USD_MINTS, DEFAULT_TEMPO_USD_ASSETS, MPP_FULFILLMENT_MAX_BODY_BYTES, MPP_FULFILLMENT_RECORD_SCHEMA, MppFlushTimeoutError, MppGateError, MppPaidNoCompletionError, ReplayRejectedError, SpentProofLedger, createMppFetch, createSolanaMppFetch, creditPaidNoCompletion, fulfillPaidProof, quoteFromChallenge, readCredits, readPaidCallEvidence };
249
+ export type { CreditEntry, MppBodyDigest, MppFetchOptions, MppFlushOptions, MppFulfillmentRecord, MppFulfillmentSink, MppGateChallenge, MppGateDecision, MppGateEvaluator, MppIntegration, MppPayFetch, MppPaymentFacts, SolanaMppFetchOptions };
package/dist/index.js CHANGED
@@ -41,12 +41,18 @@ var MppPaidNoCompletionError = class extends Error {
41
41
  this.status = status;
42
42
  }
43
43
  };
44
+ var UNKNOWN_REFERENCE = "mpp-unknown";
44
45
  function creditPaidNoCompletion(ledger, err, fields, hooks) {
45
46
  if (!(err instanceof MppPaidNoCompletionError)) {
46
47
  throw new TypeError("credit requires MppPaidNoCompletionError");
47
48
  }
48
49
  void hooks;
49
- return ledger.recordOnce(fields);
50
+ const reference = [fields.reference, err.reference].find(
51
+ (value) => typeof value === "string" && value !== "" && value !== UNKNOWN_REFERENCE
52
+ );
53
+ const { reference: _dropped, ...rest } = fields;
54
+ void _dropped;
55
+ return ledger.recordOnce(reference ? { ...rest, reference } : rest);
50
56
  }
51
57
  var credits = new CreditLedger();
52
58
  function creditFromChallenge(challenge, payer) {
@@ -160,7 +166,7 @@ async function boundedSnippet(response, timeoutMs = SNIPPET_TIMEOUT_MS) {
160
166
  }
161
167
  }
162
168
  function fail(spend, call, status, detail, cause) {
163
- const reference = call.reference ?? "mpp-unknown";
169
+ const reference = call.reference ?? UNKNOWN_REFERENCE;
164
170
  const why = status !== void 0 ? `HTTP ${status}` : "request failed";
165
171
  if (spend && call.record) {
166
172
  spend.annotateRecord(call.record, `signed, no completion (${why}) ref ${reference}`);
@@ -273,8 +279,10 @@ async function evaluateMppGate(evaluator, challenge) {
273
279
  }
274
280
 
275
281
  // src/mpp-fulfillment-record.ts
276
- var MPP_FULFILLMENT_RECORD_SCHEMA = "twzrd.mpp.fulfillment.v1";
282
+ import { createHash as createHash2 } from "crypto";
283
+ var MPP_FULFILLMENT_RECORD_SCHEMA = "twzrd.mpp.fulfillment.v2";
277
284
  var MPP_FULFILLMENT_MAX_BODY_BYTES = 1024 * 1024;
285
+ var NO_BODY_DIGEST = { response_bytes: null, body_sha256: null };
278
286
  function asRecord(value) {
279
287
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
280
288
  }
@@ -324,6 +332,7 @@ function createSseReceiptParser(challengeId, expectedIntent, expectedMethod) {
324
332
  let unavailable = false;
325
333
  let receipt;
326
334
  let sellerReportedModel = null;
335
+ const hash = createHash2("sha256");
327
336
  const finishEvent = () => {
328
337
  if (data.length && eventName === "payment-receipt" && !receipt) {
329
338
  try {
@@ -359,6 +368,7 @@ function createSseReceiptParser(challengeId, expectedIntent, expectedMethod) {
359
368
  return {
360
369
  push(chunk) {
361
370
  totalBytes += chunk.byteLength;
371
+ if (totalBytes <= MPP_FULFILLMENT_MAX_BODY_BYTES) hash.update(chunk);
362
372
  if (totalBytes > MPP_FULFILLMENT_MAX_BODY_BYTES) {
363
373
  unavailable = true;
364
374
  buffer = "";
@@ -378,6 +388,10 @@ function createSseReceiptParser(challengeId, expectedIntent, expectedMethod) {
378
388
  buffer = "";
379
389
  }
380
390
  },
391
+ /** Digest of every pushed byte; call only after the source stream reported done. */
392
+ digest() {
393
+ return totalBytes > MPP_FULFILLMENT_MAX_BODY_BYTES ? NO_BODY_DIGEST : { response_bytes: totalBytes, body_sha256: hash.digest("hex") };
394
+ },
381
395
  finish() {
382
396
  if (!unavailable && !receipt) {
383
397
  buffer += decoder.decode();
@@ -391,8 +405,8 @@ function createSseReceiptParser(challengeId, expectedIntent, expectedMethod) {
391
405
  }
392
406
  };
393
407
  }
394
- async function readJsonCapped(body) {
395
- if (!body) return void 0;
408
+ async function readBodyCapped(body) {
409
+ if (!body) return null;
396
410
  const reader = body.getReader();
397
411
  const chunks = [];
398
412
  let size = 0;
@@ -403,15 +417,32 @@ async function readJsonCapped(body) {
403
417
  size += value.byteLength;
404
418
  if (size > MPP_FULFILLMENT_MAX_BODY_BYTES) {
405
419
  await reader.cancel().catch(() => void 0);
406
- return void 0;
420
+ return null;
407
421
  }
408
422
  chunks.push(value);
409
423
  }
410
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
424
+ return Buffer.concat(chunks);
425
+ } catch {
426
+ return null;
427
+ }
428
+ }
429
+ function parseJson(bytes) {
430
+ if (!bytes) return void 0;
431
+ try {
432
+ return JSON.parse(bytes.toString("utf8"));
411
433
  } catch {
412
434
  return void 0;
413
435
  }
414
436
  }
437
+ function digestOf(bytes) {
438
+ return bytes ? {
439
+ response_bytes: bytes.byteLength,
440
+ body_sha256: createHash2("sha256").update(bytes).digest("hex")
441
+ } : NO_BODY_DIGEST;
442
+ }
443
+ async function readJsonCapped(body) {
444
+ return parseJson(await readBodyCapped(body));
445
+ }
415
446
  function bodyIsJson(headers) {
416
447
  return (headers.get("content-type") ?? "").toLowerCase().includes("json");
417
448
  }
@@ -449,7 +480,7 @@ function wrappedResponse(response, body) {
449
480
  });
450
481
  return wrapped;
451
482
  }
452
- async function emitRecord(input, receipt, requestValue, responseValue, sink, sellerModelOverride) {
483
+ async function emitRecord(input, receipt, requestValue, responseValue, sink, digest, sellerModelOverride) {
453
484
  const req = asRecord(requestValue);
454
485
  const res = asRecord(responseValue);
455
486
  const requestedModel = string(req?.model);
@@ -474,7 +505,9 @@ async function emitRecord(input, receipt, requestValue, responseValue, sink, sel
474
505
  requested_model: requestedModel,
475
506
  seller_reported_model: sellerReportedModel,
476
507
  seller_reported_model_matches_request: requestedModel && sellerReportedModel ? requestedModel.toLowerCase() === sellerReportedModel.toLowerCase() : null,
477
- gate: input.gate ?? null
508
+ gate: input.gate ?? null,
509
+ response_bytes: digest.response_bytes,
510
+ body_sha256: digest.body_sha256
478
511
  };
479
512
  await sink(row);
480
513
  }
@@ -507,11 +540,11 @@ function recordMppFulfillment(input, sink, onPending) {
507
540
  });
508
541
  track(recording2);
509
542
  let recordingStarted = false;
510
- const finishRecord = (receipt2, sellerReportedModel) => {
543
+ const finishRecord = (receipt2, sellerReportedModel, digest) => {
511
544
  if (recordingStarted) return;
512
545
  recordingStarted = true;
513
546
  const write = requestBody.then(
514
- (request) => emitRecord(input, receipt2, request, void 0, sink, sellerReportedModel)
547
+ (request) => emitRecord(input, receipt2, request, void 0, sink, digest, sellerReportedModel)
515
548
  );
516
549
  void write.then(resolveRecording, rejectRecording);
517
550
  void write.catch(() => {
@@ -526,7 +559,7 @@ function recordMppFulfillment(input, sink, onPending) {
526
559
  const { done, value } = await reader.read();
527
560
  if (done) {
528
561
  const parsed = parser.finish();
529
- finishRecord(parsed.receipt, parsed.sellerReportedModel);
562
+ finishRecord(parsed.receipt, parsed.sellerReportedModel, parser.digest());
530
563
  controller.close();
531
564
  reader.releaseLock();
532
565
  return;
@@ -534,7 +567,7 @@ function recordMppFulfillment(input, sink, onPending) {
534
567
  parser.push(value);
535
568
  controller.enqueue(value);
536
569
  } catch (error) {
537
- finishRecord({ state: "unavailable", value: null }, null);
570
+ finishRecord({ state: "unavailable", value: null }, null, NO_BODY_DIGEST);
538
571
  controller.error(error);
539
572
  }
540
573
  },
@@ -542,14 +575,14 @@ function recordMppFulfillment(input, sink, onPending) {
542
575
  await reader.cancel(reason).catch(() => void 0);
543
576
  const parsed = parser.finish();
544
577
  const receipt2 = parsed.receipt.state === "missing" ? { state: "unavailable", value: null } : parsed.receipt;
545
- finishRecord(receipt2, parsed.sellerReportedModel);
578
+ finishRecord(receipt2, parsed.sellerReportedModel, NO_BODY_DIGEST);
546
579
  }
547
580
  });
548
581
  return wrappedResponse(input.response, tapped);
549
582
  }
550
- const responseBody = bodyIsJson(input.response.headers) ? readJsonCapped(input.response.clone().body) : Promise.resolve(void 0);
583
+ const responseBody = bodyIsJson(input.response.headers) ? readBodyCapped(input.response.clone().body) : Promise.resolve(null);
551
584
  const recording = Promise.all([requestBody, responseBody]).then(
552
- ([request, response]) => emitRecord(input, receipt, request, response, sink)
585
+ ([request, bytes]) => emitRecord(input, receipt, request, parseJson(bytes), sink, digestOf(bytes))
553
586
  );
554
587
  track(recording);
555
588
  void recording.catch(() => {
@@ -583,6 +616,102 @@ function runWithMppPaymentBudget(context, run) {
583
616
  return contextStorage.run(context, run);
584
617
  }
585
618
 
619
+ // src/mpp-flush.ts
620
+ var MppFlushTimeoutError = class extends Error {
621
+ pending;
622
+ timeoutMs;
623
+ ledgerPending;
624
+ constructor(pending, timeoutMs, ledgerPending = false) {
625
+ super(
626
+ `MPP flush deadline reached after ${timeoutMs} ms: ${pending} fulfillment record(s) pending; ledger flush ${ledgerPending ? "still pending" : "finished"}`
627
+ );
628
+ this.name = "MppFlushTimeoutError";
629
+ this.pending = pending;
630
+ this.timeoutMs = timeoutMs;
631
+ this.ledgerPending = ledgerPending;
632
+ }
633
+ };
634
+ var MAX_TIMER_MS = 2147483647;
635
+ function deadlineOf(options) {
636
+ const value = options?.timeoutMs;
637
+ if (value === void 0) return void 0;
638
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > MAX_TIMER_MS) {
639
+ throw new TypeError(`flush timeoutMs must be a number from 0 to ${MAX_TIMER_MS}`);
640
+ }
641
+ return value;
642
+ }
643
+ var ownership = /* @__PURE__ */ new WeakMap();
644
+ function ownershipFor(failures) {
645
+ let entry = ownership.get(failures);
646
+ if (!entry) {
647
+ entry = { waiters: /* @__PURE__ */ new Map(), claimed: /* @__PURE__ */ new WeakSet() };
648
+ ownership.set(failures, entry);
649
+ }
650
+ return entry;
651
+ }
652
+ var isObject = (value) => typeof value === "object" && value !== null || typeof value === "function";
653
+ async function flushFulfillments(pending, failures, integrationFlush, options) {
654
+ const timeoutMs = deadlineOf(options);
655
+ let timer;
656
+ let expired = false;
657
+ const deadline = timeoutMs === void 0 ? void 0 : new Promise((resolve) => {
658
+ timer = setTimeout(() => {
659
+ expired = true;
660
+ resolve();
661
+ }, timeoutMs);
662
+ });
663
+ const wait = (task) => deadline ? Promise.race([task, deadline]) : task;
664
+ try {
665
+ await wait(Promise.all([...pending]).then(() => void 0));
666
+ const owners = ownershipFor(failures);
667
+ const ownErrors = [];
668
+ let ledgerPending = true;
669
+ let waiting = true;
670
+ let source;
671
+ try {
672
+ source = Promise.resolve(integrationFlush());
673
+ } catch (error) {
674
+ ownErrors.push(error);
675
+ ledgerPending = false;
676
+ }
677
+ const release = () => {
678
+ if (!waiting) return;
679
+ waiting = false;
680
+ if (!source) return;
681
+ const left = (owners.waiters.get(source) ?? 1) - 1;
682
+ if (left > 0) owners.waiters.set(source, left);
683
+ else owners.waiters.delete(source);
684
+ };
685
+ let ledger = Promise.resolve();
686
+ if (source) {
687
+ const observed = source;
688
+ owners.waiters.set(observed, (owners.waiters.get(observed) ?? 0) + 1);
689
+ ledger = observed.catch((error) => {
690
+ if (waiting) {
691
+ ownErrors.push(error);
692
+ if (isObject(error)) owners.claimed.add(error);
693
+ const queued = failures.indexOf(error);
694
+ if (queued >= 0) failures.splice(queued, 1);
695
+ } else if (!owners.waiters.get(observed) && !(isObject(error) && owners.claimed.has(error)) && !failures.includes(error)) {
696
+ failures.push(error);
697
+ }
698
+ }).finally(() => {
699
+ ledgerPending = false;
700
+ });
701
+ }
702
+ try {
703
+ await wait(ledger);
704
+ } finally {
705
+ release();
706
+ }
707
+ const errors = [.../* @__PURE__ */ new Set([...failures.splice(0), ...ownErrors])];
708
+ if (expired) errors.push(new MppFlushTimeoutError(pending.size, timeoutMs, ledgerPending));
709
+ if (errors.length) throw new AggregateError(errors, "MPP fulfillment records did not flush");
710
+ } finally {
711
+ clearTimeout(timer);
712
+ }
713
+ }
714
+
586
715
  // src/mpp-fetch.ts
587
716
  var mppxClientDeps = {
588
717
  load: () => loadOptional("mppx", MPPX_RANGE, () => import("mppx/client"))
@@ -747,16 +876,7 @@ function createMppFetch(account, base = fetch, opts) {
747
876
  payFetch.supportsNativeStreaming = true;
748
877
  payFetch.onNoCompletion = rejectPaidNoCompletion;
749
878
  payFetch.isPaidResponse = isPaidResponse;
750
- payFetch.flush = async () => {
751
- await Promise.all([...pendingFulfillments]);
752
- const errors = fulfillmentFailures.splice(0);
753
- try {
754
- await integration.flush();
755
- } catch (error) {
756
- errors.push(error);
757
- }
758
- if (errors.length) throw new AggregateError(errors, "MPP fulfillment records did not flush");
759
- };
879
+ payFetch.flush = (options) => flushFulfillments(pendingFulfillments, fulfillmentFailures, () => integration.flush(), options);
760
880
  return payFetch;
761
881
  }
762
882
 
@@ -968,16 +1088,7 @@ async function createSolanaMppFetch(opts) {
968
1088
  payFetch.supportsNativeStreaming = true;
969
1089
  payFetch.onNoCompletion = rejectPaidNoCompletion;
970
1090
  payFetch.isPaidResponse = isPaidResponse;
971
- payFetch.flush = async () => {
972
- await Promise.all([...pendingFulfillments]);
973
- const errors = fulfillmentFailures.splice(0);
974
- try {
975
- await integration.flush();
976
- } catch (error) {
977
- errors.push(error);
978
- }
979
- if (errors.length) throw new AggregateError(errors, "MPP fulfillment records did not flush");
980
- };
1091
+ payFetch.flush = (options) => flushFulfillments(pendingFulfillments, fulfillmentFailures, () => integration.flush(), options);
981
1092
  return payFetch;
982
1093
  }
983
1094
  function solanaNetworkId(value) {
@@ -1041,6 +1152,7 @@ export {
1041
1152
  DEFAULT_TEMPO_USD_ASSETS,
1042
1153
  MPP_FULFILLMENT_MAX_BODY_BYTES,
1043
1154
  MPP_FULFILLMENT_RECORD_SCHEMA,
1155
+ MppFlushTimeoutError,
1044
1156
  MppGateError,
1045
1157
  MppPaidNoCompletionError,
1046
1158
  ReplayRejectedError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twzrd1111/clawrouter-mpp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MPP (Machine Payments Protocol) payment rails for ClawRouter: Tempo and Solana charge, with spend limits and paid-failure handling",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",