diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 76e744a..4e7d3fe 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -8,5 +8,8 @@ export { x402Wallet } from "../rails/x402.js"; export type { X402WalletConfig } from "../rails/x402.js"; +export { ledgerGuardReview, ledgerGuardX402Wallet } from "../rails/ledger-guard.js"; +export type { LedgerGuardConfig } from "../rails/ledger-guard.js"; + export { mockWallet } from "../rails/mock.js"; export type { MockWalletConfig } from "../rails/mock.js"; diff --git a/src/rails/ledger-guard.ts b/src/rails/ledger-guard.ts new file mode 100644 index 0000000..56a764d --- /dev/null +++ b/src/rails/ledger-guard.ts @@ -0,0 +1,172 @@ +/** + * LedgerGuard safety rail. + * + * Complements blacktea's spending controls (how much an agent may spend) + * with LedgerGuard's transaction intent safety (who actually gets paid). + * + * Before any payment is signed, the engine reviews the real calldata: + * - recipient is not a zero address / seed-listed address + * - the decoded transfer matches the declared intent (recipient, amount, asset) + * - the call is safe to sign (fail-closed: unknown calls are never treated safe) + * + * Integration is a drop-in wrapper around the x402 rail: + * + * import { ledgerGuardX402Wallet } from "@nmrtn/blacktea/ledger-guard"; + * + * const pay = blacktea({ + * rail: ledgerGuardX402Wallet({ + * privateKey: process.env.EVM_PRIVATE_KEY!, + * chain: "base", + * engineUrl: "https://ledgerguard-gules.vercel.app", + * }), + * policy: { ... }, + * }); + * + * The rail never receives the private key — LedgerGuard is a read-only + * preflight reviewer; signing stays in blacktea's x402 signer. + */ + +import { NetworkError, RailUnavailableError } from "../errors.js"; +import type { + PayInput, + PayOptions, + PaymentRequirement, + RailAdapter, + SettleResult, +} from "../types.js"; +import { type X402WalletConfig, x402Wallet } from "./x402.js"; + +export interface LedgerGuardConfig extends X402WalletConfig { + /** + * LedgerGuard engine base URL. Defaults to the public demo deployment. + */ + engineUrl?: string; + /** + * Network identifier the engine expects. Defaults to "baseMainnet". + */ + engineNetwork?: string; + /** + * When true (default), a BLOCK from the engine aborts the payment. + * Set false only to log-and-continue during evaluation. + */ + failClosed?: boolean; +} + +interface PreflightResult { + decision: "ALLOW" | "REVIEW" | "BLOCK" | string; + findings?: Array<{ code: string; severity: string; message: string }>; +} + +/** + * Review a payment against the LedgerGuard engine before anything signs. + * Fail-closed: network errors and BLOCK decisions abort the payment. + */ +export async function ledgerGuardReview( + cfg: Pick, + payment: { + from: string; + to: string; + data?: string; + valueWei?: string; + amountMicroUsdc?: string; + recipient?: string; + purpose?: string; + network?: string; + }, +): Promise { + const baseUrl = (cfg.engineUrl ?? "https://ledgerguard-gules.vercel.app").replace(/\/$/, ""); + const network = payment.network ?? cfg.engineNetwork ?? "baseMainnet"; + + const body = { + network, + from: payment.from, + to: payment.to, + ...(payment.data ? { data: payment.data } : {}), + ...(payment.valueWei ? { valueWei: payment.valueWei } : {}), + intent: { + action: "transfer", + expectedRecipient: payment.recipient ?? payment.to, + expectedAssetAddress: payment.to, + ...(payment.amountMicroUsdc ? { expectedAmountMicroUsdc: payment.amountMicroUsdc } : {}), + ...(payment.purpose ? { purpose: payment.purpose } : {}), + }, + policy: {}, + }; + + let response: Response; + try { + response = await fetch(`${baseUrl}/v1/preflight`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } catch (err) { + throw new NetworkError(`LedgerGuard preflight fetch failed: ${(err as Error).message}`, err); + } + + let result: PreflightResult; + try { + result = (await response.json()) as PreflightResult; + } catch { + throw new RailUnavailableError( + "ledger-guard", + `LedgerGuard preflight returned non-JSON (HTTP ${response.status}).`, + ); + } + + const failClosed = cfg.failClosed ?? true; + const decision = result.decision ?? "UNKNOWN"; + if (failClosed && decision === "BLOCK") { + const codes = (result.findings ?? []).map((f) => f.code).join(","); + throw new RailUnavailableError( + "ledger-guard", + `LedgerGuard BLOCKED this payment (findings: ${codes || "unspecified"}).`, + ); + } + return result; +} + +/** + * Drop-in x402 rail wrapper that reviews every payment against LedgerGuard + * before the x402 signer runs. Signing still happens locally via x402-fetch. + */ +export function ledgerGuardX402Wallet(cfg: LedgerGuardConfig): RailAdapter { + const inner = x402Wallet(cfg); + const engineUrl = cfg.engineUrl ?? "https://ledgerguard-gules.vercel.app"; + const engineNetwork = cfg.engineNetwork ?? "baseMainnet"; + + return { + name: "x402+ledger-guard", + + supports(input: PayInput): boolean { + return inner.supports(input); + }, + + async preflight(input: PayInput): Promise { + return inner.preflight(input); + }, + + async settle( + input: PayInput, + requirement: PaymentRequirement, + opts: PayOptions, + ): Promise { + // Review the real transaction before anything signs. + await ledgerGuardReview( + { engineUrl, engineNetwork, failClosed: cfg.failClosed }, + { + from: (input as { from?: string }).from ?? "", + to: (requirement as { recipient_wallet?: string }).recipient_wallet ?? "", + ...(input.body ? { data: (input.body as { data?: string }).data } : {}), + ...(requirement.amount !== undefined + ? { amountMicroUsdc: String(Math.round(requirement.amount * 1_000_000)) } + : {}), + recipient: (requirement as { recipient_wallet?: string }).recipient_wallet, + purpose: (input as { purpose?: string }).purpose ?? "blacktea agent payment", + }, + ); + + return inner.settle(input, requirement, opts); + }, + }; +} diff --git a/test/ledger-guard.test.ts b/test/ledger-guard.test.ts new file mode 100644 index 0000000..2b127a0 --- /dev/null +++ b/test/ledger-guard.test.ts @@ -0,0 +1,82 @@ +/** + * LedgerGuard rail tests. These hit the public LedgerGuard engine over HTTP + * (read-only preflight review; nothing is signed and no funds move). + */ + +import { describe, expect, it } from "vitest"; +import { ledgerGuardReview, ledgerGuardX402Wallet } from "../src/rails/ledger-guard.js"; + +// The public Vercel deployment keeps Base mainnet preflight behind a +// fail-closed gate (NETWORK_DISABLED until release gates pass). For real +// integration development, point at a locally enabled engine. +const ENGINE = process.env.LEDGERGUARD_ENGINE_URL ?? "http://localhost:3000"; +const BUYER = "0xF1437d9CD304aE49f2ec005AC967813B3a7c466c"; +const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const MERCHANT = "0x4732d748a7dA766A0192adC2BBefC6041AAF9056"; + +function transferCalldata(to: string, amountMicro: number): string { + return `0xa9059cbb${to.toLowerCase().replace(/^0x/, "").padStart(64, "0")}${BigInt(amountMicro) + .toString(16) + .padStart(64, "0")}`; +} + +describe("ledgerGuardReview (real engine, read-only)", () => { + it("reviews a normal merchant transfer and returns a decision", async () => { + const result = await ledgerGuardReview( + { engineUrl: ENGINE, engineNetwork: "baseMainnet" }, + { + from: BUYER, + to: USDC, + data: transferCalldata(MERCHANT, 100000), + amountMicroUsdc: "100000", + recipient: MERCHANT, + purpose: "blacktea integration test", + }, + ); + expect(["ALLOW", "REVIEW", "BLOCK"]).toContain(result.decision); + }); + + it("blocks a zero-address recipient (fail-closed)", async () => { + const zero = "0x0000000000000000000000000000000000000000"; + await expect( + ledgerGuardReview( + { engineUrl: ENGINE, engineNetwork: "baseMainnet" }, + { + from: BUYER, + to: USDC, + data: transferCalldata(zero, 100000), + amountMicroUsdc: "100000", + recipient: zero, + purpose: "blacktea integration test", + }, + ), + ).rejects.toThrow(/BLOCKED/i); + }); + + it("returns the engine decision without throwing when failClosed=false", async () => { + const result = await ledgerGuardReview( + { engineUrl: ENGINE, engineNetwork: "baseMainnet", failClosed: false }, + { + from: BUYER, + to: USDC, + data: transferCalldata(MERCHANT, 100000), + amountMicroUsdc: "100000", + recipient: MERCHANT, + purpose: "blacktea integration test", + }, + ); + expect(result).toHaveProperty("decision"); + }); +}); + +describe("ledgerGuardX402Wallet adapter", () => { + it("exposes the x402-compatible rail surface", () => { + const rail = ledgerGuardX402Wallet({ + privateKey: `0x${"11".repeat(32)}`, + chain: "base", + engineUrl: ENGINE, + }); + expect(rail.name).toBe("x402+ledger-guard"); + expect(rail.supports({ url: "https://example.com/resource", intent: "test" })).toBe(true); + }); +});