@twzrd1111/clawrouter-mpp 0.2.0 → 0.4.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,34 @@
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 to 0.4.0 from 0.3.0
7
+
8
+ This release includes the standalone Solana receipt verifier (#16), fulfillment wiring (#18),
9
+ and the per-RPC deadline (#19). Verification is opt-in and does not verify Tempo receipts.
10
+ Purchase binding remains `null/no_buyer_nonce`; Echo and a durable reference index are not supplied
11
+ by this release. The reference index is caller-supplied and its operations have no built-in timeout.
12
+
13
+ - Fulfillment rows now use `twzrd.mpp.fulfillment.v3`. v3 adds eight `receipt_reference_*`
14
+ verification fields (below); every other field is unchanged. Readers must accept the new schema.
15
+ The fields are null unless the Solana rail was given `receiptVerification`, so Tempo rows and
16
+ unconfigured Solana rows carry them as null ("not evaluated", never "clean").
17
+ - `createSolanaMppFetch({ receiptVerification: { verify } })` retains the transaction the buyer
18
+ signed and chain-verifies the seller's receipt reference before writing the row.
19
+ - Each `getTransaction` call made by the verifier has a deadline: `rpcTimeoutMs`, default 10000.
20
+ When it passes, the row records `null/rpc_unavailable` and the `signal` passed to the fetcher
21
+ aborts, so a hung RPC cannot hold the row. Pass that `signal` to `fetch()`.
22
+
23
+ ## Upgrading from 0.2.0
24
+
25
+ - Fulfillment rows now use `twzrd.mpp.fulfillment.v2`. Readers must accept the new schema to retain
26
+ `response_bytes` and `body_sha256`; these fields are null when the recorder does not read a complete
27
+ body within its 1 MiB cap.
28
+ - Credit rows carry an optional `reference` matching the error and spend record. It is a local
29
+ signed-payload hash, not a settlement signature.
30
+ - `payFetch.flush({ timeoutMs })` bounds waiting for both records and ledger persistence. A timeout
31
+ leaves the paid response and background writes intact; an omitted timeout keeps the previous
32
+ unbounded wait. Values above 2,147,483,647 ms are rejected.
33
+
6
34
  ```ts
7
35
  import { startProxy, SpendControl, FileSpendControlStorage } from "@twzrd1111/clawrouter";
8
36
  import { createMppFetch, createSolanaMppFetch } from "@twzrd1111/clawrouter-mpp";
@@ -36,7 +64,13 @@ run asynchronously so they do not hold the response stream. `payFetch.flush?.()`
36
64
  records and fsyncs the local ledger during orderly shutdown. `startProxy` calls the rail's flush hook
37
65
  after closing its listener, and direct rail users must call `payFetch.flush()` themselves. It waits for
38
66
  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
67
+ pending. `payFetch.flush({ timeoutMs })` bounds the entire wait for records and ledger persistence
68
+ (0 through 2,147,483,647 ms). At the deadline it still attempts the ledger flush in the background,
69
+ then rejects with an `AggregateError` holding an `MppFlushTimeoutError`. Its `pending` field counts
70
+ unfinished records; `ledgerPending` indicates whether ledger flushing is still in progress.
71
+ 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.
72
+ It never cancels or reads a response body, so a late reader still gets the whole paid response,
73
+ and its record lands when that body is drained. Network contribution remains off unless separately enabled. It blocks unscored
40
74
  networks by default, including Tempo mainnet and Moderato, until TWZRD has a reputation corpus for
41
75
  them. The default provider also fails closed on outages; the ledger sink itself makes no network
42
76
  call. An explicit `allowUnscoredNetwork: true` requires `policy.maxAmountUsd` and returns
@@ -45,7 +79,7 @@ fulfillment row omits prompts, responses, credentials, and raw receipt headers;
45
79
  field is explicitly a seller claim. Network contribution remains a separate opt-in.
46
80
 
47
81
  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.
82
+ `0.16.2` does not include Tempo charge evaluation or the shared-ledger adapter.
49
83
 
50
84
  Receipt-safe payment metadata is available through `readPaidCallEvidence(response)` for a paid
51
85
  response from this rail. The
@@ -57,6 +91,58 @@ intent match the challenge; it is not an on-chain verification. `seller_reported
57
91
  compares labels only and does not verify model identity. A failed local write does not change an
58
92
  already-paid response; `payFetch.flush()` surfaces persistence failures to the shutdown caller.
59
93
 
94
+ Rows use schema `twzrd.mpp.fulfillment.v3`. `response_bytes` and `body_sha256` (lowercase hex
95
+ SHA-256) describe the complete response body as the recorder read it: a JSON body read through its
96
+ clone, or an SSE body tapped to its end. Both are `null` when the recorder did not read the body,
97
+ it exceeded 1 MiB, or the stream was cancelled or errored before its end; a partial body is never
98
+ hashed. The digest is of bytes the seller sent and is not a seller attestation.
99
+
100
+ ### Receipt reference chain verification (Solana, opt-in)
101
+
102
+ ```ts
103
+ import { createReceiptChainVerifier, createSolanaMppFetch } from "@twzrd1111/clawrouter-mpp";
104
+
105
+ const verify = createReceiptChainVerifier({
106
+ cluster: "mainnet", // must match the rail's network; a mismatch throws at construction
107
+ getTransaction, // your finalized getTransaction (base64, maxSupportedTransactionVersion 0)
108
+ index, // a durable ReceiptReferenceIndex with an atomic bind
109
+ rpcTimeoutMs: 10_000, // per getTransaction call (the default); pass the given signal to fetch()
110
+ });
111
+ const payFetch = await createSolanaMppFetch({
112
+ signer,
113
+ rpcUrl,
114
+ network: "mainnet",
115
+ spend,
116
+ integration,
117
+ receiptVerification: { verify, finalityWaitMs: 30_000 },
118
+ });
119
+ ```
120
+
121
+ The rail keeps the base64 wire transaction from the SDK's `signed` event, the buyer clock when it
122
+ fired, and a buyer-generated purchase id (every send of one credential is one purchase). After a
123
+ paid response, the recorder passes the seller's `valid_format` receipt reference with those facts to
124
+ the verifier and writes its result into the row:
125
+
126
+ - `receipt_reference_chain_verified` (`true`, `false`, or `null`), `receipt_reference_finalized_slot`,
127
+ `receipt_reference_mismatch` (why `false`), and `receipt_reference_unverified_reason` (why `null`;
128
+ `verifier_error` when the verifier threw).
129
+ - `receipt_reference_purchase_bound` and its reason. It stays `null/no_buyer_nonce` until a seller
130
+ echoes a buyer nonce; `chain_verified: true` does not mean the payment belongs to this purchase alone.
131
+ - `receipt_reference_local_acceptance` (`accepted`, `refused`, or `null`) and
132
+ `receipt_reference_refusal_reason` (`reference_reused`, `stale_by_local_clock`): this sink's
133
+ policy, which never changes a chain field.
134
+
135
+ Verification never delays or changes the response. It delays only the row and `flush()`.
136
+ `finalityWaitMs` (default 0) re-checks a reference that is not finalized yet every `finalityPollMs`
137
+ (default 2000); with the default, an unfinalized reference is recorded as `null/not_finalized`.
138
+ `finalityWaitMs` is not an RPC timeout: each `getTransaction` call has its own deadline,
139
+ `rpcTimeoutMs` on the verifier (default 10000). When it passes, the fetcher's `signal` aborts and
140
+ the reference is recorded as `null/rpc_unavailable`. A row therefore waits at most about
141
+ `finalityWaitMs + rpcTimeoutMs` on the chain. The reference index has no deadline, so a durable
142
+ index must bound its own calls; `flush({ timeoutMs })` bounds only how long the caller waits.
143
+ No RPC URL ships with the package. `InMemoryReceiptReferenceIndex` forgets on restart and is for
144
+ tests. See `docs/receipt-chain-verify.md` for each check.
145
+
60
146
  This candidate requires `@twzrd1111/clawrouter >=0.12.309`, including deferred streaming headers.
61
147
  The fork is the release target. Upstream PRs #545 and #546 are independent of this release.
62
148
 
@@ -121,6 +207,13 @@ synchronous claim gives concurrent duplicates one winner. The claim remains spen
121
207
  model fails. This helper does not verify a payment itself,
122
208
  and installing a buyer rail cannot add replay protection to a remote seller.
123
209
 
210
+ `SpentProofLedger` keeps claims in memory, so a restart or a second process forgets spent
211
+ proofs, and a proof can be redeemed again while its challenge is still valid. A seller with
212
+ more than one process, or one that restarts, needs a durable, atomic uniqueness check shared by
213
+ every worker (for example a unique key on network plus transaction signature, as an
214
+ authoritative settlement table provides). Rejecting expired challenges narrows the window but
215
+ does not close it on its own.
216
+
124
217
  ## Credits for signed calls with no completion
125
218
 
126
219
  ```ts
@@ -155,6 +248,9 @@ and request deadlines cancel the upstream reader and clean up coalesced callers.
155
248
  `CreditLedger` and `creditPaidNoCompletion(ledger, error, fields)` are also public for callers
156
249
  with their own ledger; `readCredits(ledger)` reads that ledger. Retrying the credit operation
157
250
  returns the same immutable row and invokes no signer. It does not retry a model request.
251
+ Each row carries the payment `reference` from the error (or from `fields.reference`) when one
252
+ is known, so a credit can be joined to the error and the spend record. The reference is a hash
253
+ of the signed payload, not a chain signature; an unknown reference is left off the row.
158
254
 
159
255
  Both ledgers are in memory, scoped to one process, and reset on restart. Credits record
160
256
  signed attempts, not confirmed settlement or redeemable balances. There is no refund, chain
package/dist/index.d.ts CHANGED
@@ -1,9 +1,126 @@
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
+ /** A blockhash stays valid for about 150 slots, roughly 60-90 s. */
7
+ declare const LANDING_WINDOW_SECONDS = 90;
8
+ declare const CLUSTER_IDS: {
9
+ readonly mainnet: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
10
+ readonly devnet: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1";
11
+ };
12
+ type SolanaCluster = keyof typeof CLUSTER_IDS;
13
+ type TrustedMint = {
14
+ decimals: number;
15
+ tokenPrograms: readonly string[];
16
+ };
17
+ /** Classic SPL Token USD mints with 6 decimals: USDC mainnet, USDC devnet, USDT mainnet. */
18
+ declare const DEFAULT_TRUSTED_USD_MINTS: Readonly<Record<string, TrustedMint>>;
19
+ type ChainMismatch = "bad_reference_format" | "wrong_network" | "signature_mismatch" | "message_mismatch" | "buyer_signature_missing" | "untrusted_token_program" | "untrusted_mint" | "decimals_mismatch" | "tx_failed" | "no_matching_transfer" | "multiple_matching_transfers" | "amount_mismatch" | "mint_mismatch" | "payee_mismatch" | "payer_mismatch";
20
+ type ChainUnverifiedReason = "rpc_unavailable" | "not_finalized" | "no_fetcher" | "non_solana" | "no_signed_transaction" | "splits_unsupported" | "unnormalized_terms" | "unsupported_message_format"
21
+ /** The verifier itself threw; recorded by the fulfillment hook, never returned by `verify`. */
22
+ | "verifier_error";
23
+ type ReceiptChainVerification = {
24
+ receipt_reference_chain_verified: boolean | null;
25
+ receipt_reference_finalized_slot: number | null;
26
+ receipt_reference_mismatch: ChainMismatch | null;
27
+ receipt_reference_unverified_reason: ChainUnverifiedReason | null;
28
+ receipt_reference_purchase_bound: boolean | null;
29
+ receipt_reference_purchase_bound_reason: "nonce_mismatch" | "no_buyer_nonce" | "chain_unverified" | null;
30
+ receipt_reference_local_acceptance: "accepted" | "refused" | null;
31
+ receipt_reference_refusal_reason: "reference_reused" | "stale_by_local_clock" | null;
32
+ };
33
+ /** Compiled inner instruction as returned by getTransaction with `encoding: "base64"`. */
34
+ type RpcInnerInstruction = {
35
+ programIdIndex: number;
36
+ accounts: number[];
37
+ data: string;
38
+ };
39
+ /** The subset of a `getTransaction` (`encoding: "base64"`, finalized) result this verifier reads. */
40
+ type RpcTransaction = {
41
+ slot: number;
42
+ blockTime: number | null;
43
+ meta: {
44
+ err: unknown;
45
+ innerInstructions?: {
46
+ index: number;
47
+ instructions: RpcInnerInstruction[];
48
+ }[] | null;
49
+ } | null;
50
+ transaction: [string, "base64"] | readonly [string, string];
51
+ };
52
+ /**
53
+ * Must call getTransaction(signature, {commitment: "finalized", encoding: "base64",
54
+ * maxSupportedTransactionVersion: 0}) on the configured cluster and return its result (null when
55
+ * not found or not finalized). Throw on transport failure. `signal` aborts when the verifier's
56
+ * `rpcTimeoutMs` deadline passes; pass it to fetch() so the request is cancelled, not abandoned.
57
+ */
58
+ type GetFinalizedTransaction = (signature: string, options?: {
59
+ signal?: AbortSignal;
60
+ }) => Promise<RpcTransaction | null>;
61
+ /**
62
+ * Durable, atomic reference -> purchase_id index. `bind` returns true when this purchase owns the
63
+ * reference (newly bound or already bound to the same purchase) and false when another purchase
64
+ * holds it; exactly one concurrent binder may win. `owner` reads without binding.
65
+ */
66
+ type ReceiptReferenceIndex = {
67
+ bind(reference: string, purchaseId: string): Promise<boolean>;
68
+ owner(reference: string): Promise<string | undefined>;
69
+ };
70
+ /** In-memory index for tests and single-process demos. It forgets on restart, so it is not durable. */
71
+ declare class InMemoryReceiptReferenceIndex implements ReceiptReferenceIndex {
72
+ #private;
73
+ bind(reference: string, purchaseId: string): Promise<boolean>;
74
+ owner(reference: string): Promise<string | undefined>;
75
+ }
76
+ type ReceiptChainVerifierConfig = {
77
+ cluster: SolanaCluster;
78
+ getTransaction?: GetFinalizedTransaction;
79
+ index: ReceiptReferenceIndex;
80
+ /** Trusted mint -> decimals and token programs. Defaults to DEFAULT_TRUSTED_USD_MINTS. */
81
+ trustedMints?: Readonly<Record<string, TrustedMint>>;
82
+ /** Default 600. Must exceed maxClockSkewSeconds + LANDING_WINDOW_SECONDS. */
83
+ maxAgeSeconds?: number;
84
+ /** Default 60. */
85
+ maxClockSkewSeconds?: number;
86
+ /**
87
+ * Deadline for one getTransaction call, in ms. Default 10000. When it passes, the fetcher's
88
+ * signal aborts and the reference is recorded as null/rpc_unavailable. The reference index is
89
+ * not covered: a durable index must bound its own calls.
90
+ */
91
+ rpcTimeoutMs?: number;
92
+ };
93
+ /** Default deadline for one getTransaction call. */
94
+ declare const DEFAULT_RPC_TIMEOUT_MS = 10000;
95
+ type ReceiptChainVerifyInput = {
96
+ /** Seller's receipt reference: the settled transaction signature. */
97
+ reference: string;
98
+ /** MPP payment method; only "solana" is verified. */
99
+ method: string;
100
+ /** Challenge terms as the seller sent them. */
101
+ network: string | null;
102
+ asset: string | null;
103
+ payTo: string | null;
104
+ amountAtomic: string | null;
105
+ /** True when the challenge carried split recipients. */
106
+ hasSplits?: boolean;
107
+ /** The buyer's own address. */
108
+ payer: string;
109
+ /** Base64 wire transaction from the rail's `signed` event, or null when not retained. */
110
+ signedTransaction: string | null;
111
+ /** Buyer clock (unix seconds) when the signed event fired. */
112
+ signedAtUnix: number | null;
113
+ /** Buyer-generated id of the logical purchase (not the seller's challenge id). */
114
+ purchaseId: string;
115
+ };
116
+ /** A built verifier. `cluster` lets a rail refuse a verifier configured for another cluster. */
117
+ type ReceiptChainVerify = ((input: ReceiptChainVerifyInput) => Promise<ReceiptChainVerification>) & {
118
+ readonly cluster: SolanaCluster;
119
+ };
120
+ /** Build a verifier. Throws when the age policy cannot hold an honest payment. */
121
+ declare function createReceiptChainVerifier(config: ReceiptChainVerifierConfig): ReceiptChainVerify;
122
+
123
+ declare const MPP_FULFILLMENT_RECORD_SCHEMA = "twzrd.mpp.fulfillment.v3";
7
124
  declare const MPP_FULFILLMENT_MAX_BODY_BYTES: number;
8
125
  type MppPaymentFacts = {
9
126
  challengeId: string;
@@ -14,7 +131,14 @@ type MppPaymentFacts = {
14
131
  asset: string | null;
15
132
  amountAtomic: string | null;
16
133
  };
17
- type MppFulfillmentRecord = {
134
+ /**
135
+ * Chain verification of the seller's receipt reference. Every field is null when no verifier ran
136
+ * (none configured, a non-Solana rail, or no valid-format receipt): null means "not evaluated",
137
+ * never "clean". See docs/receipt-chain-verify.md for each field.
138
+ */
139
+ type MppReceiptReferenceVerification = ReceiptChainVerification;
140
+ declare const RECEIPT_REFERENCE_NOT_EVALUATED: Readonly<MppReceiptReferenceVerification>;
141
+ type MppFulfillmentRecord = MppReceiptReferenceVerification & {
18
142
  schema: typeof MPP_FULFILLMENT_RECORD_SCHEMA;
19
143
  at_unix_ms: number;
20
144
  resource_origin: string | null;
@@ -29,7 +153,10 @@ type MppFulfillmentRecord = {
29
153
  receipt_state: "valid_format" | "missing" | "invalid_format" | "unavailable";
30
154
  receipt_status: string | null;
31
155
  receipt_method: string | null;
32
- /** Seller-supplied MPP receipt reference (EVM hash or Solana signature); not chain-verified here. */
156
+ /**
157
+ * Seller-supplied MPP receipt reference (EVM hash or Solana signature). Chain-verified only when
158
+ * the rail was given a verifier; see the receipt_reference_* verification fields.
159
+ */
33
160
  receipt_reference: string | null;
34
161
  receipt_timestamp: string | null;
35
162
  requested_model: string | null;
@@ -38,6 +165,18 @@ type MppFulfillmentRecord = {
38
165
  /** Equality of seller-reported and requested labels only; this does not verify the served model. */
39
166
  seller_reported_model_matches_request: boolean | null;
40
167
  gate: MppGateDecision | null;
168
+ /**
169
+ * Byte length of the complete response body as received. null when the recorder did not read
170
+ * the body, it exceeded MPP_FULFILLMENT_MAX_BODY_BYTES, or it was cancelled or errored first.
171
+ */
172
+ response_bytes: number | null;
173
+ /** Lowercase hex SHA-256 of the same complete body; null under the same conditions. */
174
+ body_sha256: string | null;
175
+ };
176
+ /** Size and digest of a complete response body, or null fields when it was not fully read. */
177
+ type MppBodyDigest = {
178
+ response_bytes: number | null;
179
+ body_sha256: string | null;
41
180
  };
42
181
  type MppFulfillmentSink = (record: MppFulfillmentRecord) => void | Promise<void>;
43
182
 
@@ -75,6 +214,27 @@ declare class MppGateError extends Error {
75
214
  constructor(code: MppGateError["code"], challengeId: string, message: string, decision?: MppGateDecision);
76
215
  }
77
216
 
217
+ type MppFlushOptions = {
218
+ /**
219
+ * Bound the entire wait for fulfillment records and ledger persistence. At the deadline,
220
+ * ledger flushing is still attempted in the background; neither writes nor response bodies
221
+ * are cancelled. Late ledger failures are retained for the next flush(). Omit to wait forever.
222
+ * Valid range: 0 through 2_147_483_647 milliseconds (Node's maximum timer delay).
223
+ */
224
+ timeoutMs?: number;
225
+ };
226
+ /** A rail fetch whose flush() accepts an optional deadline. Assignable to PayFetch. */
227
+ type MppPayFetch = PayFetch & {
228
+ flush: (options?: MppFlushOptions) => Promise<void>;
229
+ };
230
+ /** The deadline elapsed; persistence may still be running and response bodies remain caller-owned. */
231
+ declare class MppFlushTimeoutError extends Error {
232
+ readonly pending: number;
233
+ readonly timeoutMs: number;
234
+ readonly ledgerPending: boolean;
235
+ constructor(pending: number, timeoutMs: number, ledgerPending?: boolean);
236
+ }
237
+
78
238
  /**
79
239
  * Tempo TIP-20 USD stablecoins priced here, lower-case, all 6 decimals (the challenge carries no
80
240
  * `decimals`). Any other asset cannot be priced, so it is refused while amount limits are set.
@@ -108,7 +268,7 @@ declare function quoteFromChallenge(challenge: ChallengeLike, usdAssets?: Readon
108
268
  amount: string | undefined;
109
269
  priceable: boolean;
110
270
  };
111
- declare function createMppFetch(account: Account, base: typeof fetch | undefined, opts: MppFetchOptions): PayFetch;
271
+ declare function createMppFetch(account: Account, base: typeof fetch | undefined, opts: MppFetchOptions): MppPayFetch;
112
272
 
113
273
  declare const DEFAULT_SOLANA_USD_MINTS: ReadonlySet<string>;
114
274
  type SolanaNetwork = "mainnet" | "devnet" | "localnet";
@@ -122,8 +282,25 @@ type SolanaMppFetchOptions = {
122
282
  integration: MppIntegration;
123
283
  /** Injectable transport; defaults to fetch. The SDK's RPC transport is separate. */
124
284
  fetch?: typeof fetch;
285
+ /**
286
+ * Chain-verify each seller receipt reference before its fulfillment row is written. Off when
287
+ * omitted (the row's receipt_reference_* verification fields stay null). Never delays the response.
288
+ */
289
+ receiptVerification?: SolanaReceiptVerificationOptions;
290
+ };
291
+ type SolanaReceiptVerificationOptions = {
292
+ /** From createReceiptChainVerifier, built for the same cluster as `network`. */
293
+ verify: ReceiptChainVerify;
294
+ /**
295
+ * How long to keep re-checking a reference that is not finalized yet, in ms. Default 0: one check,
296
+ * and a not-yet-finalized reference is recorded as null/not_finalized. This delays only the row
297
+ * (and flush()), never the response.
298
+ */
299
+ finalityWaitMs?: number;
300
+ /** Delay between finality re-checks, in ms. Default 2000. */
301
+ finalityPollMs?: number;
125
302
  };
126
- declare function createSolanaMppFetch(opts: SolanaMppFetchOptions): Promise<PayFetch>;
303
+ declare function createSolanaMppFetch(opts: SolanaMppFetchOptions): Promise<MppPayFetch>;
127
304
 
128
305
  /** In-memory credit row. No chain write and no signer. */
129
306
  type CreditEntry = Readonly<{
@@ -131,6 +308,11 @@ type CreditEntry = Readonly<{
131
308
  amount: string;
132
309
  challengeId: string;
133
310
  reason: string;
311
+ /**
312
+ * Payment reference from MppPaidNoCompletionError, when one is known: `mpp-` plus a short hash of
313
+ * the signed payload. Joins the row to the error and the spend record. Not a chain tx signature.
314
+ */
315
+ reference?: string;
134
316
  }>;
135
317
  declare class CreditLedger {
136
318
  private readonly rows;
@@ -149,6 +331,7 @@ declare class MppPaidNoCompletionError extends Error {
149
331
  /**
150
332
  * Record one credit for a paid call that returned no completion.
151
333
  * A retry of the same payer and challenge returns the first row.
334
+ * The row carries `fields.reference`, else the error's reference when one is known.
152
335
  * `sign`, when passed, is never called.
153
336
  */
154
337
  declare function creditPaidNoCompletion(ledger: CreditLedger, err: MppPaidNoCompletionError, fields: CreditEntry, hooks?: {
@@ -206,5 +389,5 @@ declare function fulfillPaidProof<T>(opts: {
206
389
  upstream: () => Promise<T>;
207
390
  }): Promise<T>;
208
391
 
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 };
392
+ export { CreditLedger, DEFAULT_RPC_TIMEOUT_MS, DEFAULT_SOLANA_USD_MINTS, DEFAULT_TEMPO_USD_ASSETS, DEFAULT_TRUSTED_USD_MINTS, InMemoryReceiptReferenceIndex, LANDING_WINDOW_SECONDS, MPP_FULFILLMENT_MAX_BODY_BYTES, MPP_FULFILLMENT_RECORD_SCHEMA, MppFlushTimeoutError, MppGateError, MppPaidNoCompletionError, RECEIPT_REFERENCE_NOT_EVALUATED, ReplayRejectedError, SpentProofLedger, createMppFetch, createReceiptChainVerifier, createSolanaMppFetch, creditPaidNoCompletion, fulfillPaidProof, quoteFromChallenge, readCredits, readPaidCallEvidence };
393
+ export type { ChainMismatch, ChainUnverifiedReason, CreditEntry, GetFinalizedTransaction, MppBodyDigest, MppFetchOptions, MppFlushOptions, MppFulfillmentRecord, MppFulfillmentSink, MppGateChallenge, MppGateDecision, MppGateEvaluator, MppIntegration, MppPayFetch, MppPaymentFacts, MppReceiptReferenceVerification, ReceiptChainVerification, ReceiptChainVerifierConfig, ReceiptChainVerify, ReceiptChainVerifyInput, ReceiptReferenceIndex, RpcTransaction, SolanaCluster, SolanaMppFetchOptions, SolanaReceiptVerificationOptions, TrustedMint };
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,24 @@ 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.v3";
277
284
  var MPP_FULFILLMENT_MAX_BODY_BYTES = 1024 * 1024;
285
+ var RECEIPT_REFERENCE_NOT_EVALUATED = Object.freeze({
286
+ receipt_reference_chain_verified: null,
287
+ receipt_reference_finalized_slot: null,
288
+ receipt_reference_mismatch: null,
289
+ receipt_reference_unverified_reason: null,
290
+ receipt_reference_purchase_bound: null,
291
+ receipt_reference_purchase_bound_reason: null,
292
+ receipt_reference_local_acceptance: null,
293
+ receipt_reference_refusal_reason: null
294
+ });
295
+ var VERIFIER_ERROR = Object.freeze({
296
+ ...RECEIPT_REFERENCE_NOT_EVALUATED,
297
+ receipt_reference_unverified_reason: "verifier_error"
298
+ });
299
+ var NO_BODY_DIGEST = { response_bytes: null, body_sha256: null };
278
300
  function asRecord(value) {
279
301
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
280
302
  }
@@ -324,6 +346,7 @@ function createSseReceiptParser(challengeId, expectedIntent, expectedMethod) {
324
346
  let unavailable = false;
325
347
  let receipt;
326
348
  let sellerReportedModel = null;
349
+ const hash = createHash2("sha256");
327
350
  const finishEvent = () => {
328
351
  if (data.length && eventName === "payment-receipt" && !receipt) {
329
352
  try {
@@ -359,6 +382,7 @@ function createSseReceiptParser(challengeId, expectedIntent, expectedMethod) {
359
382
  return {
360
383
  push(chunk) {
361
384
  totalBytes += chunk.byteLength;
385
+ if (totalBytes <= MPP_FULFILLMENT_MAX_BODY_BYTES) hash.update(chunk);
362
386
  if (totalBytes > MPP_FULFILLMENT_MAX_BODY_BYTES) {
363
387
  unavailable = true;
364
388
  buffer = "";
@@ -378,6 +402,10 @@ function createSseReceiptParser(challengeId, expectedIntent, expectedMethod) {
378
402
  buffer = "";
379
403
  }
380
404
  },
405
+ /** Digest of every pushed byte; call only after the source stream reported done. */
406
+ digest() {
407
+ return totalBytes > MPP_FULFILLMENT_MAX_BODY_BYTES ? NO_BODY_DIGEST : { response_bytes: totalBytes, body_sha256: hash.digest("hex") };
408
+ },
381
409
  finish() {
382
410
  if (!unavailable && !receipt) {
383
411
  buffer += decoder.decode();
@@ -391,8 +419,8 @@ function createSseReceiptParser(challengeId, expectedIntent, expectedMethod) {
391
419
  }
392
420
  };
393
421
  }
394
- async function readJsonCapped(body) {
395
- if (!body) return void 0;
422
+ async function readBodyCapped(body) {
423
+ if (!body) return null;
396
424
  const reader = body.getReader();
397
425
  const chunks = [];
398
426
  let size = 0;
@@ -403,15 +431,32 @@ async function readJsonCapped(body) {
403
431
  size += value.byteLength;
404
432
  if (size > MPP_FULFILLMENT_MAX_BODY_BYTES) {
405
433
  await reader.cancel().catch(() => void 0);
406
- return void 0;
434
+ return null;
407
435
  }
408
436
  chunks.push(value);
409
437
  }
410
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
438
+ return Buffer.concat(chunks);
439
+ } catch {
440
+ return null;
441
+ }
442
+ }
443
+ function parseJson(bytes) {
444
+ if (!bytes) return void 0;
445
+ try {
446
+ return JSON.parse(bytes.toString("utf8"));
411
447
  } catch {
412
448
  return void 0;
413
449
  }
414
450
  }
451
+ function digestOf(bytes) {
452
+ return bytes ? {
453
+ response_bytes: bytes.byteLength,
454
+ body_sha256: createHash2("sha256").update(bytes).digest("hex")
455
+ } : NO_BODY_DIGEST;
456
+ }
457
+ async function readJsonCapped(body) {
458
+ return parseJson(await readBodyCapped(body));
459
+ }
415
460
  function bodyIsJson(headers) {
416
461
  return (headers.get("content-type") ?? "").toLowerCase().includes("json");
417
462
  }
@@ -449,11 +494,20 @@ function wrappedResponse(response, body) {
449
494
  });
450
495
  return wrapped;
451
496
  }
452
- async function emitRecord(input, receipt, requestValue, responseValue, sink, sellerModelOverride) {
497
+ async function emitRecord(input, receipt, requestValue, responseValue, sink, digest, sellerModelOverride) {
453
498
  const req = asRecord(requestValue);
454
499
  const res = asRecord(responseValue);
455
500
  const requestedModel = string(req?.model);
456
501
  const sellerReportedModel = string(sellerModelOverride) ?? string(res?.model);
502
+ const reference = string(receipt.value?.reference);
503
+ let verification = RECEIPT_REFERENCE_NOT_EVALUATED;
504
+ if (receipt.state === "valid_format" && reference && input.verifyReceiptReference) {
505
+ try {
506
+ verification = await input.verifyReceiptReference(reference);
507
+ } catch {
508
+ verification = VERIFIER_ERROR;
509
+ }
510
+ }
457
511
  const row = {
458
512
  schema: MPP_FULFILLMENT_RECORD_SCHEMA,
459
513
  at_unix_ms: Date.now(),
@@ -469,12 +523,22 @@ async function emitRecord(input, receipt, requestValue, responseValue, sink, sel
469
523
  receipt_state: receipt.state,
470
524
  receipt_status: string(receipt.value?.status),
471
525
  receipt_method: string(receipt.value?.method),
472
- receipt_reference: string(receipt.value?.reference),
526
+ receipt_reference: reference,
473
527
  receipt_timestamp: string(receipt.value?.timestamp),
474
528
  requested_model: requestedModel,
475
529
  seller_reported_model: sellerReportedModel,
476
530
  seller_reported_model_matches_request: requestedModel && sellerReportedModel ? requestedModel.toLowerCase() === sellerReportedModel.toLowerCase() : null,
477
- gate: input.gate ?? null
531
+ gate: input.gate ?? null,
532
+ response_bytes: digest.response_bytes,
533
+ body_sha256: digest.body_sha256,
534
+ receipt_reference_chain_verified: verification.receipt_reference_chain_verified,
535
+ receipt_reference_finalized_slot: verification.receipt_reference_finalized_slot,
536
+ receipt_reference_mismatch: verification.receipt_reference_mismatch,
537
+ receipt_reference_unverified_reason: verification.receipt_reference_unverified_reason,
538
+ receipt_reference_purchase_bound: verification.receipt_reference_purchase_bound,
539
+ receipt_reference_purchase_bound_reason: verification.receipt_reference_purchase_bound_reason,
540
+ receipt_reference_local_acceptance: verification.receipt_reference_local_acceptance,
541
+ receipt_reference_refusal_reason: verification.receipt_reference_refusal_reason
478
542
  };
479
543
  await sink(row);
480
544
  }
@@ -507,11 +571,11 @@ function recordMppFulfillment(input, sink, onPending) {
507
571
  });
508
572
  track(recording2);
509
573
  let recordingStarted = false;
510
- const finishRecord = (receipt2, sellerReportedModel) => {
574
+ const finishRecord = (receipt2, sellerReportedModel, digest) => {
511
575
  if (recordingStarted) return;
512
576
  recordingStarted = true;
513
577
  const write = requestBody.then(
514
- (request) => emitRecord(input, receipt2, request, void 0, sink, sellerReportedModel)
578
+ (request) => emitRecord(input, receipt2, request, void 0, sink, digest, sellerReportedModel)
515
579
  );
516
580
  void write.then(resolveRecording, rejectRecording);
517
581
  void write.catch(() => {
@@ -526,7 +590,7 @@ function recordMppFulfillment(input, sink, onPending) {
526
590
  const { done, value } = await reader.read();
527
591
  if (done) {
528
592
  const parsed = parser.finish();
529
- finishRecord(parsed.receipt, parsed.sellerReportedModel);
593
+ finishRecord(parsed.receipt, parsed.sellerReportedModel, parser.digest());
530
594
  controller.close();
531
595
  reader.releaseLock();
532
596
  return;
@@ -534,7 +598,7 @@ function recordMppFulfillment(input, sink, onPending) {
534
598
  parser.push(value);
535
599
  controller.enqueue(value);
536
600
  } catch (error) {
537
- finishRecord({ state: "unavailable", value: null }, null);
601
+ finishRecord({ state: "unavailable", value: null }, null, NO_BODY_DIGEST);
538
602
  controller.error(error);
539
603
  }
540
604
  },
@@ -542,14 +606,14 @@ function recordMppFulfillment(input, sink, onPending) {
542
606
  await reader.cancel(reason).catch(() => void 0);
543
607
  const parsed = parser.finish();
544
608
  const receipt2 = parsed.receipt.state === "missing" ? { state: "unavailable", value: null } : parsed.receipt;
545
- finishRecord(receipt2, parsed.sellerReportedModel);
609
+ finishRecord(receipt2, parsed.sellerReportedModel, NO_BODY_DIGEST);
546
610
  }
547
611
  });
548
612
  return wrappedResponse(input.response, tapped);
549
613
  }
550
- const responseBody = bodyIsJson(input.response.headers) ? readJsonCapped(input.response.clone().body) : Promise.resolve(void 0);
614
+ const responseBody = bodyIsJson(input.response.headers) ? readBodyCapped(input.response.clone().body) : Promise.resolve(null);
551
615
  const recording = Promise.all([requestBody, responseBody]).then(
552
- ([request, response]) => emitRecord(input, receipt, request, response, sink)
616
+ ([request, bytes]) => emitRecord(input, receipt, request, parseJson(bytes), sink, digestOf(bytes))
553
617
  );
554
618
  track(recording);
555
619
  void recording.catch(() => {
@@ -583,6 +647,102 @@ function runWithMppPaymentBudget(context, run) {
583
647
  return contextStorage.run(context, run);
584
648
  }
585
649
 
650
+ // src/mpp-flush.ts
651
+ var MppFlushTimeoutError = class extends Error {
652
+ pending;
653
+ timeoutMs;
654
+ ledgerPending;
655
+ constructor(pending, timeoutMs, ledgerPending = false) {
656
+ super(
657
+ `MPP flush deadline reached after ${timeoutMs} ms: ${pending} fulfillment record(s) pending; ledger flush ${ledgerPending ? "still pending" : "finished"}`
658
+ );
659
+ this.name = "MppFlushTimeoutError";
660
+ this.pending = pending;
661
+ this.timeoutMs = timeoutMs;
662
+ this.ledgerPending = ledgerPending;
663
+ }
664
+ };
665
+ var MAX_TIMER_MS = 2147483647;
666
+ function deadlineOf(options) {
667
+ const value = options?.timeoutMs;
668
+ if (value === void 0) return void 0;
669
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > MAX_TIMER_MS) {
670
+ throw new TypeError(`flush timeoutMs must be a number from 0 to ${MAX_TIMER_MS}`);
671
+ }
672
+ return value;
673
+ }
674
+ var ownership = /* @__PURE__ */ new WeakMap();
675
+ function ownershipFor(failures) {
676
+ let entry = ownership.get(failures);
677
+ if (!entry) {
678
+ entry = { waiters: /* @__PURE__ */ new Map(), claimed: /* @__PURE__ */ new WeakSet() };
679
+ ownership.set(failures, entry);
680
+ }
681
+ return entry;
682
+ }
683
+ var isObject = (value) => typeof value === "object" && value !== null || typeof value === "function";
684
+ async function flushFulfillments(pending, failures, integrationFlush, options) {
685
+ const timeoutMs = deadlineOf(options);
686
+ let timer;
687
+ let expired = false;
688
+ const deadline = timeoutMs === void 0 ? void 0 : new Promise((resolve) => {
689
+ timer = setTimeout(() => {
690
+ expired = true;
691
+ resolve();
692
+ }, timeoutMs);
693
+ });
694
+ const wait = (task) => deadline ? Promise.race([task, deadline]) : task;
695
+ try {
696
+ await wait(Promise.all([...pending]).then(() => void 0));
697
+ const owners = ownershipFor(failures);
698
+ const ownErrors = [];
699
+ let ledgerPending = true;
700
+ let waiting = true;
701
+ let source;
702
+ try {
703
+ source = Promise.resolve(integrationFlush());
704
+ } catch (error) {
705
+ ownErrors.push(error);
706
+ ledgerPending = false;
707
+ }
708
+ const release = () => {
709
+ if (!waiting) return;
710
+ waiting = false;
711
+ if (!source) return;
712
+ const left = (owners.waiters.get(source) ?? 1) - 1;
713
+ if (left > 0) owners.waiters.set(source, left);
714
+ else owners.waiters.delete(source);
715
+ };
716
+ let ledger = Promise.resolve();
717
+ if (source) {
718
+ const observed = source;
719
+ owners.waiters.set(observed, (owners.waiters.get(observed) ?? 0) + 1);
720
+ ledger = observed.catch((error) => {
721
+ if (waiting) {
722
+ ownErrors.push(error);
723
+ if (isObject(error)) owners.claimed.add(error);
724
+ const queued = failures.indexOf(error);
725
+ if (queued >= 0) failures.splice(queued, 1);
726
+ } else if (!owners.waiters.get(observed) && !(isObject(error) && owners.claimed.has(error)) && !failures.includes(error)) {
727
+ failures.push(error);
728
+ }
729
+ }).finally(() => {
730
+ ledgerPending = false;
731
+ });
732
+ }
733
+ try {
734
+ await wait(ledger);
735
+ } finally {
736
+ release();
737
+ }
738
+ const errors = [.../* @__PURE__ */ new Set([...failures.splice(0), ...ownErrors])];
739
+ if (expired) errors.push(new MppFlushTimeoutError(pending.size, timeoutMs, ledgerPending));
740
+ if (errors.length) throw new AggregateError(errors, "MPP fulfillment records did not flush");
741
+ } finally {
742
+ clearTimeout(timer);
743
+ }
744
+ }
745
+
586
746
  // src/mpp-fetch.ts
587
747
  var mppxClientDeps = {
588
748
  load: () => loadOptional("mppx", MPPX_RANGE, () => import("mppx/client"))
@@ -747,20 +907,12 @@ function createMppFetch(account, base = fetch, opts) {
747
907
  payFetch.supportsNativeStreaming = true;
748
908
  payFetch.onNoCompletion = rejectPaidNoCompletion;
749
909
  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
- };
910
+ payFetch.flush = (options) => flushFulfillments(pendingFulfillments, fulfillmentFailures, () => integration.flush(), options);
760
911
  return payFetch;
761
912
  }
762
913
 
763
914
  // src/mpp-solana-fetch.ts
915
+ import { randomUUID } from "crypto";
764
916
  import {
765
917
  assertSpendPolicyAllows as assertSpendPolicyAllows2,
766
918
  SpendPolicyError as SpendPolicyError3
@@ -775,12 +927,46 @@ var CAIP2 = {
775
927
  devnet: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
776
928
  localnet: "solana:localnet"
777
929
  };
930
+ var MAX_TIMER_MS2 = 2147483647;
931
+ function verificationSettings(options, network) {
932
+ if (options === void 0) return void 0;
933
+ if (typeof options?.verify !== "function") {
934
+ throw new TypeError("receiptVerification.verify must come from createReceiptChainVerifier");
935
+ }
936
+ if (options.verify.cluster !== network) {
937
+ throw new TypeError(
938
+ `receiptVerification is built for ${String(options.verify.cluster)}, but the rail network is ${network}`
939
+ );
940
+ }
941
+ const waitMs = options.finalityWaitMs ?? 0;
942
+ const pollMs = options.finalityPollMs ?? 2e3;
943
+ for (const [name, value, min] of [
944
+ ["finalityWaitMs", waitMs, 0],
945
+ ["finalityPollMs", pollMs, 1]
946
+ ]) {
947
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > MAX_TIMER_MS2)
948
+ throw new TypeError(
949
+ `receiptVerification.${name} must be a number from ${min} to ${MAX_TIMER_MS2}`
950
+ );
951
+ }
952
+ return { verify: options.verify, waitMs, pollMs };
953
+ }
954
+ async function verifyUntilFinal(verify, waitMs, pollMs) {
955
+ const deadline = Date.now() + waitMs;
956
+ for (; ; ) {
957
+ const result = await verify();
958
+ if (result.receipt_reference_unverified_reason !== "not_finalized") return result;
959
+ if (Date.now() + pollMs > deadline) return result;
960
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
961
+ }
962
+ }
778
963
  var solanaChargeDeps = {
779
964
  load: () => loadOptional("@solana/mpp", SOLANA_MPP_RANGE, () => import("@solana/mpp/client"))
780
965
  };
781
966
  async function createSolanaMppFetch(opts) {
782
967
  assertMppIntegration(opts?.integration, "Solana");
783
968
  const integration = Object.freeze({ ...opts.integration });
969
+ const verification = verificationSettings(opts.receiptVerification, opts.network);
784
970
  const { charge, selectSolanaChargeChallengeFromResponse, resolveStablecoinMint } = await solanaChargeDeps.load();
785
971
  const base = opts.fetch ?? fetch;
786
972
  const usd = /* @__PURE__ */ new Set([...DEFAULT_SOLANA_USD_MINTS, ...opts.usdMints ?? []]);
@@ -877,6 +1063,8 @@ async function createSolanaMppFetch(opts) {
877
1063
  let sessionSpendRecorded = false;
878
1064
  let record;
879
1065
  let payload = "";
1066
+ let signedTransaction = null;
1067
+ let signedAtUnix = null;
880
1068
  try {
881
1069
  const method = charge({
882
1070
  signer: opts.signer,
@@ -887,6 +1075,8 @@ async function createSolanaMppFetch(opts) {
887
1075
  if (event.type !== "signed") return;
888
1076
  signed = true;
889
1077
  payload = event.transaction;
1078
+ signedTransaction = event.transaction;
1079
+ signedAtUnix = Math.floor(Date.now() / 1e3);
890
1080
  if (!sessionSpendRecorded) {
891
1081
  budgetContext?.recordSigned(budgetQuoteMicros);
892
1082
  sessionSpendRecorded = true;
@@ -912,12 +1102,17 @@ async function createSolanaMppFetch(opts) {
912
1102
  });
913
1103
  }
914
1104
  }
1105
+ const splits = challenge.request.methodDetails.splits;
915
1106
  return {
916
1107
  credential,
917
1108
  payload,
918
1109
  record,
919
1110
  gate: gateDecision,
920
- payment
1111
+ payment,
1112
+ signedTransaction,
1113
+ signedAtUnix,
1114
+ purchaseId: randomUUID(),
1115
+ hasSplits: Array.isArray(splits) && splits.length > 0
921
1116
  };
922
1117
  } catch (err) {
923
1118
  if (signed) {
@@ -951,8 +1146,32 @@ async function createSolanaMppFetch(opts) {
951
1146
  const response = await base(new Request(request, { headers }));
952
1147
  const paid = currentPaidCall();
953
1148
  if (response.status !== 402 && paid?.signed && paid.payment) {
1149
+ const terms = paid.payment;
1150
+ const verifyReceiptReference = verification ? (reference) => verifyUntilFinal(
1151
+ () => verification.verify({
1152
+ reference,
1153
+ method: terms.method,
1154
+ network: terms.network,
1155
+ asset: terms.asset,
1156
+ payTo: terms.payTo,
1157
+ amountAtomic: terms.amountAtomic,
1158
+ hasSplits: result.hasSplits,
1159
+ payer: opts.signer.address,
1160
+ signedTransaction: result.signedTransaction,
1161
+ signedAtUnix: result.signedAtUnix,
1162
+ purchaseId: result.purchaseId
1163
+ }),
1164
+ verification.waitMs,
1165
+ verification.pollMs
1166
+ ) : void 0;
954
1167
  return recordMppFulfillment(
955
- { input: requestForRecord, response, payment: paid.payment, gate: paid.gate },
1168
+ {
1169
+ input: requestForRecord,
1170
+ response,
1171
+ payment: terms,
1172
+ gate: paid.gate,
1173
+ verifyReceiptReference
1174
+ },
956
1175
  integration.onFulfillment,
957
1176
  trackFulfillment
958
1177
  );
@@ -968,16 +1187,7 @@ async function createSolanaMppFetch(opts) {
968
1187
  payFetch.supportsNativeStreaming = true;
969
1188
  payFetch.onNoCompletion = rejectPaidNoCompletion;
970
1189
  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
- };
1190
+ payFetch.flush = (options) => flushFulfillments(pendingFulfillments, fulfillmentFailures, () => integration.flush(), options);
981
1191
  return payFetch;
982
1192
  }
983
1193
  function solanaNetworkId(value) {
@@ -1035,17 +1245,293 @@ async function fulfillPaidProof(opts) {
1035
1245
  if (!won) throw new ReplayRejectedError(opts.networkId, opts.txSignature, opts.challengeId);
1036
1246
  return opts.upstream();
1037
1247
  }
1248
+
1249
+ // src/receipt-chain-verify.ts
1250
+ var SOLANA_KIT_RANGE = "~6.10.0";
1251
+ var TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
1252
+ var TOKEN_2022_PROGRAM = "TokenzQdBNbLqP7VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
1253
+ var ASSOCIATED_TOKEN_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
1254
+ var KNOWN_TOKEN_PROGRAMS = /* @__PURE__ */ new Set([TOKEN_PROGRAM, TOKEN_2022_PROGRAM]);
1255
+ var IX_TRANSFER = 3;
1256
+ var IX_TRANSFER_CHECKED = 12;
1257
+ var LANDING_WINDOW_SECONDS = 90;
1258
+ var CLUSTER_IDS = {
1259
+ mainnet: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
1260
+ devnet: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"
1261
+ };
1262
+ var SYMBOL_MINTS = {
1263
+ mainnet: { USDC: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" },
1264
+ devnet: { USDC: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" }
1265
+ };
1266
+ var DEFAULT_TRUSTED_USD_MINTS = Object.freeze({
1267
+ EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: { decimals: 6, tokenPrograms: [TOKEN_PROGRAM] },
1268
+ "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU": { decimals: 6, tokenPrograms: [TOKEN_PROGRAM] },
1269
+ Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB: { decimals: 6, tokenPrograms: [TOKEN_PROGRAM] }
1270
+ });
1271
+ var InMemoryReceiptReferenceIndex = class {
1272
+ #owners = /* @__PURE__ */ new Map();
1273
+ async bind(reference, purchaseId) {
1274
+ const current = this.#owners.get(reference);
1275
+ if (current === void 0) {
1276
+ this.#owners.set(reference, purchaseId);
1277
+ return true;
1278
+ }
1279
+ return current === purchaseId;
1280
+ }
1281
+ async owner(reference) {
1282
+ return this.#owners.get(reference);
1283
+ }
1284
+ };
1285
+ var DEFAULT_RPC_TIMEOUT_MS = 1e4;
1286
+ var MAX_TIMER_MS3 = 2147483647;
1287
+ var loadKit = () => loadOptional("@solana/kit", SOLANA_KIT_RANGE, () => import("@solana/kit"));
1288
+ function bytesEqual(a, b) {
1289
+ if (a.length !== b.length) return false;
1290
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
1291
+ return true;
1292
+ }
1293
+ function readU64(data, offset) {
1294
+ let value = 0n;
1295
+ for (let i = 7; i >= 0; i--) value = value << 8n | BigInt(data[offset + i]);
1296
+ return value;
1297
+ }
1298
+ function clusterOf(network) {
1299
+ if (network === "mainnet" || network === "mainnet-beta" || network === CLUSTER_IDS.mainnet)
1300
+ return "mainnet";
1301
+ if (network === "devnet" || network === CLUSTER_IDS.devnet) return "devnet";
1302
+ return void 0;
1303
+ }
1304
+ function createReceiptChainVerifier(config) {
1305
+ const maxAge = config.maxAgeSeconds ?? 600;
1306
+ const skew = config.maxClockSkewSeconds ?? 60;
1307
+ if (!Number.isFinite(maxAge) || !Number.isFinite(skew) || skew < 0) {
1308
+ throw new TypeError("maxAgeSeconds and maxClockSkewSeconds must be finite; skew must be >= 0");
1309
+ }
1310
+ if (maxAge <= skew + LANDING_WINDOW_SECONDS) {
1311
+ throw new RangeError(
1312
+ `maxAgeSeconds (${maxAge}) must exceed maxClockSkewSeconds (${skew}) + the ${LANDING_WINDOW_SECONDS} s landing window`
1313
+ );
1314
+ }
1315
+ if (!config.index) throw new TypeError("a ReceiptReferenceIndex is required");
1316
+ const rpcTimeoutMs = config.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS;
1317
+ if (typeof rpcTimeoutMs !== "number" || !Number.isFinite(rpcTimeoutMs) || rpcTimeoutMs < 1 || rpcTimeoutMs > MAX_TIMER_MS3) {
1318
+ throw new TypeError(`rpcTimeoutMs must be a number from 1 to ${MAX_TIMER_MS3}`);
1319
+ }
1320
+ const trusted = config.trustedMints ?? DEFAULT_TRUSTED_USD_MINTS;
1321
+ const cluster = config.cluster;
1322
+ if (!(cluster in CLUSTER_IDS)) throw new TypeError(`unknown cluster ${String(cluster)}`);
1323
+ async function fetchWithDeadline(getTransaction, reference) {
1324
+ const controller = new AbortController();
1325
+ let timer;
1326
+ const expired = new Promise((_, reject) => {
1327
+ timer = setTimeout(() => {
1328
+ const reason = new Error(`getTransaction exceeded rpcTimeoutMs (${rpcTimeoutMs} ms)`);
1329
+ controller.abort(reason);
1330
+ reject(reason);
1331
+ }, rpcTimeoutMs);
1332
+ });
1333
+ try {
1334
+ return await Promise.race([
1335
+ getTransaction(reference, { signal: controller.signal }),
1336
+ expired
1337
+ ]);
1338
+ } finally {
1339
+ clearTimeout(timer);
1340
+ }
1341
+ }
1342
+ async function verify(input) {
1343
+ const chain = await chainFacts(input);
1344
+ const verified = chain.receipt_reference_chain_verified;
1345
+ const purchase = verified === true ? {
1346
+ receipt_reference_purchase_bound: null,
1347
+ receipt_reference_purchase_bound_reason: "no_buyer_nonce"
1348
+ } : {
1349
+ receipt_reference_purchase_bound: null,
1350
+ receipt_reference_purchase_bound_reason: "chain_unverified"
1351
+ };
1352
+ let refusal = null;
1353
+ if (chain.blockTime !== null && input.signedAtUnix !== null && Number.isFinite(input.signedAtUnix) && input.signedAtUnix - chain.blockTime > maxAge) {
1354
+ refusal = "stale_by_local_clock";
1355
+ }
1356
+ if (verified === true && refusal === null) {
1357
+ const owns = await config.index.bind(input.reference, input.purchaseId);
1358
+ if (!owns) refusal = "reference_reused";
1359
+ } else {
1360
+ const owner = await config.index.owner(input.reference);
1361
+ if (owner !== void 0 && owner !== input.purchaseId) refusal = "reference_reused";
1362
+ }
1363
+ const acceptance = refusal ? "refused" : verified === true ? "accepted" : null;
1364
+ return {
1365
+ receipt_reference_chain_verified: verified,
1366
+ receipt_reference_finalized_slot: chain.receipt_reference_finalized_slot,
1367
+ receipt_reference_mismatch: chain.receipt_reference_mismatch,
1368
+ receipt_reference_unverified_reason: chain.receipt_reference_unverified_reason,
1369
+ ...purchase,
1370
+ receipt_reference_local_acceptance: acceptance,
1371
+ receipt_reference_refusal_reason: refusal
1372
+ };
1373
+ }
1374
+ function result(verified, code, slot = null, blockTime = null) {
1375
+ return {
1376
+ receipt_reference_chain_verified: verified,
1377
+ receipt_reference_finalized_slot: slot,
1378
+ receipt_reference_mismatch: verified === false ? code : null,
1379
+ receipt_reference_unverified_reason: verified === null ? code : null,
1380
+ blockTime
1381
+ };
1382
+ }
1383
+ async function chainFacts(input) {
1384
+ if (input.method !== "solana") return result(null, "non_solana");
1385
+ const kit = await loadKit();
1386
+ const base58 = kit.getBase58Encoder();
1387
+ let referenceBytes;
1388
+ try {
1389
+ referenceBytes = new Uint8Array(base58.encode(input.reference));
1390
+ } catch {
1391
+ return result(false, "bad_reference_format");
1392
+ }
1393
+ if (referenceBytes.length !== 64) return result(false, "bad_reference_format");
1394
+ const challengeCluster = input.network ? clusterOf(input.network) : void 0;
1395
+ if (challengeCluster !== cluster) return result(false, "wrong_network");
1396
+ if (input.hasSplits) return result(null, "splits_unsupported");
1397
+ if (!input.signedTransaction) return result(null, "no_signed_transaction");
1398
+ const symbols = SYMBOL_MINTS[cluster];
1399
+ const asset = input.asset ? Object.prototype.hasOwnProperty.call(symbols, input.asset) ? symbols[input.asset] : input.asset : null;
1400
+ if (!asset || !kit.isAddress(asset)) return result(null, "unnormalized_terms");
1401
+ if (!input.payTo || !kit.isAddress(input.payTo)) return result(null, "unnormalized_terms");
1402
+ if (!kit.isAddress(input.payer)) return result(null, "unnormalized_terms");
1403
+ if (!input.amountAtomic || !/^\d+$/.test(input.amountAtomic))
1404
+ return result(null, "unnormalized_terms");
1405
+ let retained;
1406
+ try {
1407
+ retained = decodeWire(kit, input.signedTransaction);
1408
+ } catch {
1409
+ return result(null, "no_signed_transaction");
1410
+ }
1411
+ if (!config.getTransaction) return result(null, "no_fetcher");
1412
+ let tx;
1413
+ try {
1414
+ tx = await fetchWithDeadline(config.getTransaction, input.reference);
1415
+ } catch {
1416
+ return result(null, "rpc_unavailable");
1417
+ }
1418
+ if (!tx) return result(null, "not_finalized");
1419
+ const slot = typeof tx.slot === "number" ? tx.slot : null;
1420
+ const blockTime = typeof tx.blockTime === "number" ? tx.blockTime : null;
1421
+ const at = (verified, code) => result(verified, code, slot, blockTime);
1422
+ if (!tx.meta) return at(null, "rpc_unavailable");
1423
+ if (tx.meta.err !== null && tx.meta.err !== void 0) return at(false, "tx_failed");
1424
+ let onChain;
1425
+ try {
1426
+ onChain = decodeWire(kit, tx.transaction[0]);
1427
+ } catch {
1428
+ return at(null, "rpc_unavailable");
1429
+ }
1430
+ const firstSignature = Object.values(onChain.signatures)[0];
1431
+ if (!firstSignature || !bytesEqual(firstSignature, referenceBytes))
1432
+ return at(false, "signature_mismatch");
1433
+ if (!bytesEqual(onChain.messageBytes, retained.messageBytes))
1434
+ return at(false, "message_mismatch");
1435
+ const buyerRetained = retained.signatures[input.payer];
1436
+ const buyerOnChain = onChain.signatures[input.payer];
1437
+ if (!buyerRetained || !buyerOnChain || !bytesEqual(buyerRetained, buyerOnChain)) {
1438
+ return at(false, "buyer_signature_missing");
1439
+ }
1440
+ const message = kit.getCompiledTransactionMessageDecoder().decode(onChain.messageBytes);
1441
+ if (message.version !== "legacy" && message.version !== 0) {
1442
+ return at(null, "unsupported_message_format");
1443
+ }
1444
+ const lookups = message.addressTableLookups;
1445
+ if (lookups && lookups.length > 0) return at(null, "unsupported_message_format");
1446
+ const keys = message.staticAccounts.map(String);
1447
+ const transfers = [];
1448
+ const consider = (programIndex, accountIndexes, data) => {
1449
+ const program = keys[programIndex];
1450
+ if (!program || !KNOWN_TOKEN_PROGRAMS.has(program) || data.length === 0) return;
1451
+ if (data[0] !== IX_TRANSFER && data[0] !== IX_TRANSFER_CHECKED) return;
1452
+ transfers.push({ program, tag: data[0], accounts: accountIndexes.map((i) => keys[i]), data });
1453
+ };
1454
+ for (const ix of message.instructions) {
1455
+ consider(ix.programAddressIndex, ix.accountIndices ?? [], new Uint8Array(ix.data ?? []));
1456
+ }
1457
+ const base58Bytes = kit.getBase58Encoder();
1458
+ if (!Array.isArray(tx.meta.innerInstructions)) return at(null, "rpc_unavailable");
1459
+ for (const group of tx.meta.innerInstructions) {
1460
+ for (const ix of group.instructions) {
1461
+ let data;
1462
+ try {
1463
+ data = new Uint8Array(base58Bytes.encode(ix.data));
1464
+ } catch {
1465
+ return at(null, "rpc_unavailable");
1466
+ }
1467
+ consider(ix.programIdIndex, ix.accounts, data);
1468
+ }
1469
+ }
1470
+ const checked = transfers.filter((t) => t.tag === IX_TRANSFER_CHECKED);
1471
+ if (checked.length === 0) return at(false, "no_matching_transfer");
1472
+ if (checked.length > 1 || transfers.length > 1) return at(false, "multiple_matching_transfers");
1473
+ const [transfer] = checked;
1474
+ if (transfer.data.length < 10 || transfer.accounts.length < 4)
1475
+ return at(false, "no_matching_transfer");
1476
+ const [source, mint, destination, authority] = transfer.accounts;
1477
+ const trust = Object.prototype.hasOwnProperty.call(trusted, asset) ? trusted[asset] : void 0;
1478
+ if (!trust) return at(false, "untrusted_mint");
1479
+ if (!trust.tokenPrograms.includes(transfer.program))
1480
+ return at(false, "untrusted_token_program");
1481
+ if (mint !== asset) return at(false, "mint_mismatch");
1482
+ if (transfer.data[9] !== trust.decimals) return at(false, "decimals_mismatch");
1483
+ const [expectedDestination] = await kit.getProgramDerivedAddress({
1484
+ programAddress: kit.address(ASSOCIATED_TOKEN_PROGRAM),
1485
+ seeds: [
1486
+ kit.getAddressEncoder().encode(kit.address(input.payTo)),
1487
+ kit.getAddressEncoder().encode(kit.address(transfer.program)),
1488
+ kit.getAddressEncoder().encode(kit.address(asset))
1489
+ ]
1490
+ });
1491
+ if (destination !== expectedDestination) return at(false, "payee_mismatch");
1492
+ if (readU64(transfer.data, 1) !== BigInt(input.amountAtomic))
1493
+ return at(false, "amount_mismatch");
1494
+ if (authority !== input.payer) return at(false, "payer_mismatch");
1495
+ const [expectedSource] = await kit.getProgramDerivedAddress({
1496
+ programAddress: kit.address(ASSOCIATED_TOKEN_PROGRAM),
1497
+ seeds: [
1498
+ kit.getAddressEncoder().encode(kit.address(input.payer)),
1499
+ kit.getAddressEncoder().encode(kit.address(transfer.program)),
1500
+ kit.getAddressEncoder().encode(kit.address(asset))
1501
+ ]
1502
+ });
1503
+ if (source !== expectedSource) return at(false, "payer_mismatch");
1504
+ return at(true, null);
1505
+ }
1506
+ return Object.freeze(Object.assign(verify, { cluster }));
1507
+ }
1508
+ function decodeWire(kit, base64) {
1509
+ const bytes = kit.getBase64Encoder().encode(base64);
1510
+ const decoded = kit.getTransactionDecoder().decode(bytes);
1511
+ const signatures = {};
1512
+ for (const [addr, sig] of Object.entries(decoded.signatures)) {
1513
+ signatures[addr] = sig ? new Uint8Array(sig) : null;
1514
+ }
1515
+ return { messageBytes: new Uint8Array(decoded.messageBytes), signatures };
1516
+ }
1038
1517
  export {
1039
1518
  CreditLedger,
1519
+ DEFAULT_RPC_TIMEOUT_MS,
1040
1520
  DEFAULT_SOLANA_USD_MINTS,
1041
1521
  DEFAULT_TEMPO_USD_ASSETS,
1522
+ DEFAULT_TRUSTED_USD_MINTS,
1523
+ InMemoryReceiptReferenceIndex,
1524
+ LANDING_WINDOW_SECONDS,
1042
1525
  MPP_FULFILLMENT_MAX_BODY_BYTES,
1043
1526
  MPP_FULFILLMENT_RECORD_SCHEMA,
1527
+ MppFlushTimeoutError,
1044
1528
  MppGateError,
1045
1529
  MppPaidNoCompletionError,
1530
+ RECEIPT_REFERENCE_NOT_EVALUATED,
1046
1531
  ReplayRejectedError,
1047
1532
  SpentProofLedger,
1048
1533
  createMppFetch,
1534
+ createReceiptChainVerifier,
1049
1535
  createSolanaMppFetch,
1050
1536
  creditPaidNoCompletion,
1051
1537
  fulfillPaidProof,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twzrd1111/clawrouter-mpp",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",