@twzrd1111/clawrouter-mpp 0.3.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,23 @@
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
+
6
23
  ## Upgrading from 0.2.0
7
24
 
8
25
  - Fulfillment rows now use `twzrd.mpp.fulfillment.v2`. Readers must accept the new schema to retain
@@ -74,12 +91,58 @@ intent match the challenge; it is not an on-chain verification. `seller_reported
74
91
  compares labels only and does not verify model identity. A failed local write does not change an
75
92
  already-paid response; `payFetch.flush()` surfaces persistence failures to the shutdown caller.
76
93
 
77
- Rows use schema `twzrd.mpp.fulfillment.v2`. `response_bytes` and `body_sha256` (lowercase hex
94
+ Rows use schema `twzrd.mpp.fulfillment.v3`. `response_bytes` and `body_sha256` (lowercase hex
78
95
  SHA-256) describe the complete response body as the recorder read it: a JSON body read through its
79
96
  clone, or an SSE body tapped to its end. Both are `null` when the recorder did not read the body,
80
97
  it exceeded 1 MiB, or the stream was cancelled or errored before its end; a partial body is never
81
98
  hashed. The digest is of bytes the seller sent and is not a seller attestation.
82
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
+
83
146
  This candidate requires `@twzrd1111/clawrouter >=0.12.309`, including deferred streaming headers.
84
147
  The fork is the release target. Upstream PRs #545 and #546 are independent of this release.
85
148
 
package/dist/index.d.ts CHANGED
@@ -3,7 +3,124 @@ 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.v2";
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;
@@ -155,6 +282,23 @@ type SolanaMppFetchOptions = {
155
282
  integration: MppIntegration;
156
283
  /** Injectable transport; defaults to fetch. The SDK's RPC transport is separate. */
157
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;
158
302
  };
159
303
  declare function createSolanaMppFetch(opts: SolanaMppFetchOptions): Promise<MppPayFetch>;
160
304
 
@@ -245,5 +389,5 @@ declare function fulfillPaidProof<T>(opts: {
245
389
  upstream: () => Promise<T>;
246
390
  }): Promise<T>;
247
391
 
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 };
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
@@ -280,8 +280,22 @@ async function evaluateMppGate(evaluator, challenge) {
280
280
 
281
281
  // src/mpp-fulfillment-record.ts
282
282
  import { createHash as createHash2 } from "crypto";
283
- var MPP_FULFILLMENT_RECORD_SCHEMA = "twzrd.mpp.fulfillment.v2";
283
+ var MPP_FULFILLMENT_RECORD_SCHEMA = "twzrd.mpp.fulfillment.v3";
284
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
+ });
285
299
  var NO_BODY_DIGEST = { response_bytes: null, body_sha256: null };
286
300
  function asRecord(value) {
287
301
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
@@ -485,6 +499,15 @@ async function emitRecord(input, receipt, requestValue, responseValue, sink, dig
485
499
  const res = asRecord(responseValue);
486
500
  const requestedModel = string(req?.model);
487
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
+ }
488
511
  const row = {
489
512
  schema: MPP_FULFILLMENT_RECORD_SCHEMA,
490
513
  at_unix_ms: Date.now(),
@@ -500,14 +523,22 @@ async function emitRecord(input, receipt, requestValue, responseValue, sink, dig
500
523
  receipt_state: receipt.state,
501
524
  receipt_status: string(receipt.value?.status),
502
525
  receipt_method: string(receipt.value?.method),
503
- receipt_reference: string(receipt.value?.reference),
526
+ receipt_reference: reference,
504
527
  receipt_timestamp: string(receipt.value?.timestamp),
505
528
  requested_model: requestedModel,
506
529
  seller_reported_model: sellerReportedModel,
507
530
  seller_reported_model_matches_request: requestedModel && sellerReportedModel ? requestedModel.toLowerCase() === sellerReportedModel.toLowerCase() : null,
508
531
  gate: input.gate ?? null,
509
532
  response_bytes: digest.response_bytes,
510
- body_sha256: digest.body_sha256
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
511
542
  };
512
543
  await sink(row);
513
544
  }
@@ -881,6 +912,7 @@ function createMppFetch(account, base = fetch, opts) {
881
912
  }
882
913
 
883
914
  // src/mpp-solana-fetch.ts
915
+ import { randomUUID } from "crypto";
884
916
  import {
885
917
  assertSpendPolicyAllows as assertSpendPolicyAllows2,
886
918
  SpendPolicyError as SpendPolicyError3
@@ -895,12 +927,46 @@ var CAIP2 = {
895
927
  devnet: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
896
928
  localnet: "solana:localnet"
897
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
+ }
898
963
  var solanaChargeDeps = {
899
964
  load: () => loadOptional("@solana/mpp", SOLANA_MPP_RANGE, () => import("@solana/mpp/client"))
900
965
  };
901
966
  async function createSolanaMppFetch(opts) {
902
967
  assertMppIntegration(opts?.integration, "Solana");
903
968
  const integration = Object.freeze({ ...opts.integration });
969
+ const verification = verificationSettings(opts.receiptVerification, opts.network);
904
970
  const { charge, selectSolanaChargeChallengeFromResponse, resolveStablecoinMint } = await solanaChargeDeps.load();
905
971
  const base = opts.fetch ?? fetch;
906
972
  const usd = /* @__PURE__ */ new Set([...DEFAULT_SOLANA_USD_MINTS, ...opts.usdMints ?? []]);
@@ -997,6 +1063,8 @@ async function createSolanaMppFetch(opts) {
997
1063
  let sessionSpendRecorded = false;
998
1064
  let record;
999
1065
  let payload = "";
1066
+ let signedTransaction = null;
1067
+ let signedAtUnix = null;
1000
1068
  try {
1001
1069
  const method = charge({
1002
1070
  signer: opts.signer,
@@ -1007,6 +1075,8 @@ async function createSolanaMppFetch(opts) {
1007
1075
  if (event.type !== "signed") return;
1008
1076
  signed = true;
1009
1077
  payload = event.transaction;
1078
+ signedTransaction = event.transaction;
1079
+ signedAtUnix = Math.floor(Date.now() / 1e3);
1010
1080
  if (!sessionSpendRecorded) {
1011
1081
  budgetContext?.recordSigned(budgetQuoteMicros);
1012
1082
  sessionSpendRecorded = true;
@@ -1032,12 +1102,17 @@ async function createSolanaMppFetch(opts) {
1032
1102
  });
1033
1103
  }
1034
1104
  }
1105
+ const splits = challenge.request.methodDetails.splits;
1035
1106
  return {
1036
1107
  credential,
1037
1108
  payload,
1038
1109
  record,
1039
1110
  gate: gateDecision,
1040
- payment
1111
+ payment,
1112
+ signedTransaction,
1113
+ signedAtUnix,
1114
+ purchaseId: randomUUID(),
1115
+ hasSplits: Array.isArray(splits) && splits.length > 0
1041
1116
  };
1042
1117
  } catch (err) {
1043
1118
  if (signed) {
@@ -1071,8 +1146,32 @@ async function createSolanaMppFetch(opts) {
1071
1146
  const response = await base(new Request(request, { headers }));
1072
1147
  const paid = currentPaidCall();
1073
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;
1074
1167
  return recordMppFulfillment(
1075
- { 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
+ },
1076
1175
  integration.onFulfillment,
1077
1176
  trackFulfillment
1078
1177
  );
@@ -1146,18 +1245,293 @@ async function fulfillPaidProof(opts) {
1146
1245
  if (!won) throw new ReplayRejectedError(opts.networkId, opts.txSignature, opts.challengeId);
1147
1246
  return opts.upstream();
1148
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
+ }
1149
1517
  export {
1150
1518
  CreditLedger,
1519
+ DEFAULT_RPC_TIMEOUT_MS,
1151
1520
  DEFAULT_SOLANA_USD_MINTS,
1152
1521
  DEFAULT_TEMPO_USD_ASSETS,
1522
+ DEFAULT_TRUSTED_USD_MINTS,
1523
+ InMemoryReceiptReferenceIndex,
1524
+ LANDING_WINDOW_SECONDS,
1153
1525
  MPP_FULFILLMENT_MAX_BODY_BYTES,
1154
1526
  MPP_FULFILLMENT_RECORD_SCHEMA,
1155
1527
  MppFlushTimeoutError,
1156
1528
  MppGateError,
1157
1529
  MppPaidNoCompletionError,
1530
+ RECEIPT_REFERENCE_NOT_EVALUATED,
1158
1531
  ReplayRejectedError,
1159
1532
  SpentProofLedger,
1160
1533
  createMppFetch,
1534
+ createReceiptChainVerifier,
1161
1535
  createSolanaMppFetch,
1162
1536
  creditPaidNoCompletion,
1163
1537
  fulfillPaidProof,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twzrd1111/clawrouter-mpp",
3
- "version": "0.3.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",