@twzrd1111/clawrouter-mpp 0.3.0 → 0.5.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 +88 -1
- package/dist/index.d.ts +174 -5
- package/dist/index.js +428 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,6 +3,45 @@
|
|
|
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.5.0 from 0.4.0
|
|
7
|
+
|
|
8
|
+
This release adds opt-in Solana Echo purchase binding (#21). Omit `echo` to preserve existing
|
|
9
|
+
behavior. `echo: {}` offers binding and pays unbound when a seller omits or changes the reflected
|
|
10
|
+
ID; `echo: { requireBinding: true }` refuses those challenges before signing.
|
|
11
|
+
|
|
12
|
+
- Each call sends fresh 16-byte buyer entropy as `X-Buyer-Nonce: <32 hex>`. A cooperating seller
|
|
13
|
+
reflects it as `request.externalId: "twzrd:p:<nonce>"`, which the existing SDK writes as a memo.
|
|
14
|
+
- With `receiptVerification` enabled, a finalized payment passing all chain checks can set
|
|
15
|
+
`receipt_reference_purchase_bound: true` only when exactly one SPL Memo (v1 and v2 counted
|
|
16
|
+
together) matches the retained buyer expectation byte for byte. Malformed UTF-8, a leading BOM,
|
|
17
|
+
or multiple memos cannot silently match.
|
|
18
|
+
- Identical complete challenges share one credential and one spend record; binding remains per
|
|
19
|
+
call. The local nonce is not part of the shared-signing cache key.
|
|
20
|
+
- Fulfillment schema stays `twzrd.mpp.fulfillment.v3`; core peer floor stays `>=0.12.309`.
|
|
21
|
+
`response_bytes` and `body_sha256` are existing fields, first shipped in 0.3.0.
|
|
22
|
+
|
|
23
|
+
Seller reflection is a separate integration. This release does not deploy a seller, verify Tempo
|
|
24
|
+
receipts, prove model execution, or supply a durable replay index. The existing in-memory indexes
|
|
25
|
+
forget on restart; cross-worker uniqueness requires caller-supplied durable, atomic storage.
|
|
26
|
+
See the [seller wire contract](https://github.com/twzrd-sol/ClawRouter-1/blob/899295ef627032260dbff363bbb012d446c9ecc6/docs/echo-protocol.md).
|
|
27
|
+
|
|
28
|
+
## Upgrading to 0.4.0 from 0.3.0
|
|
29
|
+
|
|
30
|
+
This release includes the standalone Solana receipt verifier (#16), fulfillment wiring (#18),
|
|
31
|
+
and the per-RPC deadline (#19). Verification is opt-in and does not verify Tempo receipts.
|
|
32
|
+
Purchase binding remains `null/no_buyer_nonce`; Echo and a durable reference index are not supplied
|
|
33
|
+
by this release. The reference index is caller-supplied and its operations have no built-in timeout.
|
|
34
|
+
|
|
35
|
+
- Fulfillment rows now use `twzrd.mpp.fulfillment.v3`. v3 adds eight `receipt_reference_*`
|
|
36
|
+
verification fields (below); every other field is unchanged. Readers must accept the new schema.
|
|
37
|
+
The fields are null unless the Solana rail was given `receiptVerification`, so Tempo rows and
|
|
38
|
+
unconfigured Solana rows carry them as null ("not evaluated", never "clean").
|
|
39
|
+
- `createSolanaMppFetch({ receiptVerification: { verify } })` retains the transaction the buyer
|
|
40
|
+
signed and chain-verifies the seller's receipt reference before writing the row.
|
|
41
|
+
- Each `getTransaction` call made by the verifier has a deadline: `rpcTimeoutMs`, default 10000.
|
|
42
|
+
When it passes, the row records `null/rpc_unavailable` and the `signal` passed to the fetcher
|
|
43
|
+
aborts, so a hung RPC cannot hold the row. Pass that `signal` to `fetch()`.
|
|
44
|
+
|
|
6
45
|
## Upgrading from 0.2.0
|
|
7
46
|
|
|
8
47
|
- Fulfillment rows now use `twzrd.mpp.fulfillment.v2`. Readers must accept the new schema to retain
|
|
@@ -74,12 +113,60 @@ intent match the challenge; it is not an on-chain verification. `seller_reported
|
|
|
74
113
|
compares labels only and does not verify model identity. A failed local write does not change an
|
|
75
114
|
already-paid response; `payFetch.flush()` surfaces persistence failures to the shutdown caller.
|
|
76
115
|
|
|
77
|
-
Rows use schema `twzrd.mpp.fulfillment.
|
|
116
|
+
Rows use schema `twzrd.mpp.fulfillment.v3`. `response_bytes` and `body_sha256` (lowercase hex
|
|
78
117
|
SHA-256) describe the complete response body as the recorder read it: a JSON body read through its
|
|
79
118
|
clone, or an SSE body tapped to its end. Both are `null` when the recorder did not read the body,
|
|
80
119
|
it exceeded 1 MiB, or the stream was cancelled or errored before its end; a partial body is never
|
|
81
120
|
hashed. The digest is of bytes the seller sent and is not a seller attestation.
|
|
82
121
|
|
|
122
|
+
### Receipt reference chain verification (Solana, opt-in)
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { createReceiptChainVerifier, createSolanaMppFetch } from "@twzrd1111/clawrouter-mpp";
|
|
126
|
+
|
|
127
|
+
const verify = createReceiptChainVerifier({
|
|
128
|
+
cluster: "mainnet", // must match the rail's network; a mismatch throws at construction
|
|
129
|
+
getTransaction, // your finalized getTransaction (base64, maxSupportedTransactionVersion 0)
|
|
130
|
+
index, // a durable ReceiptReferenceIndex with an atomic bind
|
|
131
|
+
rpcTimeoutMs: 10_000, // per getTransaction call (the default); pass the given signal to fetch()
|
|
132
|
+
});
|
|
133
|
+
const payFetch = await createSolanaMppFetch({
|
|
134
|
+
signer,
|
|
135
|
+
rpcUrl,
|
|
136
|
+
network: "mainnet",
|
|
137
|
+
spend,
|
|
138
|
+
integration,
|
|
139
|
+
receiptVerification: { verify, finalityWaitMs: 30_000 },
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The rail keeps the base64 wire transaction from the SDK's `signed` event, the buyer clock when it
|
|
144
|
+
fired, and a buyer-generated purchase id (every send of one credential is one purchase). After a
|
|
145
|
+
paid response, the recorder passes the seller's `valid_format` receipt reference with those facts to
|
|
146
|
+
the verifier and writes its result into the row:
|
|
147
|
+
|
|
148
|
+
- `receipt_reference_chain_verified` (`true`, `false`, or `null`), `receipt_reference_finalized_slot`,
|
|
149
|
+
`receipt_reference_mismatch` (why `false`), and `receipt_reference_unverified_reason` (why `null`;
|
|
150
|
+
`verifier_error` when the verifier threw).
|
|
151
|
+
- `receipt_reference_purchase_bound` and its reason. With opt-in `echo: { requireBinding: true }`
|
|
152
|
+
and a cooperating seller, one exactly matching buyer memo can establish binding after chain
|
|
153
|
+
verification. Otherwise it remains unbound; see `docs/echo-protocol.md`. Chain verification alone
|
|
154
|
+
does not mean the payment belongs to this purchase alone.
|
|
155
|
+
- `receipt_reference_local_acceptance` (`accepted`, `refused`, or `null`) and
|
|
156
|
+
`receipt_reference_refusal_reason` (`reference_reused`, `stale_by_local_clock`): this sink's
|
|
157
|
+
policy, which never changes a chain field.
|
|
158
|
+
|
|
159
|
+
Verification never delays or changes the response. It delays only the row and `flush()`.
|
|
160
|
+
`finalityWaitMs` (default 0) re-checks a reference that is not finalized yet every `finalityPollMs`
|
|
161
|
+
(default 2000); with the default, an unfinalized reference is recorded as `null/not_finalized`.
|
|
162
|
+
`finalityWaitMs` is not an RPC timeout: each `getTransaction` call has its own deadline,
|
|
163
|
+
`rpcTimeoutMs` on the verifier (default 10000). When it passes, the fetcher's `signal` aborts and
|
|
164
|
+
the reference is recorded as `null/rpc_unavailable`. A row therefore waits at most about
|
|
165
|
+
`finalityWaitMs + rpcTimeoutMs` on the chain. The reference index has no deadline, so a durable
|
|
166
|
+
index must bound its own calls; `flush({ timeoutMs })` bounds only how long the caller waits.
|
|
167
|
+
No RPC URL ships with the package. `InMemoryReceiptReferenceIndex` forgets on restart and is for
|
|
168
|
+
tests. See `docs/receipt-chain-verify.md` for each check.
|
|
169
|
+
|
|
83
170
|
This candidate requires `@twzrd1111/clawrouter >=0.12.309`, including deferred streaming headers.
|
|
84
171
|
The fork is the release target. Upstream PRs #545 and #546 are independent of this release.
|
|
85
172
|
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,138 @@ 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
|
|
6
|
+
declare const MEMO_PROGRAM_ID = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
|
|
7
|
+
declare const MEMO_V1_PROGRAM_ID = "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo";
|
|
8
|
+
type BindingInstruction = {
|
|
9
|
+
program: string;
|
|
10
|
+
data: Uint8Array;
|
|
11
|
+
};
|
|
12
|
+
type PurchaseBoundOutcome = {
|
|
13
|
+
purchase_bound: boolean | null;
|
|
14
|
+
purchase_bound_reason: "no_buyer_nonce" | "nonce_mismatch" | "chain_unverified" | null;
|
|
15
|
+
};
|
|
16
|
+
/** An expected buyer nonce binds only through one exactly matching, valid UTF-8 SPL Memo. */
|
|
17
|
+
declare function evaluatePurchaseBinding(instructions: readonly BindingInstruction[], expectedExternalId: string | null | undefined): PurchaseBoundOutcome;
|
|
18
|
+
/** A blockhash stays valid for about 150 slots, roughly 60-90 s. */
|
|
19
|
+
declare const LANDING_WINDOW_SECONDS = 90;
|
|
20
|
+
declare const CLUSTER_IDS: {
|
|
21
|
+
readonly mainnet: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
|
|
22
|
+
readonly devnet: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1";
|
|
23
|
+
};
|
|
24
|
+
type SolanaCluster = keyof typeof CLUSTER_IDS;
|
|
25
|
+
type TrustedMint = {
|
|
26
|
+
decimals: number;
|
|
27
|
+
tokenPrograms: readonly string[];
|
|
28
|
+
};
|
|
29
|
+
/** Classic SPL Token USD mints with 6 decimals: USDC mainnet, USDC devnet, USDT mainnet. */
|
|
30
|
+
declare const DEFAULT_TRUSTED_USD_MINTS: Readonly<Record<string, TrustedMint>>;
|
|
31
|
+
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";
|
|
32
|
+
type ChainUnverifiedReason = "rpc_unavailable" | "not_finalized" | "no_fetcher" | "non_solana" | "no_signed_transaction" | "splits_unsupported" | "unnormalized_terms" | "unsupported_message_format"
|
|
33
|
+
/** The verifier itself threw; recorded by the fulfillment hook, never returned by `verify`. */
|
|
34
|
+
| "verifier_error";
|
|
35
|
+
type ReceiptChainVerification = {
|
|
36
|
+
receipt_reference_chain_verified: boolean | null;
|
|
37
|
+
receipt_reference_finalized_slot: number | null;
|
|
38
|
+
receipt_reference_mismatch: ChainMismatch | null;
|
|
39
|
+
receipt_reference_unverified_reason: ChainUnverifiedReason | null;
|
|
40
|
+
receipt_reference_purchase_bound: boolean | null;
|
|
41
|
+
receipt_reference_purchase_bound_reason: "nonce_mismatch" | "no_buyer_nonce" | "chain_unverified" | null;
|
|
42
|
+
receipt_reference_local_acceptance: "accepted" | "refused" | null;
|
|
43
|
+
receipt_reference_refusal_reason: "reference_reused" | "stale_by_local_clock" | null;
|
|
44
|
+
};
|
|
45
|
+
/** Compiled inner instruction as returned by getTransaction with `encoding: "base64"`. */
|
|
46
|
+
type RpcInnerInstruction = {
|
|
47
|
+
programIdIndex: number;
|
|
48
|
+
accounts: number[];
|
|
49
|
+
data: string;
|
|
50
|
+
};
|
|
51
|
+
/** The subset of a `getTransaction` (`encoding: "base64"`, finalized) result this verifier reads. */
|
|
52
|
+
type RpcTransaction = {
|
|
53
|
+
slot: number;
|
|
54
|
+
blockTime: number | null;
|
|
55
|
+
meta: {
|
|
56
|
+
err: unknown;
|
|
57
|
+
innerInstructions?: {
|
|
58
|
+
index: number;
|
|
59
|
+
instructions: RpcInnerInstruction[];
|
|
60
|
+
}[] | null;
|
|
61
|
+
} | null;
|
|
62
|
+
transaction: [string, "base64"] | readonly [string, string];
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Must call getTransaction(signature, {commitment: "finalized", encoding: "base64",
|
|
66
|
+
* maxSupportedTransactionVersion: 0}) on the configured cluster and return its result (null when
|
|
67
|
+
* not found or not finalized). Throw on transport failure. `signal` aborts when the verifier's
|
|
68
|
+
* `rpcTimeoutMs` deadline passes; pass it to fetch() so the request is cancelled, not abandoned.
|
|
69
|
+
*/
|
|
70
|
+
type GetFinalizedTransaction = (signature: string, options?: {
|
|
71
|
+
signal?: AbortSignal;
|
|
72
|
+
}) => Promise<RpcTransaction | null>;
|
|
73
|
+
/**
|
|
74
|
+
* Durable, atomic reference -> purchase_id index. `bind` returns true when this purchase owns the
|
|
75
|
+
* reference (newly bound or already bound to the same purchase) and false when another purchase
|
|
76
|
+
* holds it; exactly one concurrent binder may win. `owner` reads without binding.
|
|
77
|
+
*/
|
|
78
|
+
type ReceiptReferenceIndex = {
|
|
79
|
+
bind(reference: string, purchaseId: string): Promise<boolean>;
|
|
80
|
+
owner(reference: string): Promise<string | undefined>;
|
|
81
|
+
};
|
|
82
|
+
/** In-memory index for tests and single-process demos. It forgets on restart, so it is not durable. */
|
|
83
|
+
declare class InMemoryReceiptReferenceIndex implements ReceiptReferenceIndex {
|
|
84
|
+
#private;
|
|
85
|
+
bind(reference: string, purchaseId: string): Promise<boolean>;
|
|
86
|
+
owner(reference: string): Promise<string | undefined>;
|
|
87
|
+
}
|
|
88
|
+
type ReceiptChainVerifierConfig = {
|
|
89
|
+
cluster: SolanaCluster;
|
|
90
|
+
getTransaction?: GetFinalizedTransaction;
|
|
91
|
+
index: ReceiptReferenceIndex;
|
|
92
|
+
/** Trusted mint -> decimals and token programs. Defaults to DEFAULT_TRUSTED_USD_MINTS. */
|
|
93
|
+
trustedMints?: Readonly<Record<string, TrustedMint>>;
|
|
94
|
+
/** Default 600. Must exceed maxClockSkewSeconds + LANDING_WINDOW_SECONDS. */
|
|
95
|
+
maxAgeSeconds?: number;
|
|
96
|
+
/** Default 60. */
|
|
97
|
+
maxClockSkewSeconds?: number;
|
|
98
|
+
/**
|
|
99
|
+
* Deadline for one getTransaction call, in ms. Default 10000. When it passes, the fetcher's
|
|
100
|
+
* signal aborts and the reference is recorded as null/rpc_unavailable. The reference index is
|
|
101
|
+
* not covered: a durable index must bound its own calls.
|
|
102
|
+
*/
|
|
103
|
+
rpcTimeoutMs?: number;
|
|
104
|
+
};
|
|
105
|
+
/** Default deadline for one getTransaction call. */
|
|
106
|
+
declare const DEFAULT_RPC_TIMEOUT_MS = 10000;
|
|
107
|
+
type ReceiptChainVerifyInput = {
|
|
108
|
+
/** Seller's receipt reference: the settled transaction signature. */
|
|
109
|
+
reference: string;
|
|
110
|
+
/** MPP payment method; only "solana" is verified. */
|
|
111
|
+
method: string;
|
|
112
|
+
/** Challenge terms as the seller sent them. */
|
|
113
|
+
network: string | null;
|
|
114
|
+
asset: string | null;
|
|
115
|
+
payTo: string | null;
|
|
116
|
+
amountAtomic: string | null;
|
|
117
|
+
/** True when the challenge carried split recipients. */
|
|
118
|
+
hasSplits?: boolean;
|
|
119
|
+
/** The buyer's own address. */
|
|
120
|
+
payer: string;
|
|
121
|
+
/** Base64 wire transaction from the rail's `signed` event, or null when not retained. */
|
|
122
|
+
signedTransaction: string | null;
|
|
123
|
+
/** Buyer clock (unix seconds) when the signed event fired. */
|
|
124
|
+
signedAtUnix: number | null;
|
|
125
|
+
/** Buyer-generated id of the logical purchase (not the seller's challenge id). */
|
|
126
|
+
purchaseId: string;
|
|
127
|
+
/** Buyer-generated Echo expectation, retained only after exact pre-sign reflection. */
|
|
128
|
+
expectedExternalId?: string | null;
|
|
129
|
+
};
|
|
130
|
+
/** A built verifier. `cluster` lets a rail refuse a verifier configured for another cluster. */
|
|
131
|
+
type ReceiptChainVerify = ((input: ReceiptChainVerifyInput) => Promise<ReceiptChainVerification>) & {
|
|
132
|
+
readonly cluster: SolanaCluster;
|
|
133
|
+
};
|
|
134
|
+
/** Build a verifier. Throws when the age policy cannot hold an honest payment. */
|
|
135
|
+
declare function createReceiptChainVerifier(config: ReceiptChainVerifierConfig): ReceiptChainVerify;
|
|
136
|
+
|
|
137
|
+
declare const MPP_FULFILLMENT_RECORD_SCHEMA = "twzrd.mpp.fulfillment.v3";
|
|
7
138
|
declare const MPP_FULFILLMENT_MAX_BODY_BYTES: number;
|
|
8
139
|
type MppPaymentFacts = {
|
|
9
140
|
challengeId: string;
|
|
@@ -14,7 +145,14 @@ type MppPaymentFacts = {
|
|
|
14
145
|
asset: string | null;
|
|
15
146
|
amountAtomic: string | null;
|
|
16
147
|
};
|
|
17
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Chain verification of the seller's receipt reference. Every field is null when no verifier ran
|
|
150
|
+
* (none configured, a non-Solana rail, or no valid-format receipt): null means "not evaluated",
|
|
151
|
+
* never "clean". See docs/receipt-chain-verify.md for each field.
|
|
152
|
+
*/
|
|
153
|
+
type MppReceiptReferenceVerification = ReceiptChainVerification;
|
|
154
|
+
declare const RECEIPT_REFERENCE_NOT_EVALUATED: Readonly<MppReceiptReferenceVerification>;
|
|
155
|
+
type MppFulfillmentRecord = MppReceiptReferenceVerification & {
|
|
18
156
|
schema: typeof MPP_FULFILLMENT_RECORD_SCHEMA;
|
|
19
157
|
at_unix_ms: number;
|
|
20
158
|
resource_origin: string | null;
|
|
@@ -29,7 +167,10 @@ type MppFulfillmentRecord = {
|
|
|
29
167
|
receipt_state: "valid_format" | "missing" | "invalid_format" | "unavailable";
|
|
30
168
|
receipt_status: string | null;
|
|
31
169
|
receipt_method: string | null;
|
|
32
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* Seller-supplied MPP receipt reference (EVM hash or Solana signature). Chain-verified only when
|
|
172
|
+
* the rail was given a verifier; see the receipt_reference_* verification fields.
|
|
173
|
+
*/
|
|
33
174
|
receipt_reference: string | null;
|
|
34
175
|
receipt_timestamp: string | null;
|
|
35
176
|
requested_model: string | null;
|
|
@@ -145,6 +286,15 @@ declare function createMppFetch(account: Account, base: typeof fetch | undefined
|
|
|
145
286
|
|
|
146
287
|
declare const DEFAULT_SOLANA_USD_MINTS: ReadonlySet<string>;
|
|
147
288
|
type SolanaNetwork = "mainnet" | "devnet" | "localnet";
|
|
289
|
+
type EchoPolicy = {
|
|
290
|
+
requireBinding?: boolean;
|
|
291
|
+
};
|
|
292
|
+
type EchoBindingContext = {
|
|
293
|
+
buyerNonce: string;
|
|
294
|
+
expectedExternalId: string;
|
|
295
|
+
};
|
|
296
|
+
declare function createEchoContext(): EchoBindingContext;
|
|
297
|
+
declare function assertEchoPreSign(challengeExternalId: unknown, context: EchoBindingContext, policy?: EchoPolicy): "bound" | "unbound";
|
|
148
298
|
type SolanaMppFetchOptions = {
|
|
149
299
|
signer: KeyPairSigner;
|
|
150
300
|
rpcUrl: string;
|
|
@@ -155,6 +305,25 @@ type SolanaMppFetchOptions = {
|
|
|
155
305
|
integration: MppIntegration;
|
|
156
306
|
/** Injectable transport; defaults to fetch. The SDK's RPC transport is separate. */
|
|
157
307
|
fetch?: typeof fetch;
|
|
308
|
+
/**
|
|
309
|
+
* Chain-verify each seller receipt reference before its fulfillment row is written. Off when
|
|
310
|
+
* omitted (the row's receipt_reference_* verification fields stay null). Never delays the response.
|
|
311
|
+
*/
|
|
312
|
+
receiptVerification?: SolanaReceiptVerificationOptions;
|
|
313
|
+
/** Opt in to buyer nonce reflection. Omit to preserve existing seller externalId behavior. */
|
|
314
|
+
echo?: EchoPolicy;
|
|
315
|
+
};
|
|
316
|
+
type SolanaReceiptVerificationOptions = {
|
|
317
|
+
/** From createReceiptChainVerifier, built for the same cluster as `network`. */
|
|
318
|
+
verify: ReceiptChainVerify;
|
|
319
|
+
/**
|
|
320
|
+
* How long to keep re-checking a reference that is not finalized yet, in ms. Default 0: one check,
|
|
321
|
+
* and a not-yet-finalized reference is recorded as null/not_finalized. This delays only the row
|
|
322
|
+
* (and flush()), never the response.
|
|
323
|
+
*/
|
|
324
|
+
finalityWaitMs?: number;
|
|
325
|
+
/** Delay between finality re-checks, in ms. Default 2000. */
|
|
326
|
+
finalityPollMs?: number;
|
|
158
327
|
};
|
|
159
328
|
declare function createSolanaMppFetch(opts: SolanaMppFetchOptions): Promise<MppPayFetch>;
|
|
160
329
|
|
|
@@ -245,5 +414,5 @@ declare function fulfillPaidProof<T>(opts: {
|
|
|
245
414
|
upstream: () => Promise<T>;
|
|
246
415
|
}): Promise<T>;
|
|
247
416
|
|
|
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 };
|
|
417
|
+
export { CreditLedger, DEFAULT_RPC_TIMEOUT_MS, DEFAULT_SOLANA_USD_MINTS, DEFAULT_TEMPO_USD_ASSETS, DEFAULT_TRUSTED_USD_MINTS, InMemoryReceiptReferenceIndex, LANDING_WINDOW_SECONDS, MEMO_PROGRAM_ID, MEMO_V1_PROGRAM_ID, MPP_FULFILLMENT_MAX_BODY_BYTES, MPP_FULFILLMENT_RECORD_SCHEMA, MppFlushTimeoutError, MppGateError, MppPaidNoCompletionError, RECEIPT_REFERENCE_NOT_EVALUATED, ReplayRejectedError, SpentProofLedger, assertEchoPreSign, createEchoContext, createMppFetch, createReceiptChainVerifier, createSolanaMppFetch, creditPaidNoCompletion, evaluatePurchaseBinding, fulfillPaidProof, quoteFromChallenge, readCredits, readPaidCallEvidence };
|
|
418
|
+
export type { ChainMismatch, ChainUnverifiedReason, CreditEntry, EchoBindingContext, EchoPolicy, GetFinalizedTransaction, MppBodyDigest, MppFetchOptions, MppFlushOptions, MppFulfillmentRecord, MppFulfillmentSink, MppGateChallenge, MppGateDecision, MppGateEvaluator, MppIntegration, MppPayFetch, MppPaymentFacts, MppReceiptReferenceVerification, PurchaseBoundOutcome, 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.
|
|
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:
|
|
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 { randomBytes, randomUUID } from "crypto";
|
|
884
916
|
import {
|
|
885
917
|
assertSpendPolicyAllows as assertSpendPolicyAllows2,
|
|
886
918
|
SpendPolicyError as SpendPolicyError3
|
|
@@ -890,17 +922,68 @@ var DEFAULT_SOLANA_USD_MINTS = /* @__PURE__ */ new Set([
|
|
|
890
922
|
"4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
|
|
891
923
|
"Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
|
|
892
924
|
]);
|
|
925
|
+
function createEchoContext() {
|
|
926
|
+
const buyerNonce = randomBytes(16).toString("hex");
|
|
927
|
+
return { buyerNonce, expectedExternalId: `twzrd:p:${buyerNonce}` };
|
|
928
|
+
}
|
|
929
|
+
function assertEchoPreSign(challengeExternalId, context, policy = {}) {
|
|
930
|
+
if (challengeExternalId === void 0 || challengeExternalId === null) {
|
|
931
|
+
if (policy.requireBinding)
|
|
932
|
+
throw new SpendPolicyError3("REJECTED_UNBOUND_CHALLENGE: Seller failed to echo buyer nonce");
|
|
933
|
+
return "unbound";
|
|
934
|
+
}
|
|
935
|
+
if (challengeExternalId !== context.expectedExternalId) {
|
|
936
|
+
if (policy.requireBinding)
|
|
937
|
+
throw new SpendPolicyError3("SECURITY_VIOLATION: Challenge externalId mismatch");
|
|
938
|
+
return "unbound";
|
|
939
|
+
}
|
|
940
|
+
return "bound";
|
|
941
|
+
}
|
|
893
942
|
var CAIP2 = {
|
|
894
943
|
mainnet: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
|
|
895
944
|
devnet: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
|
|
896
945
|
localnet: "solana:localnet"
|
|
897
946
|
};
|
|
947
|
+
var MAX_TIMER_MS2 = 2147483647;
|
|
948
|
+
function verificationSettings(options, network) {
|
|
949
|
+
if (options === void 0) return void 0;
|
|
950
|
+
if (typeof options?.verify !== "function") {
|
|
951
|
+
throw new TypeError("receiptVerification.verify must come from createReceiptChainVerifier");
|
|
952
|
+
}
|
|
953
|
+
if (options.verify.cluster !== network) {
|
|
954
|
+
throw new TypeError(
|
|
955
|
+
`receiptVerification is built for ${String(options.verify.cluster)}, but the rail network is ${network}`
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
const waitMs = options.finalityWaitMs ?? 0;
|
|
959
|
+
const pollMs = options.finalityPollMs ?? 2e3;
|
|
960
|
+
for (const [name, value, min] of [
|
|
961
|
+
["finalityWaitMs", waitMs, 0],
|
|
962
|
+
["finalityPollMs", pollMs, 1]
|
|
963
|
+
]) {
|
|
964
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > MAX_TIMER_MS2)
|
|
965
|
+
throw new TypeError(
|
|
966
|
+
`receiptVerification.${name} must be a number from ${min} to ${MAX_TIMER_MS2}`
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
return { verify: options.verify, waitMs, pollMs };
|
|
970
|
+
}
|
|
971
|
+
async function verifyUntilFinal(verify, waitMs, pollMs) {
|
|
972
|
+
const deadline = Date.now() + waitMs;
|
|
973
|
+
for (; ; ) {
|
|
974
|
+
const result = await verify();
|
|
975
|
+
if (result.receipt_reference_unverified_reason !== "not_finalized") return result;
|
|
976
|
+
if (Date.now() + pollMs > deadline) return result;
|
|
977
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
978
|
+
}
|
|
979
|
+
}
|
|
898
980
|
var solanaChargeDeps = {
|
|
899
981
|
load: () => loadOptional("@solana/mpp", SOLANA_MPP_RANGE, () => import("@solana/mpp/client"))
|
|
900
982
|
};
|
|
901
983
|
async function createSolanaMppFetch(opts) {
|
|
902
984
|
assertMppIntegration(opts?.integration, "Solana");
|
|
903
985
|
const integration = Object.freeze({ ...opts.integration });
|
|
986
|
+
const verification = verificationSettings(opts.receiptVerification, opts.network);
|
|
904
987
|
const { charge, selectSolanaChargeChallengeFromResponse, resolveStablecoinMint } = await solanaChargeDeps.load();
|
|
905
988
|
const base = opts.fetch ?? fetch;
|
|
906
989
|
const usd = /* @__PURE__ */ new Set([...DEFAULT_SOLANA_USD_MINTS, ...opts.usdMints ?? []]);
|
|
@@ -931,6 +1014,8 @@ async function createSolanaMppFetch(opts) {
|
|
|
931
1014
|
opts.spend,
|
|
932
1015
|
async () => {
|
|
933
1016
|
const request = new Request(input, cleanInit);
|
|
1017
|
+
const echoContext = opts.echo === void 0 ? void 0 : createEchoContext();
|
|
1018
|
+
if (echoContext) request.headers.set("X-Buyer-Nonce", echoContext.buyerNonce);
|
|
934
1019
|
const requestForRecord = request.clone();
|
|
935
1020
|
const probe = await base(request.clone());
|
|
936
1021
|
if (probe.status !== 402) return probe;
|
|
@@ -958,6 +1043,7 @@ async function createSolanaMppFetch(opts) {
|
|
|
958
1043
|
);
|
|
959
1044
|
const payment = paymentFactsFromMppChallenge(challenge);
|
|
960
1045
|
markPaymentChallenge(payment);
|
|
1046
|
+
const expectedExternalId = echoContext && assertEchoPreSign(challenge.request.externalId, echoContext, opts.echo) === "bound" ? echoContext.expectedExternalId : null;
|
|
961
1047
|
const key = JSON.stringify([
|
|
962
1048
|
challenge.id,
|
|
963
1049
|
challenge.realm,
|
|
@@ -997,6 +1083,8 @@ async function createSolanaMppFetch(opts) {
|
|
|
997
1083
|
let sessionSpendRecorded = false;
|
|
998
1084
|
let record;
|
|
999
1085
|
let payload = "";
|
|
1086
|
+
let signedTransaction = null;
|
|
1087
|
+
let signedAtUnix = null;
|
|
1000
1088
|
try {
|
|
1001
1089
|
const method = charge({
|
|
1002
1090
|
signer: opts.signer,
|
|
@@ -1007,6 +1095,8 @@ async function createSolanaMppFetch(opts) {
|
|
|
1007
1095
|
if (event.type !== "signed") return;
|
|
1008
1096
|
signed = true;
|
|
1009
1097
|
payload = event.transaction;
|
|
1098
|
+
signedTransaction = event.transaction;
|
|
1099
|
+
signedAtUnix = Math.floor(Date.now() / 1e3);
|
|
1010
1100
|
if (!sessionSpendRecorded) {
|
|
1011
1101
|
budgetContext?.recordSigned(budgetQuoteMicros);
|
|
1012
1102
|
sessionSpendRecorded = true;
|
|
@@ -1032,12 +1122,18 @@ async function createSolanaMppFetch(opts) {
|
|
|
1032
1122
|
});
|
|
1033
1123
|
}
|
|
1034
1124
|
}
|
|
1125
|
+
const splits = challenge.request.methodDetails.splits;
|
|
1035
1126
|
return {
|
|
1036
1127
|
credential,
|
|
1037
1128
|
payload,
|
|
1038
1129
|
record,
|
|
1039
1130
|
gate: gateDecision,
|
|
1040
|
-
payment
|
|
1131
|
+
payment,
|
|
1132
|
+
signedTransaction,
|
|
1133
|
+
signedAtUnix,
|
|
1134
|
+
purchaseId: randomUUID(),
|
|
1135
|
+
hasSplits: Array.isArray(splits) && splits.length > 0,
|
|
1136
|
+
expectedExternalId
|
|
1041
1137
|
};
|
|
1042
1138
|
} catch (err) {
|
|
1043
1139
|
if (signed) {
|
|
@@ -1071,8 +1167,34 @@ async function createSolanaMppFetch(opts) {
|
|
|
1071
1167
|
const response = await base(new Request(request, { headers }));
|
|
1072
1168
|
const paid = currentPaidCall();
|
|
1073
1169
|
if (response.status !== 402 && paid?.signed && paid.payment) {
|
|
1170
|
+
const terms = paid.payment;
|
|
1171
|
+
const verifyReceiptReference = verification ? (reference) => verifyUntilFinal(
|
|
1172
|
+
() => verification.verify({
|
|
1173
|
+
reference,
|
|
1174
|
+
method: terms.method,
|
|
1175
|
+
network: terms.network,
|
|
1176
|
+
asset: terms.asset,
|
|
1177
|
+
payTo: terms.payTo,
|
|
1178
|
+
amountAtomic: terms.amountAtomic,
|
|
1179
|
+
hasSplits: result.hasSplits,
|
|
1180
|
+
payer: opts.signer.address,
|
|
1181
|
+
signedTransaction: result.signedTransaction,
|
|
1182
|
+
signedAtUnix: result.signedAtUnix,
|
|
1183
|
+
purchaseId: result.purchaseId,
|
|
1184
|
+
// Binding belongs to this call, not merely a cached credential.
|
|
1185
|
+
expectedExternalId: result.expectedExternalId === echoContext?.expectedExternalId ? result.expectedExternalId : null
|
|
1186
|
+
}),
|
|
1187
|
+
verification.waitMs,
|
|
1188
|
+
verification.pollMs
|
|
1189
|
+
) : void 0;
|
|
1074
1190
|
return recordMppFulfillment(
|
|
1075
|
-
{
|
|
1191
|
+
{
|
|
1192
|
+
input: requestForRecord,
|
|
1193
|
+
response,
|
|
1194
|
+
payment: terms,
|
|
1195
|
+
gate: paid.gate,
|
|
1196
|
+
verifyReceiptReference
|
|
1197
|
+
},
|
|
1076
1198
|
integration.onFulfillment,
|
|
1077
1199
|
trackFulfillment
|
|
1078
1200
|
);
|
|
@@ -1146,20 +1268,321 @@ async function fulfillPaidProof(opts) {
|
|
|
1146
1268
|
if (!won) throw new ReplayRejectedError(opts.networkId, opts.txSignature, opts.challengeId);
|
|
1147
1269
|
return opts.upstream();
|
|
1148
1270
|
}
|
|
1271
|
+
|
|
1272
|
+
// src/receipt-chain-verify.ts
|
|
1273
|
+
var SOLANA_KIT_RANGE = "~6.10.0";
|
|
1274
|
+
var MEMO_PROGRAM_ID = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
|
|
1275
|
+
var MEMO_V1_PROGRAM_ID = "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo";
|
|
1276
|
+
var MEMO_PROGRAMS = /* @__PURE__ */ new Set([MEMO_PROGRAM_ID, MEMO_V1_PROGRAM_ID]);
|
|
1277
|
+
function evaluatePurchaseBinding(instructions, expectedExternalId) {
|
|
1278
|
+
const unbound = { purchase_bound: null, purchase_bound_reason: "no_buyer_nonce" };
|
|
1279
|
+
const mismatch = { purchase_bound: false, purchase_bound_reason: "nonce_mismatch" };
|
|
1280
|
+
if (!expectedExternalId) return unbound;
|
|
1281
|
+
const memos = instructions.filter((ix) => MEMO_PROGRAMS.has(ix.program));
|
|
1282
|
+
if (memos.length === 0) return unbound;
|
|
1283
|
+
if (memos.length !== 1) return mismatch;
|
|
1284
|
+
try {
|
|
1285
|
+
new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(memos[0].data);
|
|
1286
|
+
} catch {
|
|
1287
|
+
return mismatch;
|
|
1288
|
+
}
|
|
1289
|
+
return bytesEqual(memos[0].data, new TextEncoder().encode(expectedExternalId)) ? { purchase_bound: true, purchase_bound_reason: null } : mismatch;
|
|
1290
|
+
}
|
|
1291
|
+
var TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
|
|
1292
|
+
var TOKEN_2022_PROGRAM = "TokenzQdBNbLqP7VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
|
|
1293
|
+
var ASSOCIATED_TOKEN_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
|
|
1294
|
+
var KNOWN_TOKEN_PROGRAMS = /* @__PURE__ */ new Set([TOKEN_PROGRAM, TOKEN_2022_PROGRAM]);
|
|
1295
|
+
var IX_TRANSFER = 3;
|
|
1296
|
+
var IX_TRANSFER_CHECKED = 12;
|
|
1297
|
+
var LANDING_WINDOW_SECONDS = 90;
|
|
1298
|
+
var CLUSTER_IDS = {
|
|
1299
|
+
mainnet: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
|
|
1300
|
+
devnet: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"
|
|
1301
|
+
};
|
|
1302
|
+
var SYMBOL_MINTS = {
|
|
1303
|
+
mainnet: { USDC: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" },
|
|
1304
|
+
devnet: { USDC: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" }
|
|
1305
|
+
};
|
|
1306
|
+
var DEFAULT_TRUSTED_USD_MINTS = Object.freeze({
|
|
1307
|
+
EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: { decimals: 6, tokenPrograms: [TOKEN_PROGRAM] },
|
|
1308
|
+
"4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU": { decimals: 6, tokenPrograms: [TOKEN_PROGRAM] },
|
|
1309
|
+
Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB: { decimals: 6, tokenPrograms: [TOKEN_PROGRAM] }
|
|
1310
|
+
});
|
|
1311
|
+
var InMemoryReceiptReferenceIndex = class {
|
|
1312
|
+
#owners = /* @__PURE__ */ new Map();
|
|
1313
|
+
async bind(reference, purchaseId) {
|
|
1314
|
+
const current = this.#owners.get(reference);
|
|
1315
|
+
if (current === void 0) {
|
|
1316
|
+
this.#owners.set(reference, purchaseId);
|
|
1317
|
+
return true;
|
|
1318
|
+
}
|
|
1319
|
+
return current === purchaseId;
|
|
1320
|
+
}
|
|
1321
|
+
async owner(reference) {
|
|
1322
|
+
return this.#owners.get(reference);
|
|
1323
|
+
}
|
|
1324
|
+
};
|
|
1325
|
+
var DEFAULT_RPC_TIMEOUT_MS = 1e4;
|
|
1326
|
+
var MAX_TIMER_MS3 = 2147483647;
|
|
1327
|
+
var loadKit = () => loadOptional("@solana/kit", SOLANA_KIT_RANGE, () => import("@solana/kit"));
|
|
1328
|
+
function bytesEqual(a, b) {
|
|
1329
|
+
if (a.length !== b.length) return false;
|
|
1330
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
1331
|
+
return true;
|
|
1332
|
+
}
|
|
1333
|
+
function readU64(data, offset) {
|
|
1334
|
+
let value = 0n;
|
|
1335
|
+
for (let i = 7; i >= 0; i--) value = value << 8n | BigInt(data[offset + i]);
|
|
1336
|
+
return value;
|
|
1337
|
+
}
|
|
1338
|
+
function clusterOf(network) {
|
|
1339
|
+
if (network === "mainnet" || network === "mainnet-beta" || network === CLUSTER_IDS.mainnet)
|
|
1340
|
+
return "mainnet";
|
|
1341
|
+
if (network === "devnet" || network === CLUSTER_IDS.devnet) return "devnet";
|
|
1342
|
+
return void 0;
|
|
1343
|
+
}
|
|
1344
|
+
function createReceiptChainVerifier(config) {
|
|
1345
|
+
const maxAge = config.maxAgeSeconds ?? 600;
|
|
1346
|
+
const skew = config.maxClockSkewSeconds ?? 60;
|
|
1347
|
+
if (!Number.isFinite(maxAge) || !Number.isFinite(skew) || skew < 0) {
|
|
1348
|
+
throw new TypeError("maxAgeSeconds and maxClockSkewSeconds must be finite; skew must be >= 0");
|
|
1349
|
+
}
|
|
1350
|
+
if (maxAge <= skew + LANDING_WINDOW_SECONDS) {
|
|
1351
|
+
throw new RangeError(
|
|
1352
|
+
`maxAgeSeconds (${maxAge}) must exceed maxClockSkewSeconds (${skew}) + the ${LANDING_WINDOW_SECONDS} s landing window`
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
if (!config.index) throw new TypeError("a ReceiptReferenceIndex is required");
|
|
1356
|
+
const rpcTimeoutMs = config.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS;
|
|
1357
|
+
if (typeof rpcTimeoutMs !== "number" || !Number.isFinite(rpcTimeoutMs) || rpcTimeoutMs < 1 || rpcTimeoutMs > MAX_TIMER_MS3) {
|
|
1358
|
+
throw new TypeError(`rpcTimeoutMs must be a number from 1 to ${MAX_TIMER_MS3}`);
|
|
1359
|
+
}
|
|
1360
|
+
const trusted = config.trustedMints ?? DEFAULT_TRUSTED_USD_MINTS;
|
|
1361
|
+
const cluster = config.cluster;
|
|
1362
|
+
if (!(cluster in CLUSTER_IDS)) throw new TypeError(`unknown cluster ${String(cluster)}`);
|
|
1363
|
+
async function fetchWithDeadline(getTransaction, reference) {
|
|
1364
|
+
const controller = new AbortController();
|
|
1365
|
+
let timer;
|
|
1366
|
+
const expired = new Promise((_, reject) => {
|
|
1367
|
+
timer = setTimeout(() => {
|
|
1368
|
+
const reason = new Error(`getTransaction exceeded rpcTimeoutMs (${rpcTimeoutMs} ms)`);
|
|
1369
|
+
controller.abort(reason);
|
|
1370
|
+
reject(reason);
|
|
1371
|
+
}, rpcTimeoutMs);
|
|
1372
|
+
});
|
|
1373
|
+
try {
|
|
1374
|
+
return await Promise.race([
|
|
1375
|
+
getTransaction(reference, { signal: controller.signal }),
|
|
1376
|
+
expired
|
|
1377
|
+
]);
|
|
1378
|
+
} finally {
|
|
1379
|
+
clearTimeout(timer);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
async function verify(input) {
|
|
1383
|
+
const chain = await chainFacts(input);
|
|
1384
|
+
const verified = chain.receipt_reference_chain_verified;
|
|
1385
|
+
const binding = verified === true ? evaluatePurchaseBinding(chain.instructions ?? [], input.expectedExternalId) : { purchase_bound: null, purchase_bound_reason: "chain_unverified" };
|
|
1386
|
+
const purchase = {
|
|
1387
|
+
receipt_reference_purchase_bound: binding.purchase_bound,
|
|
1388
|
+
receipt_reference_purchase_bound_reason: binding.purchase_bound_reason
|
|
1389
|
+
};
|
|
1390
|
+
let refusal = null;
|
|
1391
|
+
if (chain.blockTime !== null && input.signedAtUnix !== null && Number.isFinite(input.signedAtUnix) && input.signedAtUnix - chain.blockTime > maxAge) {
|
|
1392
|
+
refusal = "stale_by_local_clock";
|
|
1393
|
+
}
|
|
1394
|
+
if (verified === true && refusal === null) {
|
|
1395
|
+
const owns = await config.index.bind(input.reference, input.purchaseId);
|
|
1396
|
+
if (!owns) refusal = "reference_reused";
|
|
1397
|
+
} else {
|
|
1398
|
+
const owner = await config.index.owner(input.reference);
|
|
1399
|
+
if (owner !== void 0 && owner !== input.purchaseId) refusal = "reference_reused";
|
|
1400
|
+
}
|
|
1401
|
+
const acceptance = refusal ? "refused" : verified === true ? "accepted" : null;
|
|
1402
|
+
return {
|
|
1403
|
+
receipt_reference_chain_verified: verified,
|
|
1404
|
+
receipt_reference_finalized_slot: chain.receipt_reference_finalized_slot,
|
|
1405
|
+
receipt_reference_mismatch: chain.receipt_reference_mismatch,
|
|
1406
|
+
receipt_reference_unverified_reason: chain.receipt_reference_unverified_reason,
|
|
1407
|
+
...purchase,
|
|
1408
|
+
receipt_reference_local_acceptance: acceptance,
|
|
1409
|
+
receipt_reference_refusal_reason: refusal
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
function result(verified, code, slot = null, blockTime = null) {
|
|
1413
|
+
return {
|
|
1414
|
+
receipt_reference_chain_verified: verified,
|
|
1415
|
+
receipt_reference_finalized_slot: slot,
|
|
1416
|
+
receipt_reference_mismatch: verified === false ? code : null,
|
|
1417
|
+
receipt_reference_unverified_reason: verified === null ? code : null,
|
|
1418
|
+
blockTime
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
async function chainFacts(input) {
|
|
1422
|
+
if (input.method !== "solana") return result(null, "non_solana");
|
|
1423
|
+
const kit = await loadKit();
|
|
1424
|
+
const base58 = kit.getBase58Encoder();
|
|
1425
|
+
let referenceBytes;
|
|
1426
|
+
try {
|
|
1427
|
+
referenceBytes = new Uint8Array(base58.encode(input.reference));
|
|
1428
|
+
} catch {
|
|
1429
|
+
return result(false, "bad_reference_format");
|
|
1430
|
+
}
|
|
1431
|
+
if (referenceBytes.length !== 64) return result(false, "bad_reference_format");
|
|
1432
|
+
const challengeCluster = input.network ? clusterOf(input.network) : void 0;
|
|
1433
|
+
if (challengeCluster !== cluster) return result(false, "wrong_network");
|
|
1434
|
+
if (input.hasSplits) return result(null, "splits_unsupported");
|
|
1435
|
+
if (!input.signedTransaction) return result(null, "no_signed_transaction");
|
|
1436
|
+
const symbols = SYMBOL_MINTS[cluster];
|
|
1437
|
+
const asset = input.asset ? Object.prototype.hasOwnProperty.call(symbols, input.asset) ? symbols[input.asset] : input.asset : null;
|
|
1438
|
+
if (!asset || !kit.isAddress(asset)) return result(null, "unnormalized_terms");
|
|
1439
|
+
if (!input.payTo || !kit.isAddress(input.payTo)) return result(null, "unnormalized_terms");
|
|
1440
|
+
if (!kit.isAddress(input.payer)) return result(null, "unnormalized_terms");
|
|
1441
|
+
if (!input.amountAtomic || !/^\d+$/.test(input.amountAtomic))
|
|
1442
|
+
return result(null, "unnormalized_terms");
|
|
1443
|
+
let retained;
|
|
1444
|
+
try {
|
|
1445
|
+
retained = decodeWire(kit, input.signedTransaction);
|
|
1446
|
+
} catch {
|
|
1447
|
+
return result(null, "no_signed_transaction");
|
|
1448
|
+
}
|
|
1449
|
+
if (!config.getTransaction) return result(null, "no_fetcher");
|
|
1450
|
+
let tx;
|
|
1451
|
+
try {
|
|
1452
|
+
tx = await fetchWithDeadline(config.getTransaction, input.reference);
|
|
1453
|
+
} catch {
|
|
1454
|
+
return result(null, "rpc_unavailable");
|
|
1455
|
+
}
|
|
1456
|
+
if (!tx) return result(null, "not_finalized");
|
|
1457
|
+
const slot = typeof tx.slot === "number" ? tx.slot : null;
|
|
1458
|
+
const blockTime = typeof tx.blockTime === "number" ? tx.blockTime : null;
|
|
1459
|
+
const at = (verified, code) => result(verified, code, slot, blockTime);
|
|
1460
|
+
if (!tx.meta) return at(null, "rpc_unavailable");
|
|
1461
|
+
if (tx.meta.err !== null && tx.meta.err !== void 0) return at(false, "tx_failed");
|
|
1462
|
+
let onChain;
|
|
1463
|
+
try {
|
|
1464
|
+
onChain = decodeWire(kit, tx.transaction[0]);
|
|
1465
|
+
} catch {
|
|
1466
|
+
return at(null, "rpc_unavailable");
|
|
1467
|
+
}
|
|
1468
|
+
const firstSignature = Object.values(onChain.signatures)[0];
|
|
1469
|
+
if (!firstSignature || !bytesEqual(firstSignature, referenceBytes))
|
|
1470
|
+
return at(false, "signature_mismatch");
|
|
1471
|
+
if (!bytesEqual(onChain.messageBytes, retained.messageBytes))
|
|
1472
|
+
return at(false, "message_mismatch");
|
|
1473
|
+
const buyerRetained = retained.signatures[input.payer];
|
|
1474
|
+
const buyerOnChain = onChain.signatures[input.payer];
|
|
1475
|
+
if (!buyerRetained || !buyerOnChain || !bytesEqual(buyerRetained, buyerOnChain)) {
|
|
1476
|
+
return at(false, "buyer_signature_missing");
|
|
1477
|
+
}
|
|
1478
|
+
const message = kit.getCompiledTransactionMessageDecoder().decode(onChain.messageBytes);
|
|
1479
|
+
if (message.version !== "legacy" && message.version !== 0) {
|
|
1480
|
+
return at(null, "unsupported_message_format");
|
|
1481
|
+
}
|
|
1482
|
+
const lookups = message.addressTableLookups;
|
|
1483
|
+
if (lookups && lookups.length > 0) return at(null, "unsupported_message_format");
|
|
1484
|
+
const keys = message.staticAccounts.map(String);
|
|
1485
|
+
const transfers = [];
|
|
1486
|
+
const consider = (programIndex, accountIndexes, data) => {
|
|
1487
|
+
const program = keys[programIndex];
|
|
1488
|
+
if (!program || !KNOWN_TOKEN_PROGRAMS.has(program) || data.length === 0) return;
|
|
1489
|
+
if (data[0] !== IX_TRANSFER && data[0] !== IX_TRANSFER_CHECKED) return;
|
|
1490
|
+
transfers.push({ program, tag: data[0], accounts: accountIndexes.map((i) => keys[i]), data });
|
|
1491
|
+
};
|
|
1492
|
+
for (const ix of message.instructions) {
|
|
1493
|
+
consider(ix.programAddressIndex, ix.accountIndices ?? [], new Uint8Array(ix.data ?? []));
|
|
1494
|
+
}
|
|
1495
|
+
const base58Bytes = kit.getBase58Encoder();
|
|
1496
|
+
if (!Array.isArray(tx.meta.innerInstructions)) return at(null, "rpc_unavailable");
|
|
1497
|
+
for (const group of tx.meta.innerInstructions) {
|
|
1498
|
+
for (const ix of group.instructions) {
|
|
1499
|
+
let data;
|
|
1500
|
+
try {
|
|
1501
|
+
data = new Uint8Array(base58Bytes.encode(ix.data));
|
|
1502
|
+
} catch {
|
|
1503
|
+
return at(null, "rpc_unavailable");
|
|
1504
|
+
}
|
|
1505
|
+
consider(ix.programIdIndex, ix.accounts, data);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
const checked = transfers.filter((t) => t.tag === IX_TRANSFER_CHECKED);
|
|
1509
|
+
if (checked.length === 0) return at(false, "no_matching_transfer");
|
|
1510
|
+
if (checked.length > 1 || transfers.length > 1) return at(false, "multiple_matching_transfers");
|
|
1511
|
+
const [transfer] = checked;
|
|
1512
|
+
if (transfer.data.length < 10 || transfer.accounts.length < 4)
|
|
1513
|
+
return at(false, "no_matching_transfer");
|
|
1514
|
+
const [source, mint, destination, authority] = transfer.accounts;
|
|
1515
|
+
const trust = Object.prototype.hasOwnProperty.call(trusted, asset) ? trusted[asset] : void 0;
|
|
1516
|
+
if (!trust) return at(false, "untrusted_mint");
|
|
1517
|
+
if (!trust.tokenPrograms.includes(transfer.program))
|
|
1518
|
+
return at(false, "untrusted_token_program");
|
|
1519
|
+
if (mint !== asset) return at(false, "mint_mismatch");
|
|
1520
|
+
if (transfer.data[9] !== trust.decimals) return at(false, "decimals_mismatch");
|
|
1521
|
+
const [expectedDestination] = await kit.getProgramDerivedAddress({
|
|
1522
|
+
programAddress: kit.address(ASSOCIATED_TOKEN_PROGRAM),
|
|
1523
|
+
seeds: [
|
|
1524
|
+
kit.getAddressEncoder().encode(kit.address(input.payTo)),
|
|
1525
|
+
kit.getAddressEncoder().encode(kit.address(transfer.program)),
|
|
1526
|
+
kit.getAddressEncoder().encode(kit.address(asset))
|
|
1527
|
+
]
|
|
1528
|
+
});
|
|
1529
|
+
if (destination !== expectedDestination) return at(false, "payee_mismatch");
|
|
1530
|
+
if (readU64(transfer.data, 1) !== BigInt(input.amountAtomic))
|
|
1531
|
+
return at(false, "amount_mismatch");
|
|
1532
|
+
if (authority !== input.payer) return at(false, "payer_mismatch");
|
|
1533
|
+
const [expectedSource] = await kit.getProgramDerivedAddress({
|
|
1534
|
+
programAddress: kit.address(ASSOCIATED_TOKEN_PROGRAM),
|
|
1535
|
+
seeds: [
|
|
1536
|
+
kit.getAddressEncoder().encode(kit.address(input.payer)),
|
|
1537
|
+
kit.getAddressEncoder().encode(kit.address(transfer.program)),
|
|
1538
|
+
kit.getAddressEncoder().encode(kit.address(asset))
|
|
1539
|
+
]
|
|
1540
|
+
});
|
|
1541
|
+
if (source !== expectedSource) return at(false, "payer_mismatch");
|
|
1542
|
+
return {
|
|
1543
|
+
...at(true, null),
|
|
1544
|
+
instructions: message.instructions.map((ix) => ({
|
|
1545
|
+
program: keys[ix.programAddressIndex],
|
|
1546
|
+
data: new Uint8Array(ix.data ?? [])
|
|
1547
|
+
}))
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
return Object.freeze(Object.assign(verify, { cluster }));
|
|
1551
|
+
}
|
|
1552
|
+
function decodeWire(kit, base64) {
|
|
1553
|
+
const bytes = kit.getBase64Encoder().encode(base64);
|
|
1554
|
+
const decoded = kit.getTransactionDecoder().decode(bytes);
|
|
1555
|
+
const signatures = {};
|
|
1556
|
+
for (const [addr, sig] of Object.entries(decoded.signatures)) {
|
|
1557
|
+
signatures[addr] = sig ? new Uint8Array(sig) : null;
|
|
1558
|
+
}
|
|
1559
|
+
return { messageBytes: new Uint8Array(decoded.messageBytes), signatures };
|
|
1560
|
+
}
|
|
1149
1561
|
export {
|
|
1150
1562
|
CreditLedger,
|
|
1563
|
+
DEFAULT_RPC_TIMEOUT_MS,
|
|
1151
1564
|
DEFAULT_SOLANA_USD_MINTS,
|
|
1152
1565
|
DEFAULT_TEMPO_USD_ASSETS,
|
|
1566
|
+
DEFAULT_TRUSTED_USD_MINTS,
|
|
1567
|
+
InMemoryReceiptReferenceIndex,
|
|
1568
|
+
LANDING_WINDOW_SECONDS,
|
|
1569
|
+
MEMO_PROGRAM_ID,
|
|
1570
|
+
MEMO_V1_PROGRAM_ID,
|
|
1153
1571
|
MPP_FULFILLMENT_MAX_BODY_BYTES,
|
|
1154
1572
|
MPP_FULFILLMENT_RECORD_SCHEMA,
|
|
1155
1573
|
MppFlushTimeoutError,
|
|
1156
1574
|
MppGateError,
|
|
1157
1575
|
MppPaidNoCompletionError,
|
|
1576
|
+
RECEIPT_REFERENCE_NOT_EVALUATED,
|
|
1158
1577
|
ReplayRejectedError,
|
|
1159
1578
|
SpentProofLedger,
|
|
1579
|
+
assertEchoPreSign,
|
|
1580
|
+
createEchoContext,
|
|
1160
1581
|
createMppFetch,
|
|
1582
|
+
createReceiptChainVerifier,
|
|
1161
1583
|
createSolanaMppFetch,
|
|
1162
1584
|
creditPaidNoCompletion,
|
|
1585
|
+
evaluatePurchaseBinding,
|
|
1163
1586
|
fulfillPaidProof,
|
|
1164
1587
|
quoteFromChallenge,
|
|
1165
1588
|
readCredits,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twzrd1111/clawrouter-mpp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|