diff --git a/README.md b/README.md index d9746b60..e506489e 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,25 @@ ClawRouter is built for the agent-first world: This is the stack that lets agents operate autonomously: **x402 + USDC + local routing**. +### Pre-spend trust (TWZRD AutoGate) + +By default ClawRouter installs [`twzrd-x402-gate`](https://www.npmjs.com/package/twzrd-x402-gate) on its internal x402 client **before any payment is signed**: + +- **Solana** pays: free TWZRD preflight (wash / allow|warn|block). `block` aborts the sign. +- **Base / EVM** pays: observe mode (no fabricated Solana reputation; payment proceeds). +- **Seat identity:** every gate call stamps `X-Twzrd-Caller: twzrd-x402-gate/` for measurable installs. + +**Kill switch (default remains ON):** + +```bash +export TWZRD_AUTO_GATE=0 +# or +export TWZRD_GATE_ENABLED=false +``` + +TWZRD is a pre-spend check, not a facilitator and not a replacement for ClawRouter routing. + + --- ## How it compares diff --git a/package-lock.json b/package-lock.json index 08804433..5ace54e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,9 @@ "engines": { "node": ">=22" }, + "optionalDependencies": { + "twzrd-x402-gate": "^0.8.6" + }, "peerDependencies": { "openclaw": ">=2025.1.0" }, @@ -128,35 +131,38 @@ } }, "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", + "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1883,6 +1889,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", @@ -5688,18 +5728,6 @@ "license": "MIT", "optional": true }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -11049,6 +11077,43 @@ "fsevents": "~2.3.3" } }, + "node_modules/twzrd-x402-gate": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/twzrd-x402-gate/-/twzrd-x402-gate-0.8.6.tgz", + "integrity": "sha512-bdul9FcdKhFWuLXSWJad+za+gFETQwUMZSyCrnt358XmELKXVOY9hkdOEgcmzMEzPkBON6LCr+RZU61JpjOJ6Q==", + "license": "MIT", + "optional": true, + "bin": { + "twzrd-safe-fetch": "bin/twzrd-safe-fetch.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@scure/base": ">=1.0.0", + "@solana/kit": ">=5.1.0", + "@x402/core": ">=2.0.0", + "@x402/fetch": ">=2.0.0", + "@x402/svm": ">=2.0.0" + }, + "peerDependenciesMeta": { + "@scure/base": { + "optional": true + }, + "@solana/kit": { + "optional": true + }, + "@x402/core": { + "optional": true + }, + "@x402/fetch": { + "optional": true + }, + "@x402/svm": { + "optional": true + } + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11513,24 +11578,6 @@ } } }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 51fb7ca3..58850017 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,9 @@ "undici": "^8.5.0", "viem": "^2.39.3" }, + "optionalDependencies": { + "twzrd-x402-gate": "^0.8.6" + }, "peerDependencies": { "openclaw": ">=2025.1.0" }, diff --git a/src/proxy.ts b/src/proxy.ts index 19cbc896..6d218280 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1993,6 +1993,48 @@ export async function startProxy(options: ProxyOptions): Promise { }; } + +/** + * Default-on TWZRD AutoGate for the internal x402 client (Fork-1 seat). + * Missing optional package twzrd-x402-gate = soft-skip so forks can omit it. + * Soft-skip only when the resolution error names twzrd-x402-gate itself — + * missing transitive deps (or any other failure) fail closed so the proxy + * never boots with unguarded payments. + * Kill: TWZRD_AUTO_GATE=0 or TWZRD_GATE_ENABLED=false (handled inside the gate). + */ +async function installTwzrdAutoGateOnClient(client: x402Client): Promise { + let gate: typeof import("twzrd-x402-gate"); + try { + gate = await import("twzrd-x402-gate"); + } catch (err) { + const code = (err as { code?: string } | null)?.code; + const msg = err instanceof Error ? err.message : String(err); + const missingModule = + code === "ERR_MODULE_NOT_FOUND" || + code === "MODULE_NOT_FOUND" || + /Cannot find module/i.test(msg) || + /Cannot find package/i.test(msg); + // Only soft-skip when the absent package is the gate itself. + if (missingModule && /['"]twzrd-x402-gate['"]/i.test(msg)) { + console.warn( + "[ClawRouter] twzrd-x402-gate not available — payments unguarded. npm i twzrd-x402-gate@^0.8.6", + ); + return; + } + throw err; + } + // Registration failures must not fall through to unguarded payments. + gate.installTwzrdAutoGate(client, { + // refuse hard wash on Solana; Base remains observe (network unscored → allow) + refuseWashFlagged: true, + gateOnCanSpend: false, + unsupportedNetworkMode: "observe", + }); + console.log( + "[ClawRouter] TWZRD AutoGate ON (pre-spend check). Kill: TWZRD_AUTO_GATE=0", + ); +} + // Create x402 payment client with EVM scheme (always available) const account = privateKeyToAccount(walletKey as `0x${string}`); const evmPublicClient = createPublicClient({ chain: base, transport: http() }); @@ -2000,6 +2042,13 @@ export async function startProxy(options: ProxyOptions): Promise { const x402 = new x402Client(); registerExactEvmScheme(x402, { signer: evmSigner }); + // TWZRD Fork-1 default seat: pre-spend gate on every payment before sign. + // Default ON when twzrd-x402-gate is installed (optionalDependency). Kill switch: + // TWZRD_AUTO_GATE=0 | TWZRD_GATE_ENABLED=false + // Base/EVM payments: observe mode (allow; Solana corpus does not score EVM). + // Solana payments: full wash/preflight. Seat identity stamps X-Twzrd-Caller. + await installTwzrdAutoGateOnClient(x402); + // Register Solana scheme if key is available // Uses registerExactSvmScheme helper which registers: // - solana:* wildcard (catches any CAIP-2 Solana network) diff --git a/src/twzrd-autogate.test.ts b/src/twzrd-autogate.test.ts new file mode 100644 index 00000000..c78607de --- /dev/null +++ b/src/twzrd-autogate.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +/** + * Proxy installs TWZRD AutoGate on the internal x402Client before payFetch. + * We unit-test the env kill + dynamic import contract without booting the proxy. + */ +describe("TWZRD AutoGate seat (Fork-1)", () => { + const prevAuto = process.env.TWZRD_AUTO_GATE; + const prevGate = process.env.TWZRD_GATE_ENABLED; + + afterEach(() => { + if (prevAuto === undefined) delete process.env.TWZRD_AUTO_GATE; + else process.env.TWZRD_AUTO_GATE = prevAuto; + if (prevGate === undefined) delete process.env.TWZRD_GATE_ENABLED; + else process.env.TWZRD_GATE_ENABLED = prevGate; + vi.resetModules(); + }); + + it("isTwzrdAutoGateDisabled honors TWZRD_AUTO_GATE=0", async () => { + process.env.TWZRD_AUTO_GATE = "0"; + const { isTwzrdAutoGateDisabled } = await import("twzrd-x402-gate"); + expect(isTwzrdAutoGateDisabled()).toBe(true); + }); + + it("isTwzrdAutoGateDisabled default false when env unset", async () => { + delete process.env.TWZRD_AUTO_GATE; + delete process.env.TWZRD_GATE_ENABLED; + const { isTwzrdAutoGateDisabled } = await import("twzrd-x402-gate"); + expect(isTwzrdAutoGateDisabled()).toBe(false); + }); + + it("installTwzrdAutoGate registers on a client with onBeforePaymentCreation", async () => { + delete process.env.TWZRD_AUTO_GATE; + delete process.env.TWZRD_GATE_ENABLED; + const hooks: Array<(ctx: unknown) => Promise> = []; + const client = { + onBeforePaymentCreation(hook: (ctx: unknown) => Promise) { + hooks.push(hook); + }, + }; + const { installTwzrdAutoGate } = await import("twzrd-x402-gate"); + installTwzrdAutoGate(client as never, { + refuseWashFlagged: true, + gateOnCanSpend: false, + unsupportedNetworkMode: "observe", + }); + // Registrar must actually be invoked — typeof alone is true pre-install. + expect(hooks.length).toBeGreaterThan(0); + expect(typeof client.onBeforePaymentCreation).toBe("function"); + }); + + it("soft-skip matcher only accepts missing twzrd-x402-gate itself", () => { + // Mirror installTwzrdAutoGateOnClient catch predicate (proxy.ts). + const isSoftSkip = (code: string | undefined, msg: string): boolean => { + const missingModule = + code === "ERR_MODULE_NOT_FOUND" || + code === "MODULE_NOT_FOUND" || + /Cannot find module/i.test(msg) || + /Cannot find package/i.test(msg); + return missingModule && /['"]twzrd-x402-gate['"]/i.test(msg); + }; + expect( + isSoftSkip( + "ERR_MODULE_NOT_FOUND", + "Cannot find package 'twzrd-x402-gate' imported from /app/proxy.js", + ), + ).toBe(true); + expect( + isSoftSkip( + "ERR_MODULE_NOT_FOUND", + "Cannot find package 'some-transitive-dep' imported from /app/node_modules/twzrd-x402-gate/index.js", + ), + ).toBe(false); + expect(isSoftSkip(undefined, "boom unrelated")).toBe(false); + }); +});