@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 +35 -2
- package/dist/index.d.ts +45 -6
- package/dist/index.js +148 -36
- package/package.json +1 -1
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.
|
|
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.
|
|
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 {
|
|
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.
|
|
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):
|
|
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<
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
395
|
-
if (!body) return
|
|
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
|
|
420
|
+
return null;
|
|
407
421
|
}
|
|
408
422
|
chunks.push(value);
|
|
409
423
|
}
|
|
410
|
-
return
|
|
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) ?
|
|
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,
|
|
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 =
|
|
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 =
|
|
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.
|
|
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",
|