@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 +98 -2
- package/dist/index.d.ts +191 -8
- package/dist/index.js +525 -39
- package/package.json +1 -1
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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):
|
|
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<
|
|
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
|
-
|
|
50
|
+
const reference = [fields.reference, err.reference].find(
|
|
51
|
+
(value) => typeof value === "string" && value !== "" && value !== UNKNOWN_REFERENCE
|
|
52
|
+
);
|
|
53
|
+
const { reference: _dropped, ...rest } = fields;
|
|
54
|
+
void _dropped;
|
|
55
|
+
return ledger.recordOnce(reference ? { ...rest, reference } : rest);
|
|
50
56
|
}
|
|
51
57
|
var credits = new CreditLedger();
|
|
52
58
|
function creditFromChallenge(challenge, payer) {
|
|
@@ -160,7 +166,7 @@ async function boundedSnippet(response, timeoutMs = SNIPPET_TIMEOUT_MS) {
|
|
|
160
166
|
}
|
|
161
167
|
}
|
|
162
168
|
function fail(spend, call, status, detail, cause) {
|
|
163
|
-
const reference = call.reference ??
|
|
169
|
+
const reference = call.reference ?? UNKNOWN_REFERENCE;
|
|
164
170
|
const why = status !== void 0 ? `HTTP ${status}` : "request failed";
|
|
165
171
|
if (spend && call.record) {
|
|
166
172
|
spend.annotateRecord(call.record, `signed, no completion (${why}) ref ${reference}`);
|
|
@@ -273,8 +279,24 @@ async function evaluateMppGate(evaluator, challenge) {
|
|
|
273
279
|
}
|
|
274
280
|
|
|
275
281
|
// src/mpp-fulfillment-record.ts
|
|
276
|
-
|
|
282
|
+
import { createHash as createHash2 } from "crypto";
|
|
283
|
+
var MPP_FULFILLMENT_RECORD_SCHEMA = "twzrd.mpp.fulfillment.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
|
|
395
|
-
if (!body) return
|
|
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
|
|
434
|
+
return null;
|
|
407
435
|
}
|
|
408
436
|
chunks.push(value);
|
|
409
437
|
}
|
|
410
|
-
return
|
|
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:
|
|
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) ?
|
|
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,
|
|
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 =
|
|
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
|
-
{
|
|
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 =
|
|
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.
|
|
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",
|