@twzrd1111/clawrouter-mpp 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BlockRunAI
4
+ Copyright (c) 2026 TWZRD (MPP rails)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,48 @@
1
- # Temporary Holding Version
1
+ # @twzrd1111/clawrouter-mpp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ MPP (Machine Payments Protocol) payment rails for [ClawRouter](https://github.com/BlockRunAI/ClawRouter):
4
+ pay an MPP seller over **Tempo** or **Solana** instead of x402, under ClawRouter's own spend limits.
5
+
6
+ ```ts
7
+ import { startProxy, SpendControl, FileSpendControlStorage } from "@twzrd1111/clawrouter";
8
+ import { createMppFetch, createSolanaMppFetch } from "@twzrd1111/clawrouter-mpp";
9
+
10
+ const spend = new SpendControl({ storage: new FileSpendControlStorage() });
11
+
12
+ // Tempo (needs `mppx`)
13
+ const payFetch = createMppFetch(account, fetch, { spend });
14
+ // or Solana (needs `@solana/pay-kit`)
15
+ // const payFetch = await createSolanaMppFetch({ signer, rpcUrl, network: "devnet", spend });
16
+
17
+ await startProxy({ spendControl: spend, payFetch /* ... */ });
18
+ ```
19
+
20
+ Requires ClawRouter with the `payFetch` seam. Until that ships upstream
21
+ ([BlockRunAI/ClawRouter#546](https://github.com/BlockRunAI/ClawRouter/pull/546)), install the
22
+ `@twzrd1111/clawrouter` distribution: upstream ClawRouter plus that seam, nothing else, with version numbers
23
+ mirroring upstream. When the seam is released upstream, switch the import and peer dependency back to
24
+ `@blockrun/clawrouter`.
25
+
26
+ ## What it guarantees
27
+
28
+ - **Spend limits apply.** The amount, network, asset and payee are checked, and budget reserved, _before_
29
+ anything is signed. Payee lists on Solana are checked at the challenge event and fail closed.
30
+ The proxy refuses to start a rail that does not set `enforcesSpend` while amount limits are configured.
31
+ - **A signed payment is never paid twice.** If a call fails after a credential or transaction was signed, it
32
+ ends in `MppPaidNoCompletionError`: the proxy answers 502 `payment_settled_no_completion` and does not
33
+ retry or fall back. The spend ledger keeps the spend with a note, and the error carries a payment
34
+ reference (`mpp-<16 hex>`, a hash of the signed payload) for disputes.
35
+ - **Optional peers.** `mppx` and `@solana/pay-kit` are loaded on the first paid call; a missing one produces an
36
+ install instruction, not a crash at import.
37
+
38
+ "Signed" is not "settled": on Solana the seller submits the transaction. Not covered: failures that happen
39
+ mid-stream, and seller-side refunds.
40
+
41
+ ## Develop
42
+
43
+ ```
44
+ npm install # from the ClawRouter repo root
45
+ npm run typecheck && npm test && npm run build # in packages/clawrouter-mpp
46
+ ```
47
+
48
+ Tests resolve `@twzrd1111/clawrouter` to the repo source until the seam is released.
@@ -0,0 +1,72 @@
1
+ import { Account } from 'viem';
2
+ import { SpendControl, PayFetch } from '@twzrd1111/clawrouter';
3
+ export { PayFetch } from '@twzrd1111/clawrouter';
4
+ import { SolanaNetwork } from '@solana/pay-kit/client';
5
+ import { KeyPairSigner } from '@solana/kit';
6
+
7
+ /**
8
+ * Tempo TIP-20 USD stablecoins priced here, lower-case, all 6 decimals (the challenge carries no
9
+ * `decimals`). Any other asset cannot be priced, so it is refused while amount limits are set.
10
+ * - 0x20c0…0000 pathUSD (Moderato testnet 42431, also on mainnet 4217)
11
+ * - 0x20c0…b9537d11c60e8b50 the USD token a Tempo-run MPP proxy quoted on mainnet 4217 (2026-11-03)
12
+ */
13
+ declare const DEFAULT_TEMPO_USD_ASSETS: ReadonlySet<string>;
14
+ type MppFetchOptions = {
15
+ /** When set, every challenge is checked against these limits before a credential is created. */
16
+ spend?: SpendControl;
17
+ /** Override the assets treated as 6-decimal USD (lower-case addresses). */
18
+ usdAssets?: ReadonlySet<string>;
19
+ };
20
+ type ChallengeLike = {
21
+ id: string;
22
+ request?: Record<string, unknown>;
23
+ };
24
+ /** The quote as the spend-control layer understands it. `amount` stays undefined if unpriceable. */
25
+ declare function quoteFromChallenge(challenge: ChallengeLike, usdAssets?: ReadonlySet<string>): {
26
+ payTo: string | undefined;
27
+ network: string | undefined;
28
+ asset: string | undefined;
29
+ amount: string | undefined;
30
+ priceable: boolean;
31
+ };
32
+ type MethodLike = {
33
+ createCredential: (options: any) => Promise<string>;
34
+ };
35
+ /**
36
+ * Put the spend policy in the signing path of an mppx method.
37
+ *
38
+ * mppx's `challenge.received` event is an observer: it runs each handler inside try/catch and goes on to
39
+ * sign, so a handler cannot veto a payment by throwing (verified against mppx 0.17.0 and live, 2026-11-03).
40
+ * `createCredential` is the one place a throw really aborts: reserve (fail-closed) -> sign -> settle,
41
+ * and release the reservation if signing fails. A signed credential counts as spend even if the request
42
+ * then fails, because MPP settles before the upstream responds.
43
+ */
44
+ declare function guardMethod<M extends MethodLike>(method: M, control: SpendControl | undefined, usdAssets?: ReadonlySet<string>): M;
45
+ declare function createMppFetch(account: Account, base?: typeof fetch, opts?: MppFetchOptions): PayFetch;
46
+
47
+ /**
48
+ * 6-decimal USD stablecoin mints priced here (from pay-kit 0.13.1's own tables). Any other mint cannot be
49
+ * priced, so it is refused while amount limits are set. Pass `usdMints` to add e.g. a localnet test mint.
50
+ */
51
+ declare const DEFAULT_SOLANA_USD_MINTS: ReadonlySet<string>;
52
+ type SolanaMppFetchOptions = {
53
+ signer: KeyPairSigner;
54
+ rpcUrl: string;
55
+ network: SolanaNetwork;
56
+ /** When set, every payment is checked against these limits before it is signed. */
57
+ spend?: SpendControl;
58
+ /** Extra mints to treat as 6-decimal USD (e.g. a localnet test mint). */
59
+ usdMints?: Iterable<string>;
60
+ };
61
+ declare function createSolanaMppFetch(opts: SolanaMppFetchOptions): Promise<PayFetch>;
62
+
63
+ declare class MppPaidNoCompletionError extends Error {
64
+ readonly reference: string;
65
+ readonly status?: number;
66
+ constructor(message: string, reference: string, status?: number, options?: {
67
+ cause?: unknown;
68
+ });
69
+ }
70
+
71
+ export { DEFAULT_SOLANA_USD_MINTS, DEFAULT_TEMPO_USD_ASSETS, MppPaidNoCompletionError, createMppFetch, createSolanaMppFetch, guardMethod, quoteFromChallenge };
72
+ export type { MppFetchOptions, SolanaMppFetchOptions };
package/dist/index.js ADDED
@@ -0,0 +1,313 @@
1
+ // src/mpp-fetch.ts
2
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
3
+ import {
4
+ assertSpendPolicyAllows,
5
+ SpendPolicyError as SpendPolicyError2
6
+ } from "@twzrd1111/clawrouter";
7
+
8
+ // src/mpp-paid-failure.ts
9
+ import { createHash } from "crypto";
10
+ import { AsyncLocalStorage } from "async_hooks";
11
+ import {
12
+ PAYMENT_SETTLED_NO_COMPLETION,
13
+ SpendPolicyError
14
+ } from "@twzrd1111/clawrouter";
15
+ var MppPaidNoCompletionError = class extends Error {
16
+ reference;
17
+ status;
18
+ constructor(message, reference, status, options) {
19
+ super(message, options);
20
+ this.name = PAYMENT_SETTLED_NO_COMPLETION;
21
+ this.reference = reference;
22
+ this.status = status;
23
+ }
24
+ };
25
+ var storage = new AsyncLocalStorage();
26
+ function paymentReference(payload) {
27
+ return `mpp-${createHash("sha256").update(payload).digest("hex").slice(0, 16)}`;
28
+ }
29
+ function markSigned(payload, record) {
30
+ const call = storage.getStore();
31
+ if (!call || call.signed) return;
32
+ call.signed = true;
33
+ call.reference = paymentReference(payload);
34
+ call.record = record;
35
+ }
36
+ async function guardPaid(spend, run) {
37
+ const call = { signed: false };
38
+ let response;
39
+ try {
40
+ response = await storage.run(call, run);
41
+ } catch (err) {
42
+ if (!call.signed || err instanceof SpendPolicyError) throw err;
43
+ const detail = err instanceof Error ? err.message : String(err);
44
+ throw fail(spend, call, void 0, detail, err);
45
+ }
46
+ if (call.signed && response.status >= 400) {
47
+ throw fail(spend, call, response.status, await boundedSnippet(response));
48
+ }
49
+ return response;
50
+ }
51
+ var SNIPPET_CHARS = 200;
52
+ var SNIPPET_MAX_BYTES = 1024;
53
+ var SNIPPET_TIMEOUT_MS = 1e3;
54
+ async function boundedSnippet(response, timeoutMs = SNIPPET_TIMEOUT_MS) {
55
+ let reader;
56
+ let timer;
57
+ try {
58
+ const body = response.clone().body;
59
+ if (!body) return "";
60
+ reader = body.getReader();
61
+ const r = reader;
62
+ const chunks = [];
63
+ let total = 0;
64
+ const read = (async () => {
65
+ while (total < SNIPPET_MAX_BYTES) {
66
+ const { done, value } = await r.read();
67
+ if (done || !value) break;
68
+ chunks.push(value);
69
+ total += value.byteLength;
70
+ }
71
+ })();
72
+ const timeout = new Promise((resolve) => {
73
+ timer = setTimeout(resolve, timeoutMs);
74
+ });
75
+ await Promise.race([read, timeout]);
76
+ const bytes = new Uint8Array(Math.min(total, SNIPPET_MAX_BYTES));
77
+ let at = 0;
78
+ for (const c of chunks) {
79
+ const n = Math.min(c.byteLength, bytes.byteLength - at);
80
+ bytes.set(c.subarray(0, n), at);
81
+ at += n;
82
+ }
83
+ return new TextDecoder().decode(bytes).slice(0, SNIPPET_CHARS);
84
+ } catch {
85
+ return "";
86
+ } finally {
87
+ if (timer) clearTimeout(timer);
88
+ reader?.cancel().catch(() => {
89
+ });
90
+ }
91
+ }
92
+ function fail(spend, call, status, detail, cause) {
93
+ const reference = call.reference ?? "mpp-unknown";
94
+ const why = status !== void 0 ? `HTTP ${status}` : "request failed";
95
+ if (spend && call.record) {
96
+ spend.annotateRecord(call.record, `signed, no completion (${why}) ref ${reference}`);
97
+ }
98
+ return new MppPaidNoCompletionError(
99
+ `MPP payment was signed and sent but no completion came back (${why}${detail ? `: ${detail}` : ""}). Not retrying, to avoid paying twice. Payment reference: ${reference}`,
100
+ reference,
101
+ status,
102
+ { cause }
103
+ );
104
+ }
105
+
106
+ // src/mpp-optional.ts
107
+ async function loadOptional(name, range, importer) {
108
+ try {
109
+ return await importer();
110
+ } catch (err) {
111
+ const code = err?.code;
112
+ const message = err instanceof Error ? err.message : String(err);
113
+ const missing = code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || /Cannot find (package|module)/.test(message);
114
+ if (!missing || !message.includes(name)) throw err;
115
+ throw new Error(
116
+ `ClawRouter's MPP payment rail needs the optional package "${name}", which is not installed. Install it next to ClawRouter: npm install ${name}@${range}`,
117
+ { cause: err }
118
+ );
119
+ }
120
+ }
121
+ var MPPX_RANGE = "~0.17.0";
122
+ var PAY_KIT_RANGE = "~0.13.1";
123
+
124
+ // src/mpp-fetch.ts
125
+ var DEFAULT_TEMPO_USD_ASSETS = /* @__PURE__ */ new Set([
126
+ "0x20c0000000000000000000000000000000000000",
127
+ "0x20c000000000000000000000b9537d11c60e8b50"
128
+ ]);
129
+ function quoteFromChallenge(challenge, usdAssets = DEFAULT_TEMPO_USD_ASSETS) {
130
+ const req = challenge.request ?? {};
131
+ const asset = typeof req.currency === "string" ? req.currency : void 0;
132
+ const chainId = req.methodDetails?.chainId;
133
+ const priceable = asset !== void 0 && usdAssets.has(asset.toLowerCase()) && typeof req.amount === "string";
134
+ return {
135
+ payTo: typeof req.recipient === "string" ? req.recipient : void 0,
136
+ network: typeof chainId === "number" ? `eip155:${chainId}` : void 0,
137
+ asset,
138
+ amount: priceable ? req.amount : void 0,
139
+ priceable
140
+ };
141
+ }
142
+ function guardMethod(method, control, usdAssets = DEFAULT_TEMPO_USD_ASSETS) {
143
+ const guarded = {
144
+ createCredential: async (options) => {
145
+ let reservationId;
146
+ if (control) {
147
+ const quote = quoteFromChallenge(options.challenge, usdAssets);
148
+ if (!quote.priceable && control.hasAmountLimits()) {
149
+ throw new SpendPolicyError2(
150
+ `MPP quote is in an asset or shape ClawRouter cannot price (${quote.asset ?? "none"}); refusing to pay against a configured spend limit`
151
+ );
152
+ }
153
+ reservationId = assertSpendPolicyAllows(control, quote);
154
+ }
155
+ let credential;
156
+ try {
157
+ credential = await method.createCredential(options);
158
+ } catch (err) {
159
+ if (control && reservationId !== void 0) control.releaseReservation(reservationId);
160
+ throw err;
161
+ }
162
+ const record = control && reservationId !== void 0 ? control.settleReservation(reservationId, { action: "mpp tempo charge" }) : void 0;
163
+ markSigned(credential, record);
164
+ return credential;
165
+ }
166
+ };
167
+ return { ...method, ...guarded };
168
+ }
169
+ function createMppFetch(account, base = fetch, opts = {}) {
170
+ const calls = new AsyncLocalStorage2();
171
+ const recording = async (input, init) => {
172
+ const res = await base(input, init);
173
+ const call = calls.getStore();
174
+ if (call) call.last402 = res.status === 402 ? res.clone() : void 0;
175
+ return res;
176
+ };
177
+ const { spend, usdAssets } = opts;
178
+ let mppxPromise;
179
+ const getMppx = () => mppxPromise ??= loadOptional("mppx", MPPX_RANGE, () => import("mppx/client")).then(
180
+ ({ Mppx, tempo }) => Mppx.create({
181
+ methods: tempo({ account }).map((m) => guardMethod(m, spend, usdAssets)),
182
+ polyfill: false,
183
+ fetch: recording
184
+ })
185
+ );
186
+ const payFetch = async (input, init) => {
187
+ const call = {};
188
+ const mppx = await getMppx();
189
+ try {
190
+ return await calls.run(call, () => guardPaid(spend, () => mppx.fetch(input, init)));
191
+ } catch (e) {
192
+ if (call.last402 && /No method found for challenges/.test(e.message)) {
193
+ return call.last402;
194
+ }
195
+ throw e;
196
+ }
197
+ };
198
+ payFetch.enforcesSpend = opts.spend !== void 0;
199
+ return payFetch;
200
+ }
201
+
202
+ // src/mpp-solana-fetch.ts
203
+ import { AsyncLocalStorage as AsyncLocalStorage3 } from "async_hooks";
204
+ import {
205
+ assertSpendPolicyAllows as assertSpendPolicyAllows2,
206
+ SpendPolicyError as SpendPolicyError3
207
+ } from "@twzrd1111/clawrouter";
208
+ var DEFAULT_SOLANA_USD_MINTS = /* @__PURE__ */ new Set([
209
+ "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
210
+ // USDC mainnet
211
+ "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
212
+ // USDC devnet
213
+ "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
214
+ // USDT mainnet
215
+ ]);
216
+ var CAIP2 = {
217
+ mainnet: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
218
+ devnet: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
219
+ localnet: "solana:localnet"
220
+ };
221
+ async function createSolanaMppFetch(opts) {
222
+ const { spend, network } = opts;
223
+ const usd = /* @__PURE__ */ new Set([...DEFAULT_SOLANA_USD_MINTS, ...opts.usdMints ?? []]);
224
+ const calls = new AsyncLocalStorage3();
225
+ const permissions = spend ? {
226
+ authorize(candidate) {
227
+ const call = calls.getStore();
228
+ if (!call)
229
+ throw new SpendPolicyError3("Solana MPP payment attempted outside a guarded call");
230
+ const priceable = usd.has(candidate.mint);
231
+ if (!priceable && spend.hasAmountLimits()) {
232
+ throw new SpendPolicyError3(
233
+ `MPP quote is in a mint ClawRouter cannot price (${candidate.mint}); refusing to pay against a configured spend limit`
234
+ );
235
+ }
236
+ const amount = priceable ? candidate.amount.toString() : void 0;
237
+ call.quote = {
238
+ network: CAIP2[candidate.network],
239
+ asset: candidate.mint,
240
+ amount,
241
+ priceable
242
+ };
243
+ call.reservationId = assertSpendPolicyAllows2(spend, {
244
+ network: call.quote.network,
245
+ asset: candidate.mint,
246
+ amount,
247
+ payeeDeferred: true
248
+ });
249
+ return { maxAmountAtomic: candidate.amount };
250
+ }
251
+ } : void 0;
252
+ const { PayKitClient } = await loadOptional(
253
+ "@solana/pay-kit",
254
+ PAY_KIT_RANGE,
255
+ () => import("@solana/pay-kit/client")
256
+ );
257
+ const client = await PayKitClient.builder().signer(opts.signer).rpcUrl(opts.rpcUrl).network(network).accept(["mpp"]).permissions(permissions).onProgress((event) => {
258
+ const call = calls.getStore();
259
+ const e = event;
260
+ if (!call) return;
261
+ if (!spend) {
262
+ if (e.type === "signed" && typeof e.transaction === "string") markSigned(e.transaction);
263
+ return;
264
+ }
265
+ if (e.type === "challenge" && call.quote) {
266
+ const result = spend.check(0, {
267
+ payTo: e.recipient,
268
+ network: call.quote.network,
269
+ asset: call.quote.asset
270
+ });
271
+ if (!result.allowed) {
272
+ throw new SpendPolicyError3(result.reason ?? "blocked by spend policy", {
273
+ blockedBy: result.blockedBy,
274
+ blockedByPolicy: result.blockedByPolicy
275
+ });
276
+ }
277
+ call.payeeChecked = true;
278
+ } else if (e.type === "signing") {
279
+ if (!call.payeeChecked) {
280
+ throw new SpendPolicyError3("payee was never checked before signing; refusing to sign");
281
+ }
282
+ } else if (e.type === "signed" || e.type === "paid") {
283
+ let record;
284
+ if (call.reservationId !== void 0 && !call.settled) {
285
+ call.settled = true;
286
+ record = spend.settleReservation(call.reservationId, { action: "mpp solana charge" });
287
+ }
288
+ if (e.type === "signed" && typeof e.transaction === "string")
289
+ markSigned(e.transaction, record);
290
+ }
291
+ }).build();
292
+ const payFetch = async (input, init) => {
293
+ const call = { settled: false, payeeChecked: false };
294
+ try {
295
+ return await guardPaid(spend, () => calls.run(call, () => client.fetch(input, init, "mpp")));
296
+ } finally {
297
+ if (spend && call.reservationId !== void 0 && !call.settled) {
298
+ spend.releaseReservation(call.reservationId);
299
+ }
300
+ }
301
+ };
302
+ payFetch.enforcesSpend = spend !== void 0;
303
+ return payFetch;
304
+ }
305
+ export {
306
+ DEFAULT_SOLANA_USD_MINTS,
307
+ DEFAULT_TEMPO_USD_ASSETS,
308
+ MppPaidNoCompletionError,
309
+ createMppFetch,
310
+ createSolanaMppFetch,
311
+ guardMethod,
312
+ quoteFromChallenge
313
+ };
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@twzrd1111/clawrouter-mpp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "MPP (Machine Payments Protocol) payment rails for ClawRouter: Tempo and Solana charge, with spend limits and paid-failure handling",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "!dist/**/*.map",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "license": "MIT",
21
+ "sideEffects": false,
22
+ "scripts": {
23
+ "build": "tsup",
24
+ "typecheck": "tsc --noEmit",
25
+ "test": "vitest run"
26
+ },
27
+ "peerDependencies": {
28
+ "@twzrd1111/clawrouter": ">=0.12.306",
29
+ "@solana/kit": "*",
30
+ "@solana/pay-kit": "~0.13.1",
31
+ "mppx": "~0.17.0",
32
+ "viem": "^2.0.0"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "@solana/kit": {
36
+ "optional": true
37
+ },
38
+ "@solana/pay-kit": {
39
+ "optional": true
40
+ },
41
+ "mppx": {
42
+ "optional": true
43
+ }
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }