From eab80538fdcdd5e340ec9a3f3b8e7d783dffe2b5 Mon Sep 17 00:00:00 2001 From: Blockchain-Oracle Date: Fri, 17 Jul 2026 08:57:14 +0200 Subject: [PATCH 1/6] feat(agent): add x402 payment core --- apps/agent/src/detect.test.ts | 88 +++++++++ apps/agent/src/detect.ts | 79 ++++++++ .../src/fetch-wrapper.integration.test.ts | 130 ++++++++++++++ apps/agent/src/fetch-wrapper.ts | 42 +++++ apps/agent/src/origin-context.ts | 13 ++ apps/agent/src/payment-client.ts | 13 ++ apps/agent/src/proxy.integration.test.ts | 143 +++++++++++++++ apps/agent/src/proxy.ts | 96 ++++++++++ apps/agent/src/remote-signer.test.ts | 75 ++++++++ apps/agent/src/remote-signer.ts | 169 ++++++++++++++++++ apps/agent/src/routing.test.ts | 64 +++++++ apps/agent/src/routing.ts | 48 +++++ 12 files changed, 960 insertions(+) create mode 100644 apps/agent/src/detect.test.ts create mode 100644 apps/agent/src/detect.ts create mode 100644 apps/agent/src/fetch-wrapper.integration.test.ts create mode 100644 apps/agent/src/fetch-wrapper.ts create mode 100644 apps/agent/src/origin-context.ts create mode 100644 apps/agent/src/payment-client.ts create mode 100644 apps/agent/src/proxy.integration.test.ts create mode 100644 apps/agent/src/proxy.ts create mode 100644 apps/agent/src/remote-signer.test.ts create mode 100644 apps/agent/src/remote-signer.ts create mode 100644 apps/agent/src/routing.test.ts create mode 100644 apps/agent/src/routing.ts diff --git a/apps/agent/src/detect.test.ts b/apps/agent/src/detect.test.ts new file mode 100644 index 0000000..d25bed4 --- /dev/null +++ b/apps/agent/src/detect.test.ts @@ -0,0 +1,88 @@ +import { encodePaymentRequiredHeader } from "@x402/core/http"; +import type { PaymentRequired } from "@x402/core/types"; +import { describe, expect, it } from "vitest"; + +import { detectHttpPaymentRequired, detectMcpPaymentRequired } from "./detect.js"; + +const paymentRequired = { + accepts: [ + { + amount: "25000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + extra: { name: "USD Coin", version: "2" }, + maxTimeoutSeconds: 60, + network: "eip155:8453", + payTo: "0x1111111111111111111111111111111111111111", + scheme: "exact", + }, + ], + resource: { url: "https://resource.example.test/paid" }, + x402Version: 2, +} satisfies PaymentRequired; + +describe("MCP payment-required detection", () => { + it("detects the structured tool-result surface", () => { + expect( + detectMcpPaymentRequired({ + content: [{ text: JSON.stringify(paymentRequired), type: "text" }], + isError: true, + structuredContent: paymentRequired, + }), + ).toEqual(paymentRequired); + }); + + it("detects SEP-1036 -32042 errors, including namespaced x402 data", () => { + expect( + detectMcpPaymentRequired({ + code: -32042, + data: { paymentMethods: ["x402"], x402: paymentRequired }, + message: "Payment required", + }), + ).toEqual(paymentRequired); + }); + + it("does not classify ordinary tool errors as payment challenges", () => { + expect( + detectMcpPaymentRequired({ + content: [{ text: "upstream failed", type: "text" }], + isError: true, + }), + ).toBeNull(); + }); +}); + +describe("HTTP payment-required detection", () => { + it("decodes the v2 PAYMENT-REQUIRED header without consuming the response", async () => { + const response = new Response("protected", { + headers: { "PAYMENT-REQUIRED": encodePaymentRequiredHeader(paymentRequired) }, + status: 402, + }); + + await expect(detectHttpPaymentRequired(response)).resolves.toEqual(paymentRequired); + await expect(response.text()).resolves.toBe("protected"); + }); + + it("accepts the legacy v1 402 JSON body", async () => { + const legacy = { + accepts: [ + { + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + description: "Legacy resource", + extra: { name: "USD Coin", version: "2" }, + maxAmountRequired: "25000", + maxTimeoutSeconds: 60, + mimeType: "application/json", + network: "base", + outputSchema: {}, + payTo: "0x1111111111111111111111111111111111111111", + resource: "https://resource.example.test/legacy", + scheme: "exact", + }, + ], + x402Version: 1, + }; + const response = Response.json(legacy, { status: 402 }); + + await expect(detectHttpPaymentRequired(response)).resolves.toEqual(legacy); + }); +}); diff --git a/apps/agent/src/detect.ts b/apps/agent/src/detect.ts new file mode 100644 index 0000000..218276c --- /dev/null +++ b/apps/agent/src/detect.ts @@ -0,0 +1,79 @@ +import { decodePaymentRequiredHeader } from "@x402/core/http"; +import type { PaymentRequired } from "@x402/core/types"; +import { extractPaymentRequiredFromError, isPaymentRequiredError } from "@x402/mcp"; + +interface LegacyPaymentRequired { + accepts: unknown[]; + error?: string; + x402Version: 1; +} + +export type DetectedPaymentRequired = PaymentRequired | LegacyPaymentRequired; + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function paymentRequired(value: unknown): PaymentRequired | null { + if (!record(value) || typeof value.x402Version !== "number") return null; + if (!Array.isArray(value.accepts) || value.accepts.length === 0) return null; + if (!record(value.resource) || typeof value.resource.url !== "string") return null; + return value as PaymentRequired; +} + +function legacyPaymentRequired(value: unknown): LegacyPaymentRequired | null { + if (!record(value) || value.x402Version !== 1) return null; + if (!Array.isArray(value.accepts) || value.accepts.length === 0) return null; + return value as unknown as LegacyPaymentRequired; +} + +function paymentRequiredFromText(value: unknown) { + if (!record(value) || value.type !== "text" || typeof value.text !== "string") return null; + try { + return paymentRequired(JSON.parse(value.text)); + } catch { + return null; + } +} + +export function detectMcpPaymentRequired(value: unknown): PaymentRequired | null { + if (isPaymentRequiredError(value)) { + const extracted = extractPaymentRequiredFromError(value); + if (extracted) return extracted; + const data: unknown = value.data; + if (record(data)) { + return paymentRequired(data.x402) ?? paymentRequired(data); + } + } + if (!record(value) || value.isError !== true) return null; + + const structured = paymentRequired(value.structuredContent); + if (structured) return structured; + if (!Array.isArray(value.content)) return null; + for (const item of value.content) { + const detected = paymentRequiredFromText(item); + if (detected) return detected; + } + return null; +} + +export async function detectHttpPaymentRequired( + response: Response, +): Promise { + if (response.status !== 402) return null; + + const header = response.headers.get("PAYMENT-REQUIRED"); + if (header) { + try { + return paymentRequired(decodePaymentRequiredHeader(header)); + } catch { + return null; + } + } + + try { + return legacyPaymentRequired(await response.clone().json()); + } catch { + return null; + } +} diff --git a/apps/agent/src/fetch-wrapper.integration.test.ts b/apps/agent/src/fetch-wrapper.integration.test.ts new file mode 100644 index 0000000..14b9553 --- /dev/null +++ b/apps/agent/src/fetch-wrapper.integration.test.ts @@ -0,0 +1,130 @@ +import { createServer } from "node:http"; + +import { + decodePaymentSignatureHeader, + encodePaymentRequiredHeader, + encodePaymentResponseHeader, +} from "@x402/core/http"; +import type { PaymentRequired } from "@x402/core/types"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { createLeashFetch } from "./fetch-wrapper.js"; + +const payer = "0x2222222222222222222222222222222222222222" as const; +const signature = `0x${"ab".repeat(65)}` as const; +const transaction = `0x${"cd".repeat(32)}`; +const paymentRequired = { + accepts: [ + { + amount: "25000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + extra: { name: "USD Coin", version: "2" }, + maxTimeoutSeconds: 60, + network: "eip155:8453", + payTo: "0x1111111111111111111111111111111111111111", + scheme: "exact", + }, + ], + resource: { url: "http://127.0.0.1/protected" }, + x402Version: 2, +} satisfies PaymentRequired; + +async function jsonRequest(request: import("node:http").IncomingMessage) { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +describe("Leash fetch wrapper with real x402 and HTTP wires", () => { + const signRequests: unknown[] = []; + const resultRequests: unknown[] = []; + let origin = ""; + const server = createServer(async (request, response) => { + if (request.url === "/api/agent/sign") { + expect(request.headers.authorization).toBe("Bearer leash_sk_integration"); + signRequests.push(await jsonRequest(request)); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ receiptId: "receipt-1", signature })); + return; + } + if (request.url === "/api/agent/pay/result") { + resultRequests.push(await jsonRequest(request)); + response.statusCode = 204; + response.end(); + return; + } + if (request.url === "/protected") { + const paymentHeader = request.headers["payment-signature"]; + if (typeof paymentHeader !== "string") { + response.statusCode = 402; + response.setHeader("PAYMENT-REQUIRED", encodePaymentRequiredHeader(paymentRequired)); + response.end("payment required"); + return; + } + const payload = decodePaymentSignatureHeader(paymentHeader); + expect(payload).toMatchObject({ + accepted: { network: "eip155:8453" }, + payload: { signature }, + }); + response.setHeader( + "PAYMENT-RESPONSE", + encodePaymentResponseHeader({ + network: "eip155:8453", + payer, + success: true, + transaction, + }), + ); + response.end("protected result"); + return; + } + response.statusCode = 404; + response.end(); + }); + + beforeAll(async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP listener"); + origin = `http://127.0.0.1:${address.port}`; + paymentRequired.resource.url = `${origin}/protected`; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); + + it("pays, retries, and reports the settlement using shipped x402 packages", async () => { + const leashFetch = createLeashFetch({ + address: payer, + apiBaseUrl: origin, + apiKey: "leash_sk_integration", + fetch: globalThis.fetch, + }); + + const response = await leashFetch(`${origin}/protected`); + + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe("protected result"); + expect(signRequests).toHaveLength(1); + expect(signRequests[0]).toMatchObject({ + amount: "25000", + network: "eip155:8453", + origin: { clientName: "leash-fetch", transport: "http" }, + }); + expect(resultRequests).toEqual([ + { + outcome: "settled", + paymentResponse: { + network: "eip155:8453", + payer, + success: true, + transaction, + }, + receiptId: "receipt-1", + }, + ]); + }); +}); diff --git a/apps/agent/src/fetch-wrapper.ts b/apps/agent/src/fetch-wrapper.ts new file mode 100644 index 0000000..879816d --- /dev/null +++ b/apps/agent/src/fetch-wrapper.ts @@ -0,0 +1,42 @@ +import { wrapFetchWithPayment } from "@x402/fetch"; + +import { currentPaymentOrigin, withPaymentOrigin } from "./origin-context.js"; +import { createLeashPaymentClient } from "./payment-client.js"; +import { LeashRemoteSigner } from "./remote-signer.js"; + +interface LeashFetchOptions { + address: `0x${string}`; + apiBaseUrl: string; + apiKey: string; + clientName?: string; + fetch?: typeof globalThis.fetch; +} + +type FetchInput = Request | string | URL; + +function requestName(input: FetchInput, init?: RequestInit) { + const request = new Request(input, init); + return `${request.method} ${request.url}`; +} + +export function createLeashFetch(options: LeashFetchOptions) { + const baseFetch = options.fetch ?? globalThis.fetch; + const signer = new LeashRemoteSigner({ + address: options.address, + apiBaseUrl: options.apiBaseUrl, + apiKey: options.apiKey, + fetch: baseFetch, + origin: currentPaymentOrigin, + }); + const paidFetch = wrapFetchWithPayment(baseFetch, createLeashPaymentClient(signer)); + + return (input: FetchInput, init?: RequestInit) => + withPaymentOrigin( + { + clientName: options.clientName ?? "leash-fetch", + toolName: requestName(input, init), + transport: "http", + }, + () => paidFetch(input, init), + ); +} diff --git a/apps/agent/src/origin-context.ts b/apps/agent/src/origin-context.ts new file mode 100644 index 0000000..ff99f57 --- /dev/null +++ b/apps/agent/src/origin-context.ts @@ -0,0 +1,13 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import type { PaymentOrigin } from "./remote-signer.js"; + +const paymentOrigin = new AsyncLocalStorage(); + +export function currentPaymentOrigin() { + return paymentOrigin.getStore(); +} + +export function withPaymentOrigin(origin: PaymentOrigin, action: () => T) { + return paymentOrigin.run(origin, action); +} diff --git a/apps/agent/src/payment-client.ts b/apps/agent/src/payment-client.ts new file mode 100644 index 0000000..9bc6cff --- /dev/null +++ b/apps/agent/src/payment-client.ts @@ -0,0 +1,13 @@ +import { x402Client } from "@x402/core/client"; +import { ExactEvmScheme } from "@x402/evm/exact/client"; + +import type { LeashRemoteSigner } from "./remote-signer.js"; +import { ARBITRUM_NETWORK, BASE_NETWORK, selectLeashPaymentRequirements } from "./routing.js"; + +export function createLeashPaymentClient(signer: LeashRemoteSigner) { + const scheme = new ExactEvmScheme(signer); + return new x402Client(selectLeashPaymentRequirements) + .register(BASE_NETWORK, scheme) + .register(ARBITRUM_NETWORK, scheme) + .onPaymentResponse((context) => signer.reportSettledPayment(context)); +} diff --git a/apps/agent/src/proxy.integration.test.ts b/apps/agent/src/proxy.integration.test.ts new file mode 100644 index 0000000..7276a17 --- /dev/null +++ b/apps/agent/src/proxy.integration.test.ts @@ -0,0 +1,143 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import type { PaymentRequired } from "@x402/core/types"; +import { MCP_PAYMENT_META_KEY, MCP_PAYMENT_RESPONSE_META_KEY } from "@x402/mcp"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { createLeashPaymentClient } from "./payment-client.js"; +import { createLeashProxyServer } from "./proxy.js"; +import { LeashRemoteSigner } from "./remote-signer.js"; + +const payer = "0x2222222222222222222222222222222222222222" as const; +const signature = `0x${"ab".repeat(65)}` as const; +const transaction = `0x${"cd".repeat(32)}`; +const paymentRequired = { + accepts: [ + { + amount: "25000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + extra: { name: "USD Coin", version: "2" }, + maxTimeoutSeconds: 60, + network: "eip155:8453", + payTo: "0x1111111111111111111111111111111111111111", + scheme: "exact", + }, + ], + resource: { url: "mcp://tool/search" }, + x402Version: 2, +} satisfies PaymentRequired; + +describe("Leash MCP proxy with real SDK transports", () => { + const upstreamServer = new Server( + { name: "paid-upstream", version: "1.0.0" }, + { capabilities: { tools: {} } }, + ); + const upstreamClient = new Client({ name: "leash-upstream", version: "0.0.1" }); + const downstream = new Client({ name: "Claude Code", version: "1.2.3" }); + const signBodies: unknown[] = []; + const resultBodies: unknown[] = []; + const paidMetadata: unknown[] = []; + const remoteFetch = vi.fn(async (input: Request | string | URL, init?: RequestInit) => { + const path = new URL(input.toString()).pathname; + if (path === "/api/agent/sign") { + signBodies.push(JSON.parse(String(init?.body))); + return Response.json({ receiptId: "receipt-mcp", signature }); + } + if (path === "/api/agent/pay/result") { + resultBodies.push(JSON.parse(String(init?.body))); + return new Response(null, { status: 204 }); + } + return Response.json({ error: { code: "NOT_FOUND", message: "Not found." } }, { status: 404 }); + }); + const signer = new LeashRemoteSigner({ + address: payer, + apiBaseUrl: "https://tab.example.test", + apiKey: "leash_sk_integration", + fetch: remoteFetch, + }); + const proxy = createLeashProxyServer({ + paymentClient: createLeashPaymentClient(signer), + signer, + upstream: upstreamClient, + }); + + beforeAll(async () => { + upstreamServer.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [{ inputSchema: { type: "object" }, name: "search" }], + })); + upstreamServer.setRequestHandler(CallToolRequestSchema, (request) => { + const payment = request.params._meta?.[MCP_PAYMENT_META_KEY]; + if (!payment) { + return { + content: [{ text: JSON.stringify(paymentRequired), type: "text" }], + isError: true, + structuredContent: paymentRequired, + }; + } + paidMetadata.push(request.params._meta); + return { + _meta: { + [MCP_PAYMENT_RESPONSE_META_KEY]: { + network: "eip155:8453", + payer, + success: true, + transaction, + }, + upstreamMarker: "preserved", + }, + content: [{ text: "paid result", type: "text" }], + structuredContent: { answer: 42 }, + }; + }); + + const [upstreamClientTransport, upstreamServerTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([ + upstreamServer.connect(upstreamServerTransport), + upstreamClient.connect(upstreamClientTransport), + ]); + + const [downstreamTransport, proxyTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([proxy.connect(proxyTransport), downstream.connect(downstreamTransport)]); + }); + + afterAll(async () => { + await downstream.close(); + await proxy.close(); + await upstreamClient.close(); + await upstreamServer.close(); + }); + + it("forwards tools, pays once, and preserves request/result metadata", async () => { + await expect(downstream.listTools()).resolves.toMatchObject({ tools: [{ name: "search" }] }); + const result = await downstream.callTool({ + _meta: { traceId: "trace-1" }, + arguments: { query: "x402" }, + name: "search", + }); + + expect(result).toMatchObject({ + _meta: { upstreamMarker: "preserved" }, + content: [{ text: "paid result", type: "text" }], + structuredContent: { answer: 42 }, + }); + expect(paidMetadata).toHaveLength(1); + expect(paidMetadata[0]).toMatchObject({ traceId: "trace-1" }); + expect(signBodies[0]).toMatchObject({ + origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, + }); + expect(resultBodies).toEqual([ + { + outcome: "settled", + paymentResponse: { + network: "eip155:8453", + payer, + success: true, + transaction, + }, + receiptId: "receipt-mcp", + }, + ]); + }); +}); diff --git a/apps/agent/src/proxy.ts b/apps/agent/src/proxy.ts new file mode 100644 index 0000000..5e1f0bb --- /dev/null +++ b/apps/agent/src/proxy.ts @@ -0,0 +1,96 @@ +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import type { x402Client } from "@x402/core/client"; +import type { SettleResponse } from "@x402/core/types"; +import { MCP_PAYMENT_META_KEY, MCP_PAYMENT_RESPONSE_META_KEY } from "@x402/mcp"; + +import { detectMcpPaymentRequired } from "./detect.js"; +import { withPaymentOrigin } from "./origin-context.js"; +import type { LeashRemoteSigner } from "./remote-signer.js"; + +interface LeashProxyOptions { + paymentClient: x402Client; + signer: LeashRemoteSigner; + upstream: Client; +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function settlementFromResult(result: unknown): SettleResponse | null { + if (!record(result) || !record(result._meta)) return null; + const settlement = result._meta[MCP_PAYMENT_RESPONSE_META_KEY]; + if ( + !record(settlement) || + typeof settlement.success !== "boolean" || + typeof settlement.network !== "string" || + typeof settlement.transaction !== "string" + ) { + return null; + } + return settlement as SettleResponse; +} + +export function createLeashProxyServer(options: LeashProxyOptions) { + const server = new Server( + { name: "leash-mcp", version: "0.0.1" }, + { capabilities: { tools: {} } }, + ); + + server.setRequestHandler(ListToolsRequestSchema, (request, extra) => + options.upstream.listTools(request.params, { signal: extra.signal }), + ); + + server.setRequestHandler(CallToolRequestSchema, (request, extra) => + withPaymentOrigin( + { + clientName: server.getClientVersion()?.name ?? "Unknown client", + toolName: request.params.name, + transport: "mcp", + }, + async () => { + let challenge = null; + let initialResult: Awaited> | undefined; + try { + initialResult = await options.upstream.callTool(request.params, undefined, { + signal: extra.signal, + }); + challenge = detectMcpPaymentRequired(initialResult); + } catch (error) { + challenge = detectMcpPaymentRequired(error); + if (!challenge) throw error; + } + if (!challenge) { + if (!initialResult) throw new Error("Upstream tool returned no result"); + return initialResult; + } + + const paymentPayload = await options.paymentClient.createPaymentPayload(challenge); + const paidResult = await options.upstream.callTool( + { + ...request.params, + _meta: { + ...request.params._meta, + [MCP_PAYMENT_META_KEY]: paymentPayload, + }, + }, + undefined, + { signal: extra.signal }, + ); + const settlement = settlementFromResult(paidResult); + if (settlement) { + await options.paymentClient.handlePaymentResponse({ + paymentPayload, + requirements: paymentPayload.accepted, + settleResponse: settlement, + }); + } + return paidResult; + }, + ), + ); + + return server; +} diff --git a/apps/agent/src/remote-signer.test.ts b/apps/agent/src/remote-signer.test.ts new file mode 100644 index 0000000..51e3c39 --- /dev/null +++ b/apps/agent/src/remote-signer.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; + +import { LeashRemoteSigner, type RemoteSignerError } from "./remote-signer.js"; + +const signature = `0x${"ab".repeat(65)}` as const; +const signerRequest = { + domain: { + chainId: 8453, + name: "USD Coin", + verifyingContract: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + version: "2", + }, + message: { + from: "0x2222222222222222222222222222222222222222", + nonce: `0x${"12".repeat(32)}`, + to: "0x1111111111111111111111111111111111111111", + validAfter: "0", + validBefore: "9999999999", + value: "25000", + }, + primaryType: "TransferWithAuthorization", + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + ], + }, +}; + +describe("Leash remote signer wire", () => { + it("posts exact payment authority and correlates the returned receipt", async () => { + const fetch = vi.fn(async (_input: Request | string | URL, init?: RequestInit) => { + expect(init?.headers).toMatchObject({ authorization: "Bearer leash_sk_secret" }); + expect(JSON.parse(String(init?.body))).toEqual({ + amount: "25000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + network: "eip155:8453", + origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, + payTo: "0x1111111111111111111111111111111111111111", + signerRequest, + }); + return Response.json({ receiptId: "receipt-1", signature }); + }); + const signer = new LeashRemoteSigner({ + address: "0x2222222222222222222222222222222222222222", + apiBaseUrl: "https://tab.example.test/", + apiKey: "leash_sk_secret", + fetch, + origin: () => ({ clientName: "Claude Code", toolName: "search", transport: "mcp" }), + }); + + await expect(signer.signTypedData(signerRequest)).resolves.toBe(signature); + expect(signer.takeReceiptId(signature)).toBe("receipt-1"); + expect(signer.takeReceiptId(signature)).toBeNull(); + }); + + it("preserves a fail-closed backend error code without inventing a signature", async () => { + const signer = new LeashRemoteSigner({ + address: "0x2222222222222222222222222222222222222222", + apiBaseUrl: "https://tab.example.test", + apiKey: "leash_sk_secret", + fetch: async () => + Response.json( + { error: { code: "SIGNER_NOT_CONFIGURED", message: "Signer is not configured." } }, + { status: 409 }, + ), + }); + + await expect(signer.signTypedData(signerRequest)).rejects.toMatchObject({ + code: "SIGNER_NOT_CONFIGURED", + status: 409, + } satisfies Partial); + }); +}); diff --git a/apps/agent/src/remote-signer.ts b/apps/agent/src/remote-signer.ts new file mode 100644 index 0000000..401ea95 --- /dev/null +++ b/apps/agent/src/remote-signer.ts @@ -0,0 +1,169 @@ +import type { PaymentResponseContext } from "@x402/core/client"; +import type { ClientEvmSigner } from "@x402/evm"; +import { isAddress } from "viem"; + +import { currentPaymentOrigin } from "./origin-context.js"; +import { ARBITRUM_NETWORK, BASE_NETWORK } from "./routing.js"; + +export interface PaymentOrigin { + clientName: string; + toolName: string; + transport: "http" | "mcp"; +} + +interface RemoteSignerOptions { + address: `0x${string}`; + apiBaseUrl: string; + apiKey: string; + fetch?: typeof globalThis.fetch; + origin?: () => PaymentOrigin | undefined; +} + +interface SignerRequest { + domain: Record; + message: Record; + primaryType: string; + types: Record; +} + +export class RemoteSignerError extends Error { + constructor( + readonly code: string, + message: string, + readonly status: number, + ) { + super(message); + this.name = "RemoteSignerError"; + } +} + +function stringValue(value: unknown, field: string) { + if (typeof value === "bigint") return value.toString(); + if (typeof value === "number" && Number.isSafeInteger(value)) return String(value); + if (typeof value === "string" && value.length > 0) return value; + throw new RemoteSignerError("INVALID_SIGNER_REQUEST", `${field} is invalid.`, 400); +} + +function networkFromChainId(value: unknown) { + const chainId = stringValue(value, "domain.chainId"); + if (chainId === "8453") return BASE_NETWORK; + if (chainId === "42161") return ARBITRUM_NETWORK; + throw new RemoteSignerError("UNSUPPORTED_NETWORK", "The signing network is unsupported.", 400); +} + +function addressValue(value: unknown, field: string) { + if (typeof value !== "string" || !isAddress(value)) { + throw new RemoteSignerError("INVALID_SIGNER_REQUEST", `${field} is invalid.`, 400); + } + return value; +} + +function jsonBody(value: unknown) { + return JSON.stringify(value, (_key, field) => + typeof field === "bigint" ? field.toString() : field, + ); +} + +async function responseError(response: Response) { + try { + const body = (await response.json()) as { error?: { code?: unknown; message?: unknown } }; + if (typeof body.error?.code === "string" && typeof body.error.message === "string") { + return new RemoteSignerError(body.error.code, body.error.message, response.status); + } + } catch { + // The status still fails closed below when an upstream body is not JSON. + } + return new RemoteSignerError( + "SIGNER_REQUEST_FAILED", + "The signer request failed.", + response.status, + ); +} + +export class LeashRemoteSigner implements ClientEvmSigner { + readonly address: `0x${string}`; + readonly #apiKey: string; + readonly #endpoint: URL; + readonly #fetch: typeof globalThis.fetch; + readonly #origin: (() => PaymentOrigin | undefined) | undefined; + readonly #resultEndpoint: URL; + readonly #receiptBySignature = new Map(); + + constructor(options: RemoteSignerOptions) { + if (!isAddress(options.address)) throw new Error("Leash signer address is invalid"); + if (!options.apiKey) throw new Error("LEASH_API_KEY is required"); + this.address = options.address; + this.#apiKey = options.apiKey; + this.#endpoint = new URL("/api/agent/sign", options.apiBaseUrl); + this.#fetch = options.fetch ?? globalThis.fetch; + this.#origin = options.origin ?? currentPaymentOrigin; + this.#resultEndpoint = new URL("/api/agent/pay/result", options.apiBaseUrl); + } + + async signTypedData(signerRequest: SignerRequest): Promise<`0x${string}`> { + const network = networkFromChainId(signerRequest.domain.chainId); + const amount = stringValue(signerRequest.message.value, "message.value"); + const payTo = addressValue(signerRequest.message.to, "message.to"); + const asset = addressValue(signerRequest.domain.verifyingContract, "domain.verifyingContract"); + const origin = this.#origin?.(); + const response = await this.#fetch(this.#endpoint, { + body: jsonBody({ + amount, + asset, + network, + ...(origin ? { origin } : {}), + payTo, + signerRequest, + }), + headers: { authorization: `Bearer ${this.#apiKey}`, "content-type": "application/json" }, + method: "POST", + }); + if (!response.ok) throw await responseError(response); + + const body = (await response.json()) as { receiptId?: unknown; signature?: unknown }; + if ( + typeof body.receiptId !== "string" || + typeof body.signature !== "string" || + !/^0x[0-9a-fA-F]{130}$/.test(body.signature) + ) { + throw new RemoteSignerError( + "INVALID_SIGNER_RESPONSE", + "The signer response is invalid.", + 502, + ); + } + this.#receiptBySignature.set(body.signature, body.receiptId); + return body.signature as `0x${string}`; + } + + takeReceiptId(signature: string) { + const receiptId = this.#receiptBySignature.get(signature) ?? null; + this.#receiptBySignature.delete(signature); + return receiptId; + } + + async reportSettledPayment(context: PaymentResponseContext) { + const settlement = context.settleResponse; + const signature = context.paymentPayload.payload.signature; + if ( + settlement?.success !== true || + typeof signature !== "string" || + settlement.network !== context.requirements.network || + !isAddress(settlement.payer ?? "") || + settlement.payer?.toLowerCase() !== this.address.toLowerCase() || + !/^0x[0-9a-fA-F]{64}$/.test(settlement.transaction) + ) { + return; + } + const receiptId = this.#receiptBySignature.get(signature); + if (!receiptId) return; + + const response = await this.#fetch(this.#resultEndpoint, { + body: JSON.stringify({ outcome: "settled", paymentResponse: settlement, receiptId }), + headers: { authorization: `Bearer ${this.#apiKey}`, "content-type": "application/json" }, + method: "POST", + }); + if (!response.ok) throw await responseError(response); + this.#receiptBySignature.delete(signature); + } +} diff --git a/apps/agent/src/routing.test.ts b/apps/agent/src/routing.test.ts new file mode 100644 index 0000000..c020530 --- /dev/null +++ b/apps/agent/src/routing.test.ts @@ -0,0 +1,64 @@ +import type { PaymentRequirements } from "@x402/core/types"; +import { describe, expect, it } from "vitest"; + +import { + BASE_NETWORK, + selectLeashPaymentRequirements, + UnsupportedPaymentNetworkError, +} from "./routing.js"; + +function requirement(network: `${string}:${string}`, amount = "1000"): PaymentRequirements { + return { + amount, + asset: + network === "eip155:42161" + ? "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" + : "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + extra: { name: "USD Coin", version: "2" }, + maxTimeoutSeconds: 60, + network, + payTo: "0x1111111111111111111111111111111111111111", + scheme: "exact", + }; +} + +describe("CAIP-2 float routing", () => { + it("prefers Base when a resource accepts both covered floats", () => { + const selected = selectLeashPaymentRequirements(2, [ + requirement("eip155:42161"), + requirement(BASE_NETWORK), + ]); + + expect(selected.network).toBe(BASE_NETWORK); + }); + + it("selects Arbitrum when it is the covered option", () => { + expect(selectLeashPaymentRequirements(2, [requirement("eip155:42161")]).network).toBe( + "eip155:42161", + ); + }); + + it("fails closed for Polygon, Solana, and unknown networks", () => { + expect(() => + selectLeashPaymentRequirements(2, [requirement("eip155:137"), requirement("solana:mainnet")]), + ).toThrow(UnsupportedPaymentNetworkError); + }); + + it("rejects Permit2 requirements even on a supported network", () => { + const permit2 = requirement(BASE_NETWORK); + permit2.extra = { assetTransferMethod: "permit2" }; + + expect(() => selectLeashPaymentRequirements(2, [permit2])).toThrow( + UnsupportedPaymentNetworkError, + ); + }); + + it("rejects non-USDC assets on a supported network", () => { + const otherToken = requirement(BASE_NETWORK); + otherToken.asset = "0x3333333333333333333333333333333333333333"; + + expect(() => selectLeashPaymentRequirements(2, [otherToken])).toThrow( + UnsupportedPaymentNetworkError, + ); + }); +}); diff --git a/apps/agent/src/routing.ts b/apps/agent/src/routing.ts new file mode 100644 index 0000000..55f8708 --- /dev/null +++ b/apps/agent/src/routing.ts @@ -0,0 +1,48 @@ +import type { PaymentRequirements } from "@x402/core/types"; + +export const BASE_NETWORK = "eip155:8453" as const; +export const ARBITRUM_NETWORK = "eip155:42161" as const; +export const BASE_V1_NETWORK = "base"; +export const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +export const ARBITRUM_USDC = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"; + +export class UnsupportedPaymentNetworkError extends Error { + readonly code = "UNSUPPORTED_NETWORK"; + + constructor() { + super("The resource does not accept a supported Leash payment network."); + this.name = "UnsupportedPaymentNetworkError"; + } +} + +function isSupportedAsset(requirement: PaymentRequirements) { + const asset = requirement.asset.toLowerCase(); + if (requirement.network === BASE_NETWORK) return asset === BASE_USDC.toLowerCase(); + if (requirement.network === ARBITRUM_NETWORK) return asset === ARBITRUM_USDC.toLowerCase(); + if (String(requirement.network) === BASE_V1_NETWORK) return asset === BASE_USDC.toLowerCase(); + return false; +} + +export function selectLeashPaymentRequirements( + x402Version: number, + requirements: PaymentRequirements[], +) { + const exact = requirements.filter( + (requirement) => + requirement.scheme === "exact" && + isSupportedAsset(requirement) && + (requirement.extra.assetTransferMethod === undefined || + requirement.extra.assetTransferMethod === "eip3009"), + ); + if (x402Version === 1) { + const base = exact.find((requirement) => String(requirement.network) === BASE_V1_NETWORK); + if (base) return base; + throw new UnsupportedPaymentNetworkError(); + } + + for (const network of [BASE_NETWORK, ARBITRUM_NETWORK]) { + const match = exact.find((requirement) => requirement.network === network); + if (match) return match; + } + throw new UnsupportedPaymentNetworkError(); +} From bd59fceed7daf4964047fcc2537bb2417963569a Mon Sep 17 00:00:00 2001 From: Blockchain-Oracle Date: Fri, 17 Jul 2026 09:40:35 +0200 Subject: [PATCH 2/6] feat(web): add Leash signer control plane --- .../agent/connect/route.integration.test.ts | 263 ++ apps/web/app/api/agent/connect/route.ts | 54 + .../pay/result/route.integration.test.ts | 238 ++ apps/web/app/api/agent/pay/result/route.ts | 59 + .../api/agent/sign/route.integration.test.ts | 206 ++ apps/web/app/api/agent/sign/route.ts | 80 + apps/web/drizzle/0015_wandering_dagger.sql | 165 + apps/web/drizzle/meta/0015_snapshot.json | 2830 +++++++++++++++++ apps/web/drizzle/meta/_journal.json | 7 + .../lib/auth/leash-key.integration.test.ts | 216 ++ apps/web/lib/auth/leash-key.test.ts | 42 + apps/web/lib/auth/leash-key.ts | 195 ++ .../lib/db/leash-schema.integration.test.ts | 252 ++ apps/web/lib/db/leash-schema.ts | 298 ++ apps/web/lib/db/schema.ts | 1 + apps/web/lib/leash/connect.ts | 133 + .../leash/float-balance.integration.test.ts | 64 + apps/web/lib/leash/float-balance.ts | 54 + .../pay-result-store.integration.test.ts | 138 + apps/web/lib/leash/pay-result-store.ts | 116 + .../settlement-evidence.integration.test.ts | 195 ++ apps/web/lib/leash/settlement-evidence.ts | 172 + apps/web/lib/leash/sign-request.test.ts | 97 + apps/web/lib/leash/sign-request.ts | 183 ++ .../lib/leash/sign-store.integration.test.ts | 224 ++ apps/web/lib/leash/sign-store.ts | 258 ++ 26 files changed, 6540 insertions(+) create mode 100644 apps/web/app/api/agent/connect/route.integration.test.ts create mode 100644 apps/web/app/api/agent/connect/route.ts create mode 100644 apps/web/app/api/agent/pay/result/route.integration.test.ts create mode 100644 apps/web/app/api/agent/pay/result/route.ts create mode 100644 apps/web/app/api/agent/sign/route.integration.test.ts create mode 100644 apps/web/app/api/agent/sign/route.ts create mode 100644 apps/web/drizzle/0015_wandering_dagger.sql create mode 100644 apps/web/drizzle/meta/0015_snapshot.json create mode 100644 apps/web/lib/auth/leash-key.integration.test.ts create mode 100644 apps/web/lib/auth/leash-key.test.ts create mode 100644 apps/web/lib/auth/leash-key.ts create mode 100644 apps/web/lib/db/leash-schema.integration.test.ts create mode 100644 apps/web/lib/db/leash-schema.ts create mode 100644 apps/web/lib/leash/connect.ts create mode 100644 apps/web/lib/leash/float-balance.integration.test.ts create mode 100644 apps/web/lib/leash/float-balance.ts create mode 100644 apps/web/lib/leash/pay-result-store.integration.test.ts create mode 100644 apps/web/lib/leash/pay-result-store.ts create mode 100644 apps/web/lib/leash/settlement-evidence.integration.test.ts create mode 100644 apps/web/lib/leash/settlement-evidence.ts create mode 100644 apps/web/lib/leash/sign-request.test.ts create mode 100644 apps/web/lib/leash/sign-request.ts create mode 100644 apps/web/lib/leash/sign-store.integration.test.ts create mode 100644 apps/web/lib/leash/sign-store.ts diff --git a/apps/web/app/api/agent/connect/route.integration.test.ts b/apps/web/app/api/agent/connect/route.integration.test.ts new file mode 100644 index 0000000..ad0e6fd --- /dev/null +++ b/apps/web/app/api/agent/connect/route.integration.test.ts @@ -0,0 +1,263 @@ +import { randomUUID } from "node:crypto"; + +import { eq } from "drizzle-orm"; +import { NextRequest } from "next/server"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { issueLeashKey } from "../../../../lib/auth/leash-key"; +import { createDatabase } from "../../../../lib/db/client"; +import { agentEvents, agents, leashKeys, users } from "../../../../lib/db/schema"; +import { closeServerDatabase } from "../../../../lib/db/server"; +import { POST } from "./route"; + +const databaseUrl = process.env.DATABASE_URL; +const appOrigin = new URL(process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost").origin; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required for agent connect integration tests"); +} + +const connection = createDatabase(databaseUrl, 4); + +type AgentStatus = "provisioned" | "paused" | "frozen" | "cancelled" | "nuked"; + +function request(body: unknown, secret?: string) { + return new NextRequest(`${appOrigin}/api/agent/connect`, { + body: typeof body === "string" ? body : JSON.stringify(body), + headers: { + ...(secret ? { authorization: `Bearer ${secret}` } : {}), + "content-type": "application/json", + }, + method: "POST", + }); +} + +async function provisionAgent(label: string, status: AgentStatus = "provisioned") { + const [user] = await connection.db + .insert(users) + .values({ + email: `${label}-${randomUUID()}@example.test`, + magicIssuer: `did:ethr:${randomUUID()}`, + }) + .returning({ id: users.id }); + if (!user) throw new Error("PostgreSQL did not return the Leash owner"); + + const [agent] = await connection.db + .insert(agents) + .values({ + name: `${label} agent`, + ownerId: user.id, + signerSubject: `leash:${randomUUID()}`, + status, + }) + .returning({ id: agents.id }); + if (!agent) throw new Error("PostgreSQL did not return the Leash agent"); + + const key = await issueLeashKey(connection.db, { agentId: agent.id }); + return { agentId: agent.id, keyId: key.key.id, secret: key.secret }; +} + +async function agentRow(agentId: string) { + const [row] = await connection.db.select().from(agents).where(eq(agents.id, agentId)); + return row; +} + +async function eventsFor(agentId: string) { + return connection.db.select().from(agentEvents).where(eq(agentEvents.agentId, agentId)); +} + +describe("POST /api/agent/connect with real PostgreSQL", () => { + beforeEach(async () => { + await connection.client`truncate table users cascade`; + }); + + afterAll(async () => { + await closeServerDatabase(); + await connection.client.end(); + }); + + it("returns the same no-store 401 for missing, malformed, unknown, and revoked keys", async () => { + const provisioned = await provisionAgent("unauthorized"); + await connection.db + .update(leashKeys) + .set({ revokedAt: new Date() }) + .where(eq(leashKeys.id, provisioned.keyId)); + + const bodies = await Promise.all([ + POST(request({ transport: "mcp" })), + POST(request({ transport: "mcp" }, "not-a-key")), + POST(request({ transport: "mcp" }, `leash_sk_${"z".repeat(43)}`)), + POST(request({ transport: "mcp" }, provisioned.secret)), + ]); + + for (const response of bodies) { + expect(response.status).toBe(401); + expect(response.headers.get("cache-control")).toBe("no-store"); + await expect(response.json()).resolves.toEqual({ + error: { code: "UNAUTHORIZED", message: "Authentication is required." }, + }); + } + expect((await agentRow(provisioned.agentId))?.connectionCount).toBe(0); + await expect(eventsFor(provisioned.agentId)).resolves.toEqual([]); + }); + + it("strictly rejects malformed, extra, empty, and oversized client input", async () => { + const provisioned = await provisionAgent("invalid-input"); + const invalidBodies = [ + "{", + " ".repeat(2_049), + {}, + { transport: "stdio" }, + { extra: true, transport: "mcp" }, + { clientInfo: null, transport: "mcp" }, + { clientInfo: {}, transport: "mcp" }, + { clientInfo: { extra: true, name: "Claude Code" }, transport: "mcp" }, + { clientInfo: { name: "" }, transport: "mcp" }, + { clientInfo: { name: " ".repeat(10) }, transport: "mcp" }, + { clientInfo: { name: "x".repeat(201) }, transport: "mcp" }, + { clientInfo: { name: "Claude Code", version: "" }, transport: "mcp" }, + { clientInfo: { name: "Claude Code", version: "x".repeat(101) }, transport: "mcp" }, + ]; + + for (const body of invalidBodies) { + const response = await POST(request(body, provisioned.secret)); + expect(response.status).toBe(400); + expect(response.headers.get("cache-control")).toBe("no-store"); + await expect(response.json()).resolves.toEqual({ + error: { code: "INVALID_CONNECT_REQUEST", message: "The connect request is invalid." }, + }); + } + + expect((await agentRow(provisioned.agentId))?.connectionCount).toBe(0); + await expect(eventsFor(provisioned.agentId)).resolves.toEqual([]); + }); + + it("stores absent identity as null and renders only the response as Unknown client", async () => { + const provisioned = await provisionAgent("unknown-client", "frozen"); + const response = await POST(request({ transport: "http" }, provisioned.secret)); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(body).toEqual({ + agent: { address: null }, + client: { + connectionCount: 1, + firstSeenAt: expect.any(String), + lastSeenAt: expect.any(String), + name: "Unknown client", + transport: "http", + version: null, + }, + }); + expect(await agentRow(provisioned.agentId)).toMatchObject({ + clientName: null, + clientVersion: null, + connectionCount: 1, + firstSeenAt: new Date(body.client.firstSeenAt), + lastSeenAt: new Date(body.client.lastSeenAt), + transport: "http", + }); + await expect(eventsFor(provisioned.agentId)).resolves.toEqual([ + expect.objectContaining({ + actorSurface: "agent", + metadata: { + clientName: null, + clientVersion: null, + connectionCount: 1, + transport: "http", + }, + type: "connect", + }), + ]); + expect(JSON.stringify(await eventsFor(provisioned.agentId))).not.toContain(provisioned.secret); + }); + + it("preserves raw bounded client identity and advances the canonical latest view", async () => { + const provisioned = await provisionAgent("known-client", "paused"); + await connection.db + .update(agents) + .set({ agentAddress: "0x2222222222222222222222222222222222222222" }) + .where(eq(agents.id, provisioned.agentId)); + const firstResponse = await POST( + request( + { clientInfo: { name: " Claude Code ", version: " 1.2.3 " }, transport: "mcp" }, + provisioned.secret, + ), + ); + const first = await firstResponse.json(); + const secondResponse = await POST(request({ transport: "http" }, provisioned.secret)); + const second = await secondResponse.json(); + + expect(firstResponse.status).toBe(200); + expect(first.agent).toEqual({ + address: "0x2222222222222222222222222222222222222222", + }); + expect(first.client).toMatchObject({ + connectionCount: 1, + name: " Claude Code ", + transport: "mcp", + version: " 1.2.3 ", + }); + expect(secondResponse.status).toBe(200); + expect(second.client).toMatchObject({ + connectionCount: 2, + firstSeenAt: first.client.firstSeenAt, + name: "Unknown client", + transport: "http", + version: null, + }); + expect(Date.parse(second.client.lastSeenAt)).toBeGreaterThanOrEqual( + Date.parse(first.client.lastSeenAt), + ); + expect(await agentRow(provisioned.agentId)).toMatchObject({ + clientName: null, + clientVersion: null, + connectionCount: 2, + firstSeenAt: new Date(first.client.firstSeenAt), + lastSeenAt: new Date(second.client.lastSeenAt), + transport: "http", + }); + await expect(eventsFor(provisioned.agentId)).resolves.toHaveLength(2); + }); + + it("allows every lifecycle status to report a connection", async () => { + for (const status of ["provisioned", "paused", "frozen", "cancelled", "nuked"] as const) { + const provisioned = await provisionAgent(`status-${status}`, status); + const response = await POST(request({ transport: "mcp" }, provisioned.secret)); + + expect(response.status).toBe(200); + expect((await agentRow(provisioned.agentId))?.connectionCount).toBe(1); + } + }); + + it("serializes concurrent reports without losing counts or audit events", async () => { + const provisioned = await provisionAgent("concurrent"); + const responses = await Promise.all( + Array.from({ length: 8 }, (_, index) => + POST( + request( + { + clientInfo: { name: `client-${index}`, version: `${index}` }, + transport: index % 2 === 0 ? "mcp" : "http", + }, + provisioned.secret, + ), + ), + ), + ); + const counts = await Promise.all( + responses.map(async (response) => { + expect(response.status).toBe(200); + return (await response.json()).client.connectionCount as number; + }), + ); + + expect(counts.toSorted((left, right) => left - right)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + const stored = await agentRow(provisioned.agentId); + expect(stored?.connectionCount).toBe(8); + expect(stored?.firstSeenAt).toBeInstanceOf(Date); + expect(stored?.lastSeenAt).toBeInstanceOf(Date); + await expect(eventsFor(provisioned.agentId)).resolves.toHaveLength(8); + }); +}); diff --git a/apps/web/app/api/agent/connect/route.ts b/apps/web/app/api/agent/connect/route.ts new file mode 100644 index 0000000..6ba9463 --- /dev/null +++ b/apps/web/app/api/agent/connect/route.ts @@ -0,0 +1,54 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { authenticateLeashKey, InvalidLeashKeyError } from "../../../../lib/auth/leash-key"; +import { getServerDatabase } from "../../../../lib/db/server"; +import { + connectAgent, + InvalidConnectRequestError, + parseConnectRequest, +} from "../../../../lib/leash/connect"; + +const NO_STORE = { "cache-control": "no-store" }; + +function error(code: string, message: string, status: number) { + return NextResponse.json({ error: { code, message } }, { headers: NO_STORE, status }); +} + +export async function POST(request: NextRequest) { + const db = getServerDatabase().db; + let agentId: string; + try { + ({ agentId } = await authenticateLeashKey(db, request.headers.get("authorization"))); + } catch (authError) { + if (authError instanceof InvalidLeashKeyError) { + return error("UNAUTHORIZED", "Authentication is required.", 401); + } + throw authError; + } + + let input: ReturnType; + try { + input = parseConnectRequest(await request.text()); + } catch (parseError) { + if (parseError instanceof InvalidConnectRequestError) { + return error("INVALID_CONNECT_REQUEST", "The connect request is invalid.", 400); + } + throw parseError; + } + + const connected = await connectAgent(db, { agentId, ...input }); + return NextResponse.json( + { + agent: { address: connected.agentAddress }, + client: { + connectionCount: connected.connectionCount, + firstSeenAt: connected.firstSeenAt.toISOString(), + lastSeenAt: connected.lastSeenAt.toISOString(), + name: connected.clientName ?? "Unknown client", + transport: connected.transport, + version: connected.clientVersion, + }, + }, + { headers: NO_STORE, status: 200 }, + ); +} diff --git a/apps/web/app/api/agent/pay/result/route.integration.test.ts b/apps/web/app/api/agent/pay/result/route.integration.test.ts new file mode 100644 index 0000000..ca9f132 --- /dev/null +++ b/apps/web/app/api/agent/pay/result/route.integration.test.ts @@ -0,0 +1,238 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { createServer } from "node:http"; + +import { NextRequest } from "next/server"; +import { encodeAbiParameters, encodeEventTopics } from "viem"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { issueLeashKey } from "../../../../../lib/auth/leash-key"; +import { createDatabase } from "../../../../../lib/db/client"; +import { agents, capCycles, receipts, users } from "../../../../../lib/db/schema"; +import { closeServerDatabase } from "../../../../../lib/db/server"; +import { POST } from "./route"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for pay-result route tests"); +const connection = createDatabase(databaseUrl, 2); +const agentAddress = "0x2222222222222222222222222222222222222222"; +const payTo = "0x1111111111111111111111111111111111111111"; +const facilitator = "0x3333333333333333333333333333333333333333"; +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const nonce = `0x${"12".repeat(32)}` as const; +const transaction = `0x${"ab".repeat(32)}` as const; +const blockHash = `0x${"cd".repeat(32)}`; +const originalRpcUrl = process.env.BASE_RPC_URL; + +const events = [ + { + anonymous: false, + inputs: [ + { indexed: true, name: "from", type: "address" }, + { indexed: true, name: "to", type: "address" }, + { indexed: false, name: "value", type: "uint256" }, + ], + name: "Transfer", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, name: "authorizer", type: "address" }, + { indexed: true, name: "nonce", type: "bytes32" }, + ], + name: "AuthorizationUsed", + type: "event", + }, +] as const; + +function rpcLog(topics: ReturnType, data: `0x${string}`, index: string) { + return { + address: baseUsdc, + blockHash, + blockNumber: "0x1", + data, + logIndex: index, + removed: false, + topics: topics.map((topic) => { + if (typeof topic !== "string") throw new Error("Expected encoded event topics"); + return topic; + }), + transactionHash: transaction, + transactionIndex: "0x0", + }; +} + +function rpcReceipt(includeAuthorization: boolean) { + const transfer = rpcLog( + encodeEventTopics({ + abi: events, + args: { from: agentAddress, to: payTo }, + eventName: "Transfer", + }), + encodeAbiParameters([{ type: "uint256" }], [BigInt(25_000)]), + "0x0", + ); + const authorization = rpcLog( + encodeEventTopics({ + abi: events, + args: { authorizer: agentAddress, nonce }, + eventName: "AuthorizationUsed", + }), + "0x", + "0x1", + ); + return { + blockHash, + blockNumber: "0x1", + contractAddress: null, + cumulativeGasUsed: "0x5208", + effectiveGasPrice: "0x1", + from: facilitator, + gasUsed: "0x5208", + logs: includeAuthorization ? [transfer, authorization] : [transfer], + logsBloom: `0x${"00".repeat(256)}`, + status: "0x1", + to: baseUsdc, + transactionHash: transaction, + transactionIndex: "0x0", + type: "0x2", + }; +} + +async function provision() { + const [user] = await connection.db + .insert(users) + .values({ email: `${randomUUID()}@example.test`, magicIssuer: `did:ethr:${randomUUID()}` }) + .returning({ id: users.id }); + if (!user) throw new Error("Expected user"); + const [agent] = await connection.db + .insert(agents) + .values({ + agentAddress, + name: "Result route", + ownerId: user.id, + signerSubject: `leash:${randomUUID()}`, + }) + .returning({ id: agents.id }); + if (!agent) throw new Error("Expected agent"); + const key = await issueLeashKey(connection.db, { agentId: agent.id }); + const [cycle] = await connection.db + .insert(capCycles) + .values({ agentId: agent.id, startedAt: new Date() }) + .returning({ id: capCycles.id }); + if (!cycle) throw new Error("Expected cycle"); + const [receipt] = await connection.db + .insert(receipts) + .values({ + agentId: agent.id, + amountAtomic: "25000", + amountUsd: "0.025000", + asset: baseUsdc, + authorizationNonce: nonce, + authorizationValidBefore: new Date(Date.now() + 300_000), + cycleId: cycle.id, + network: "eip155:8453", + payTo, + requestFingerprint: randomBytes(32).toString("hex"), + }) + .returning({ id: receipts.id }); + if (!receipt) throw new Error("Expected receipt"); + return { agentId: agent.id, receiptId: receipt.id, secret: key.secret }; +} + +function observation(receiptId: string) { + return { + outcome: "observed", + paymentResponse: { + network: "eip155:8453", + payer: agentAddress, + success: true, + transaction, + }, + receiptId, + }; +} + +function request(secret: string | null, body: unknown) { + return new NextRequest("http://localhost/api/agent/pay/result", { + body: JSON.stringify(body), + headers: { + ...(secret ? { authorization: `Bearer ${secret}` } : {}), + "content-type": "application/json", + }, + method: "POST", + }); +} + +describe("POST /api/agent/pay/result", () => { + let includeAuthorization = false; + const server = createServer(async (incoming, response) => { + const chunks: Buffer[] = []; + for await (const chunk of incoming) chunks.push(Buffer.from(chunk)); + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ id: body.id, jsonrpc: "2.0", result: rpcReceipt(includeAuthorization) }), + ); + }); + + beforeAll(async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP listener"); + process.env.BASE_RPC_URL = `http://127.0.0.1:${address.port}`; + }); + + beforeEach(async () => { + includeAuthorization = false; + await connection.client`truncate table users cascade`; + }); + + afterAll(async () => { + if (originalRpcUrl === undefined) delete process.env.BASE_RPC_URL; + else process.env.BASE_RPC_URL = originalRpcUrl; + await closeServerDatabase(); + await connection.client.end(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); + + it("uses a generic 401 and rejects client-declared failure without releasing pending spend", async () => { + const pending = await provision(); + expect((await POST(request(null, observation(pending.receiptId)))).status).toBe(401); + + const response = await POST( + request(pending.secret, { outcome: "failed", receiptId: pending.receiptId }), + ); + expect(response.status).toBe(400); + const [stored] = await connection.db.select({ status: receipts.status }).from(receipts); + expect(stored?.status).toBe("pending"); + }); + + it("keeps a resource claim pending when the exact authorization-use proof is absent", async () => { + const pending = await provision(); + const response = await POST(request(pending.secret, observation(pending.receiptId))); + + expect(response.status).toBe(202); + await expect(response.json()).resolves.toEqual({ + receiptId: pending.receiptId, + status: "pending", + verified: false, + }); + }); + + it("settles from real RPC proof and handles the same callback idempotently", async () => { + const pending = await provision(); + includeAuthorization = true; + + const first = await POST(request(pending.secret, observation(pending.receiptId))); + const second = await POST(request(pending.secret, observation(pending.receiptId))); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + const [stored] = await connection.db + .select({ status: receipts.status, txHash: receipts.txHash }) + .from(receipts); + expect(stored).toEqual({ status: "settled", txHash: transaction }); + }); +}); diff --git a/apps/web/app/api/agent/pay/result/route.ts b/apps/web/app/api/agent/pay/result/route.ts new file mode 100644 index 0000000..944eefd --- /dev/null +++ b/apps/web/app/api/agent/pay/result/route.ts @@ -0,0 +1,59 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { apiError, NO_STORE_HEADERS } from "../../../../../lib/auth/api-key-http"; +import { authenticateLeashKey, InvalidLeashKeyError } from "../../../../../lib/auth/leash-key"; +import { getServerDatabase } from "../../../../../lib/db/server"; +import { + applySettlementObservation, + SettlementResultConflictError, +} from "../../../../../lib/leash/pay-result-store"; +import { + InvalidSettlementObservationError, + parseSettlementObservation, +} from "../../../../../lib/leash/settlement-evidence"; + +export async function POST(request: NextRequest) { + try { + const database = getServerDatabase().db; + const principal = await authenticateLeashKey(database, request.headers.get("authorization")); + let body: unknown; + try { + body = await request.json(); + } catch { + return apiError( + "INVALID_SETTLEMENT_OBSERVATION", + "The settlement observation is invalid.", + 400, + ); + } + const evidence = parseSettlementObservation(body); + const result = await applySettlementObservation(database, { + agentId: principal.agentId, + evidence, + }); + if (result.kind === "not_found") { + return apiError("RECEIPT_NOT_FOUND", "The receipt was not found.", 404); + } + if (result.kind === "pending" || result.kind === "settled") { + return NextResponse.json( + { + receiptId: result.receiptId, + status: result.kind, + verified: result.verified, + }, + { headers: NO_STORE_HEADERS, status: result.kind === "settled" ? 200 : 202 }, + ); + } + return apiError("RECEIPT_NOT_PENDING", "The receipt is not pending.", 409); + } catch (error) { + if (error instanceof InvalidLeashKeyError) { + return apiError(error.code, error.message, 401); + } + if (error instanceof InvalidSettlementObservationError) { + return apiError(error.code, error.message, 400); + } + if (error instanceof SettlementResultConflictError) { + return apiError(error.code, error.message, error.status); + } + throw error; + } +} diff --git a/apps/web/app/api/agent/sign/route.integration.test.ts b/apps/web/app/api/agent/sign/route.integration.test.ts new file mode 100644 index 0000000..59a4a3c --- /dev/null +++ b/apps/web/app/api/agent/sign/route.integration.test.ts @@ -0,0 +1,206 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { createServer } from "node:http"; + +import { NextRequest } from "next/server"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { issueLeashKey } from "../../../../lib/auth/leash-key"; +import { createDatabase } from "../../../../lib/db/client"; +import { agents, capCycles, caps, receipts, users } from "../../../../lib/db/schema"; +import { closeServerDatabase } from "../../../../lib/db/server"; +import { POST } from "./route"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for sign route tests"); +const connection = createDatabase(databaseUrl, 3); +const agentAddress = "0x2222222222222222222222222222222222222222"; +const payTo = "0x1111111111111111111111111111111111111111"; +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const originalRpcUrl = process.env.BASE_RPC_URL; + +async function provision( + options: { capCents?: string | null; status?: "provisioned" | "paused" | "frozen" } = {}, +) { + const [user] = await connection.db + .insert(users) + .values({ email: `${randomUUID()}@example.test`, magicIssuer: `did:ethr:${randomUUID()}` }) + .returning({ id: users.id }); + if (!user) throw new Error("Expected user"); + const [agent] = await connection.db + .insert(agents) + .values({ + agentAddress, + name: "Sign route", + ownerId: user.id, + signerSubject: `leash:${randomUUID()}`, + status: options.status ?? "provisioned", + }) + .returning({ id: agents.id }); + if (!agent) throw new Error("Expected agent"); + const key = await issueLeashKey(connection.db, { agentId: agent.id }); + const [cycle] = await connection.db + .insert(capCycles) + .values({ agentId: agent.id, startedAt: new Date() }) + .returning({ id: capCycles.id }); + if (!cycle) throw new Error("Expected cycle"); + if (options.capCents !== null) { + await connection.db.insert(caps).values({ + agentId: agent.id, + amountUsdCents: options.capCents ?? "100", + frequency: "daily", + }); + } + return { agentId: agent.id, secret: key.secret }; +} + +function signBody(amount = "25000") { + const validBefore = Math.floor(Date.now() / 1_000) + 300; + return { + amount, + asset: baseUsdc, + network: "eip155:8453", + origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, + payTo, + signerRequest: { + domain: { chainId: 8453, name: "USD Coin", verifyingContract: baseUsdc, version: "2" }, + message: { + from: agentAddress, + nonce: `0x${randomBytes(32).toString("hex")}`, + to: payTo, + validAfter: "0", + validBefore: String(validBefore), + value: amount, + }, + primaryType: "TransferWithAuthorization", + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + }, + }; +} + +function request(secret: string | null, body: unknown, raw = false) { + return new NextRequest("http://localhost/api/agent/sign", { + body: raw ? String(body) : JSON.stringify(body), + headers: { + ...(secret ? { authorization: `Bearer ${secret}` } : {}), + "content-type": "application/json", + }, + method: "POST", + }); +} + +describe("POST /api/agent/sign", () => { + let liveBalance = BigInt(1_000_000); + const rpcMethods: string[] = []; + const server = createServer(async (incoming, response) => { + const chunks: Buffer[] = []; + for await (const chunk of incoming) chunks.push(Buffer.from(chunk)); + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + rpcMethods.push(body.method); + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + id: body.id, + jsonrpc: "2.0", + result: `0x${liveBalance.toString(16).padStart(64, "0")}`, + }), + ); + }); + + beforeAll(async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP listener"); + process.env.BASE_RPC_URL = `http://127.0.0.1:${address.port}`; + }); + + beforeEach(async () => { + liveBalance = BigInt(1_000_000); + rpcMethods.length = 0; + await connection.client`truncate table users cascade`; + }); + + afterAll(async () => { + if (originalRpcUrl === undefined) delete process.env.BASE_RPC_URL; + else process.env.BASE_RPC_URL = originalRpcUrl; + await closeServerDatabase(); + await connection.client.end(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); + + it("authenticates first and applies the status gate before malformed JSON", async () => { + const paused = await provision({ status: "paused" }); + const unknown = await POST(request(null, "{", true)); + expect(unknown.status).toBe(401); + + const response = await POST(request(paused.secret, "{", true)); + expect(response.status).toBe(423); + await expect(response.json()).resolves.toMatchObject({ error: { code: "AGENT_PAUSED" } }); + expect(rpcMethods).toHaveLength(0); + }); + + it("rejects malformed authority and no-cap policy before touching RPC", async () => { + const configured = await provision(); + expect((await POST(request(configured.secret, { arbitrary: "typed data" }))).status).toBe(400); + + await connection.client`truncate table users cascade`; + const noCap = await provision({ capCents: null }); + const response = await POST(request(noCap.secret, signBody())); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ error: { code: "LEASH_CAP_NOT_SET" } }); + expect(rpcMethods).toHaveLength(0); + }); + + it("writes cap-exceeded as blocked without reading or signing", async () => { + const identity = await provision({ capCents: "1" }); + const response = await POST(request(identity.secret, signBody("25000"))); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "LEASH_CAP_EXCEEDED" }, + }); + const [stored] = await connection.db + .select({ intendedNetwork: receipts.intendedNetwork, status: receipts.status }) + .from(receipts); + expect(stored).toEqual({ intendedNetwork: "eip155:8453", status: "blocked" }); + expect(rpcMethods).toHaveLength(0); + }); + + it("reads live native-USDC balance and records a proven empty float", async () => { + const identity = await provision(); + liveBalance = BigInt(0); + const response = await POST(request(identity.secret, signBody())); + + expect(response.status).toBe(402); + await expect(response.json()).resolves.toMatchObject({ error: { code: "FLOAT_EMPTY" } }); + const [stored] = await connection.db + .select({ reason: receipts.reason, status: receipts.status }) + .from(receipts); + expect(stored).toEqual({ reason: "FLOAT_EMPTY", status: "failed" }); + expect(rpcMethods).toEqual(["eth_call"]); + }); + + it("returns the honest signer block with a failed receipt and no signature or hash", async () => { + const identity = await provision(); + const response = await POST(request(identity.secret, signBody())); + const body = await response.json(); + + expect(response.status).toBe(503); + expect(body).toMatchObject({ error: { code: "SIGNER_NOT_CONFIGURED" } }); + expect(JSON.stringify(body)).not.toContain("signature"); + const [stored] = await connection.db + .select({ reason: receipts.reason, status: receipts.status, txHash: receipts.txHash }) + .from(receipts); + expect(stored).toEqual({ reason: "SIGNER_NOT_CONFIGURED", status: "failed", txHash: null }); + }); +}); diff --git a/apps/web/app/api/agent/sign/route.ts b/apps/web/app/api/agent/sign/route.ts new file mode 100644 index 0000000..ebe1271 --- /dev/null +++ b/apps/web/app/api/agent/sign/route.ts @@ -0,0 +1,80 @@ +import type { NextRequest } from "next/server"; +import { apiError } from "../../../../lib/auth/api-key-http"; +import { authenticateLeashKey, InvalidLeashKeyError } from "../../../../lib/auth/leash-key"; +import { getServerDatabase } from "../../../../lib/db/server"; +import { readFloatBalance } from "../../../../lib/leash/float-balance"; +import { InvalidSignRequestError } from "../../../../lib/leash/sign-request"; +import { + completePreSigningChecks, + reserveSignRequest, + SignGateError, +} from "../../../../lib/leash/sign-store"; + +const MAX_SIGN_REQUEST_BYTES = 64 * 1_024; + +function statusForCode(code: string) { + if (code === "INVALID_LEASH_KEY") return 401; + if (code.startsWith("AGENT_")) return 423; + if (code === "FLOAT_EMPTY") return 402; + if (code === "LEASH_CAP_EXCEEDED" || code === "LEASH_CAP_NOT_SET") return 403; + if (code === "SIGNER_NOT_CONFIGURED" || code === "FLOAT_CHECK_UNAVAILABLE") return 503; + return 409; +} + +function signError(code: string, status = statusForCode(code)) { + return apiError(code, "The signing request cannot proceed.", status); +} + +export async function POST(request: NextRequest) { + try { + const database = getServerDatabase().db; + const principal = await authenticateLeashKey(database, request.headers.get("authorization")); + const body = await request.text(); + if (Buffer.byteLength(body, "utf8") > MAX_SIGN_REQUEST_BYTES) { + return signError("INVALID_SIGN_REQUEST", 400); + } + + const reservation = await reserveSignRequest(database, { + agentId: principal.agentId, + body, + keyId: principal.leashKeyId, + }); + if (reservation.kind !== "pending") { + return signError(reservation.code ?? "SIGN_REQUEST_CONFLICT"); + } + + let liveBalanceAtomic: bigint; + try { + liveBalanceAtomic = await readFloatBalance({ + address: reservation.agentAddress, + network: reservation.network, + }); + } catch { + return signError("FLOAT_CHECK_UNAVAILABLE", 503); + } + + const checked = await completePreSigningChecks(database, { + agentId: principal.agentId, + keyId: principal.leashKeyId, + liveBalanceAtomic, + receiptId: reservation.receiptId, + signerAvailable: false, + }); + if (checked.kind !== "ready") { + return signError(checked.code ?? "SIGN_REQUEST_CONFLICT"); + } + + return signError("SIGNER_NOT_CONFIGURED", 503); + } catch (error) { + if (error instanceof InvalidLeashKeyError) { + return apiError(error.code, error.message, 401); + } + if (error instanceof InvalidSignRequestError) { + return apiError(error.code, error.message, 400); + } + if (error instanceof SignGateError) { + return signError(error.code, error.status); + } + throw error; + } +} diff --git a/apps/web/drizzle/0015_wandering_dagger.sql b/apps/web/drizzle/0015_wandering_dagger.sql new file mode 100644 index 0000000..7c89ad0 --- /dev/null +++ b/apps/web/drizzle/0015_wandering_dagger.sql @@ -0,0 +1,165 @@ +CREATE TYPE "public"."agent_event_surface" AS ENUM('agent', 'web', 'pwa', 'push_action', 'system');--> statement-breakpoint +CREATE TYPE "public"."agent_event_type" AS ENUM('connect', 'sign', 'block', 'revoke');--> statement-breakpoint +CREATE TYPE "public"."agent_status" AS ENUM('provisioned', 'paused', 'frozen', 'cancelled', 'nuked');--> statement-breakpoint +CREATE TYPE "public"."cap_frequency" AS ENUM('daily', 'weekly', 'monthly', 'never');--> statement-breakpoint +CREATE TYPE "public"."cap_reset_reason" AS ENUM('schedule', 'manual', 'frequency_change');--> statement-breakpoint +CREATE TYPE "public"."leash_asset" AS ENUM('USDC');--> statement-breakpoint +CREATE TYPE "public"."leash_network" AS ENUM('eip155:8453', 'eip155:42161');--> statement-breakpoint +CREATE TYPE "public"."receipt_status" AS ENUM('pending', 'settled', 'failed', 'blocked');--> statement-breakpoint +CREATE TABLE "agent_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "agent_id" uuid NOT NULL, + "type" "agent_event_type" NOT NULL, + "actor_surface" "agent_event_surface" NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "agent_events_metadata_check" CHECK (jsonb_typeof("agent_events"."metadata") = 'object') +); +--> statement-breakpoint +CREATE TABLE "agents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "owner_id" uuid NOT NULL, + "name" text NOT NULL, + "status" "agent_status" DEFAULT 'provisioned' NOT NULL, + "signer_subject" text NOT NULL, + "agent_address" varchar(42), + "client_name" text, + "client_version" text, + "transport" text, + "connection_count" integer DEFAULT 0 NOT NULL, + "first_seen_at" timestamp with time zone, + "last_seen_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "agents_name_check" CHECK ("agents"."name" ~ '[^[:space:]]'), + CONSTRAINT "agents_signer_subject_check" CHECK ("agents"."signer_subject" ~ '[^[:space:]]'), + CONSTRAINT "agents_address_check" CHECK ("agents"."agent_address" is null or ("agents"."agent_address" ~ '^0x[0-9a-fA-F]{40}$' + and lower("agents"."agent_address") <> '0x0000000000000000000000000000000000000000')), + CONSTRAINT "agents_connection_count_check" CHECK ("agents"."connection_count" >= 0) +); +--> statement-breakpoint +CREATE TABLE "cap_cycles" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "agent_id" uuid NOT NULL, + "started_at" timestamp with time zone NOT NULL, + "ended_at" timestamp with time zone, + "reset_reason" "cap_reset_reason", + CONSTRAINT "cap_cycles_id_agent_unique" UNIQUE("id","agent_id"), + CONSTRAINT "cap_cycles_end_check" CHECK (("cap_cycles"."ended_at" is null and "cap_cycles"."reset_reason" is null) + or ("cap_cycles"."ended_at" > "cap_cycles"."started_at" and "cap_cycles"."reset_reason" is not null)) +); +--> statement-breakpoint +CREATE TABLE "caps" ( + "agent_id" uuid PRIMARY KEY NOT NULL, + "amount_usd_cents" numeric(20, 0), + "frequency" "cap_frequency" NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "caps_amount_check" CHECK ("caps"."amount_usd_cents" is null or "caps"."amount_usd_cents" > 0) +); +--> statement-breakpoint +CREATE TABLE "floats" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "agent_id" uuid NOT NULL, + "network" "leash_network" NOT NULL, + "asset" "leash_asset" NOT NULL, + "token_address" varchar(42) NOT NULL, + "balance_atomic" numeric NOT NULL, + "balance_usd" numeric(38, 6) NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "floats_balance_atomic_check" CHECK ("floats"."balance_atomic" >= 0 and "floats"."balance_atomic" = trunc("floats"."balance_atomic")), + CONSTRAINT "floats_balance_usd_check" CHECK ("floats"."balance_usd" >= 0), + CONSTRAINT "floats_native_usdc_check" CHECK (("floats"."network" = 'eip155:8453' + and lower("floats"."token_address") = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913') + or ("floats"."network" = 'eip155:42161' + and lower("floats"."token_address") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')) +); +--> statement-breakpoint +CREATE TABLE "leash_keys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "agent_id" uuid NOT NULL, + "hashed_key" varchar(64) NOT NULL, + "prefix" text NOT NULL, + "last4" varchar(4) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_used_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "last_auth_failure_at" timestamp with time zone, + "last_auth_failure_code" text, + "rotated_from_id" uuid, + CONSTRAINT "leash_keys_hash_check" CHECK ("leash_keys"."hashed_key" ~ '^[0-9a-f]{64}$'), + CONSTRAINT "leash_keys_prefix_check" CHECK ("leash_keys"."prefix" = 'leash_sk_'), + CONSTRAINT "leash_keys_last4_check" CHECK ("leash_keys"."last4" ~ '^[A-Za-z0-9_-]{4}$') +); +--> statement-breakpoint +CREATE TABLE "receipts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "agent_id" uuid NOT NULL, + "cycle_id" uuid NOT NULL, + "parent_id" uuid, + "status" "receipt_status" DEFAULT 'pending' NOT NULL, + "reason" text, + "amount_atomic" numeric NOT NULL, + "amount_usd" numeric(38, 6) NOT NULL, + "asset" varchar(42) NOT NULL, + "network" "leash_network" NOT NULL, + "intended_network" "leash_network", + "pay_to" varchar(42) NOT NULL, + "authorization_nonce" varchar(66) NOT NULL, + "request_fingerprint" varchar(64) NOT NULL, + "authorization_valid_before" timestamp with time zone NOT NULL, + "origin" jsonb, + "settlement_response" jsonb, + "tx_hash" varchar(66), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "settled_at" timestamp with time zone, + CONSTRAINT "receipts_amount_atomic_check" CHECK ("receipts"."amount_atomic" > 0 and "receipts"."amount_atomic" = trunc("receipts"."amount_atomic")), + CONSTRAINT "receipts_amount_usd_check" CHECK ("receipts"."amount_usd" > 0), + CONSTRAINT "receipts_pay_to_check" CHECK ("receipts"."pay_to" ~ '^0x[0-9a-fA-F]{40}$' + and lower("receipts"."pay_to") <> '0x0000000000000000000000000000000000000000'), + CONSTRAINT "receipts_native_usdc_check" CHECK (("receipts"."network" = 'eip155:8453' + and lower("receipts"."asset") = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913') + or ("receipts"."network" = 'eip155:42161' + and lower("receipts"."asset") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')), + CONSTRAINT "receipts_authorization_check" CHECK ("receipts"."authorization_nonce" ~ '^0x[0-9a-fA-F]{64}$' + and "receipts"."request_fingerprint" ~ '^[0-9a-f]{64}$'), + CONSTRAINT "receipts_origin_check" CHECK ("receipts"."origin" is null or (jsonb_typeof("receipts"."origin") = 'object' + and "receipts"."origin" ? 'transport' + and "receipts"."origin"->>'transport' in ('mcp', 'http'))), + CONSTRAINT "receipts_tx_hash_check" CHECK ("receipts"."tx_hash" is null or "receipts"."tx_hash" ~ '^0x[0-9a-fA-F]{64}$'), + CONSTRAINT "receipts_state_check" CHECK (("receipts"."status" = 'pending' and "receipts"."reason" is null + and "receipts"."intended_network" is null and "receipts"."tx_hash" is null + and "receipts"."settlement_response" is null and "receipts"."settled_at" is null) + or ("receipts"."status" = 'settled' and "receipts"."reason" is null + and "receipts"."intended_network" is null and "receipts"."tx_hash" is not null + and "receipts"."settlement_response" is not null and "receipts"."settled_at" is not null) + or ("receipts"."status" = 'failed' and "receipts"."reason" ~ '[^[:space:]]' + and "receipts"."intended_network" is null and "receipts"."tx_hash" is null + and "receipts"."settlement_response" is null and "receipts"."settled_at" is null) + or ("receipts"."status" = 'blocked' and "receipts"."reason" ~ '[^[:space:]]' + and "receipts"."intended_network" is not null and "receipts"."tx_hash" is null + and "receipts"."settlement_response" is null and "receipts"."settled_at" is null)) +); +--> statement-breakpoint +ALTER TABLE "agent_events" ADD CONSTRAINT "agent_events_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agents" ADD CONSTRAINT "agents_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cap_cycles" ADD CONSTRAINT "cap_cycles_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "caps" ADD CONSTRAINT "caps_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "floats" ADD CONSTRAINT "floats_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "leash_keys" ADD CONSTRAINT "leash_keys_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "leash_keys" ADD CONSTRAINT "leash_keys_rotated_from_id_leash_keys_id_fk" FOREIGN KEY ("rotated_from_id") REFERENCES "public"."leash_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "receipts" ADD CONSTRAINT "receipts_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "receipts" ADD CONSTRAINT "receipts_parent_id_receipts_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."receipts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "receipts" ADD CONSTRAINT "receipts_cycle_agent_fk" FOREIGN KEY ("cycle_id","agent_id") REFERENCES "public"."cap_cycles"("id","agent_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "agent_events_agent_created_idx" ON "agent_events" USING btree ("agent_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "agents_owner_id_idx" ON "agents" USING btree ("owner_id");--> statement-breakpoint +CREATE UNIQUE INDEX "agents_signer_subject_unique" ON "agents" USING btree ("signer_subject");--> statement-breakpoint +CREATE UNIQUE INDEX "agents_agent_address_unique" ON "agents" USING btree ("agent_address") WHERE "agents"."agent_address" is not null;--> statement-breakpoint +CREATE UNIQUE INDEX "cap_cycles_one_active_per_agent" ON "cap_cycles" USING btree ("agent_id") WHERE "cap_cycles"."ended_at" is null;--> statement-breakpoint +CREATE INDEX "cap_cycles_agent_started_idx" ON "cap_cycles" USING btree ("agent_id","started_at" DESC NULLS LAST);--> statement-breakpoint +CREATE UNIQUE INDEX "floats_agent_network_unique" ON "floats" USING btree ("agent_id","network");--> statement-breakpoint +CREATE UNIQUE INDEX "leash_keys_hashed_key_unique" ON "leash_keys" USING btree ("hashed_key");--> statement-breakpoint +CREATE UNIQUE INDEX "leash_keys_one_active_per_agent" ON "leash_keys" USING btree ("agent_id") WHERE "leash_keys"."revoked_at" is null;--> statement-breakpoint +CREATE UNIQUE INDEX "receipts_agent_nonce_unique" ON "receipts" USING btree ("agent_id","authorization_nonce");--> statement-breakpoint +CREATE UNIQUE INDEX "receipts_agent_fingerprint_unique" ON "receipts" USING btree ("agent_id","request_fingerprint");--> statement-breakpoint +CREATE UNIQUE INDEX "receipts_network_tx_hash_unique" ON "receipts" USING btree ("network","tx_hash") WHERE "receipts"."tx_hash" is not null;--> statement-breakpoint +CREATE INDEX "receipts_cap_gate_idx" ON "receipts" USING btree ("agent_id","cycle_id","status");--> statement-breakpoint +CREATE INDEX "receipts_agent_created_idx" ON "receipts" USING btree ("agent_id","created_at" DESC NULLS LAST); \ No newline at end of file diff --git a/apps/web/drizzle/meta/0015_snapshot.json b/apps/web/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000..d9135a6 --- /dev/null +++ b/apps/web/drizzle/meta/0015_snapshot.json @@ -0,0 +1,2830 @@ +{ + "id": "9e4fb6ee-e4b8-4fe1-af6f-9abf7ab2070c", + "prevId": "2c35174f-f43c-4015-a5c9-581d4f0111b5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "api_key_permissions", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last4": { + "name": "last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotated_from_id": { + "name": "rotated_from_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_public_key_unique": { + "name": "api_keys_public_key_unique", + "columns": [ + { + "expression": "public_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_secret_hash_unique": { + "name": "api_keys_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_one_active_publishable_per_env": { + "name": "api_keys_one_active_publishable_per_env", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"api_keys\".\"revoked_at\" is null and \"api_keys\".\"type\" = 'publishable'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_merchant_id_merchants_id_fk": { + "name": "api_keys_merchant_id_merchants_id_fk", + "tableFrom": "api_keys", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_rotated_from_id_api_keys_id_fk": { + "name": "api_keys_rotated_from_id_api_keys_id_fk", + "tableFrom": "api_keys", + "tableTo": "api_keys", + "columnsFrom": ["rotated_from_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "api_keys_material_check": { + "name": "api_keys_material_check", + "value": "(\"api_keys\".\"type\" = 'publishable' and \"api_keys\".\"public_key\" is not null and \"api_keys\".\"secret_hash\" is null)\n or (\"api_keys\".\"type\" = 'secret' and \"api_keys\".\"public_key\" is null and \"api_keys\".\"secret_hash\" is not null\n and \"api_keys\".\"secret_hash\" ~ '^[0-9a-f]{64}$')" + }, + "api_keys_permissions_check": { + "name": "api_keys_permissions_check", + "value": "(\"api_keys\".\"type\" = 'publishable' and \"api_keys\".\"permissions\" is null)\n or (\"api_keys\".\"type\" = 'secret' and \"api_keys\".\"permissions\" is not null)" + }, + "api_keys_prefix_check": { + "name": "api_keys_prefix_check", + "value": "(\"api_keys\".\"type\" = 'publishable' and (\n (\"api_keys\".\"env\" = 'test' and \"api_keys\".\"prefix\" = 'pk_test_')\n or (\"api_keys\".\"env\" = 'live' and \"api_keys\".\"prefix\" = 'pk_live_')\n )) or (\"api_keys\".\"type\" = 'secret' and (\n (\"api_keys\".\"env\" = 'test' and \"api_keys\".\"prefix\" = 'sk_test_')\n or (\"api_keys\".\"env\" = 'live' and \"api_keys\".\"prefix\" = 'sk_live_')\n ))" + }, + "api_keys_public_key_prefix_check": { + "name": "api_keys_public_key_prefix_check", + "value": "\"api_keys\".\"type\" = 'secret' or (\n left(\"api_keys\".\"public_key\", length(\"api_keys\".\"prefix\")) = \"api_keys\".\"prefix\"\n and \"api_keys\".\"public_key\" ~ '^pk_(test|live)_[A-Za-z0-9_-]+$'\n )" + } + }, + "isRLSEnabled": false + }, + "public.merchants": { + "name": "merchants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "business_name": { + "name": "business_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_etag": { + "name": "logo_etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_upload_count": { + "name": "logo_upload_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "logo_upload_window_started_at": { + "name": "logo_upload_window_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "receiving_address": { + "name": "receiving_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "receiving_address_source": { + "name": "receiving_address_source", + "type": "receiving_address_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'magic_default'" + }, + "live_activated_at": { + "name": "live_activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "merchants_user_id_unique": { + "name": "merchants_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "merchants_user_id_users_id_fk": { + "name": "merchants_user_id_users_id_fk", + "tableFrom": "merchants", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "merchants_receiving_address_check": { + "name": "merchants_receiving_address_check", + "value": "\"merchants\".\"receiving_address\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"merchants\".\"receiving_address\") <> '0x0000000000000000000000000000000000000000'" + } + }, + "isRLSEnabled": false + }, + "public.quickstart_progress": { + "name": "quickstart_progress", + "schema": "", + "columns": { + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_key": { + "name": "step_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "quickstart_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "quickstart_progress_merchant_id_merchants_id_fk": { + "name": "quickstart_progress_merchant_id_merchants_id_fk", + "tableFrom": "quickstart_progress", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quickstart_progress_merchant_id_step_key_pk": { + "name": "quickstart_progress_merchant_id_step_key_pk", + "columns": ["merchant_id", "step_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "citext", + "primaryKey": false, + "notNull": true + }, + "magic_issuer": { + "name": "magic_issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_magic_issuer_unique": { + "name": "users_magic_issuer_unique", + "columns": [ + { + "expression": "magic_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_events": { + "name": "agent_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_surface": { + "name": "actor_surface", + "type": "agent_event_surface", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_events_agent_created_idx": { + "name": "agent_events_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_events_agent_id_agents_id_fk": { + "name": "agent_events_agent_id_agents_id_fk", + "tableFrom": "agent_events", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_events_metadata_check": { + "name": "agent_events_metadata_check", + "value": "jsonb_typeof(\"agent_events\".\"metadata\") = 'object'" + } + }, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "agent_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'provisioned'" + }, + "signer_subject": { + "name": "signer_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_address": { + "name": "agent_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_version": { + "name": "client_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_count": { + "name": "connection_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_signer_subject_unique": { + "name": "agents_signer_subject_unique", + "columns": [ + { + "expression": "signer_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_agent_address_unique": { + "name": "agents_agent_address_unique", + "columns": [ + { + "expression": "agent_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agents\".\"agent_address\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agents_name_check": { + "name": "agents_name_check", + "value": "\"agents\".\"name\" ~ '[^[:space:]]'" + }, + "agents_signer_subject_check": { + "name": "agents_signer_subject_check", + "value": "\"agents\".\"signer_subject\" ~ '[^[:space:]]'" + }, + "agents_address_check": { + "name": "agents_address_check", + "value": "\"agents\".\"agent_address\" is null or (\"agents\".\"agent_address\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"agents\".\"agent_address\") <> '0x0000000000000000000000000000000000000000')" + }, + "agents_connection_count_check": { + "name": "agents_connection_count_check", + "value": "\"agents\".\"connection_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.cap_cycles": { + "name": "cap_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reset_reason": { + "name": "reset_reason", + "type": "cap_reset_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "cap_cycles_one_active_per_agent": { + "name": "cap_cycles_one_active_per_agent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cap_cycles\".\"ended_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "cap_cycles_agent_started_idx": { + "name": "cap_cycles_agent_started_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cap_cycles_agent_id_agents_id_fk": { + "name": "cap_cycles_agent_id_agents_id_fk", + "tableFrom": "cap_cycles", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cap_cycles_id_agent_unique": { + "name": "cap_cycles_id_agent_unique", + "nullsNotDistinct": false, + "columns": ["id", "agent_id"] + } + }, + "policies": {}, + "checkConstraints": { + "cap_cycles_end_check": { + "name": "cap_cycles_end_check", + "value": "(\"cap_cycles\".\"ended_at\" is null and \"cap_cycles\".\"reset_reason\" is null)\n or (\"cap_cycles\".\"ended_at\" > \"cap_cycles\".\"started_at\" and \"cap_cycles\".\"reset_reason\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.caps": { + "name": "caps", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "amount_usd_cents": { + "name": "amount_usd_cents", + "type": "numeric(20, 0)", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "cap_frequency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "caps_agent_id_agents_id_fk": { + "name": "caps_agent_id_agents_id_fk", + "tableFrom": "caps", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "caps_amount_check": { + "name": "caps_amount_check", + "value": "\"caps\".\"amount_usd_cents\" is null or \"caps\".\"amount_usd_cents\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.floats": { + "name": "floats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "leash_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "leash_asset", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_address": { + "name": "token_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "balance_atomic": { + "name": "balance_atomic", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "balance_usd": { + "name": "balance_usd", + "type": "numeric(38, 6)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "floats_agent_network_unique": { + "name": "floats_agent_network_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "floats_agent_id_agents_id_fk": { + "name": "floats_agent_id_agents_id_fk", + "tableFrom": "floats", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "floats_balance_atomic_check": { + "name": "floats_balance_atomic_check", + "value": "\"floats\".\"balance_atomic\" >= 0 and \"floats\".\"balance_atomic\" = trunc(\"floats\".\"balance_atomic\")" + }, + "floats_balance_usd_check": { + "name": "floats_balance_usd_check", + "value": "\"floats\".\"balance_usd\" >= 0" + }, + "floats_native_usdc_check": { + "name": "floats_native_usdc_check", + "value": "(\"floats\".\"network\" = 'eip155:8453'\n and lower(\"floats\".\"token_address\") = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913')\n or (\"floats\".\"network\" = 'eip155:42161'\n and lower(\"floats\".\"token_address\") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')" + } + }, + "isRLSEnabled": false + }, + "public.leash_keys": { + "name": "leash_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hashed_key": { + "name": "hashed_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last4": { + "name": "last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_auth_failure_at": { + "name": "last_auth_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_auth_failure_code": { + "name": "last_auth_failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotated_from_id": { + "name": "rotated_from_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "leash_keys_hashed_key_unique": { + "name": "leash_keys_hashed_key_unique", + "columns": [ + { + "expression": "hashed_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leash_keys_one_active_per_agent": { + "name": "leash_keys_one_active_per_agent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leash_keys\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leash_keys_agent_id_agents_id_fk": { + "name": "leash_keys_agent_id_agents_id_fk", + "tableFrom": "leash_keys", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leash_keys_rotated_from_id_leash_keys_id_fk": { + "name": "leash_keys_rotated_from_id_leash_keys_id_fk", + "tableFrom": "leash_keys", + "tableTo": "leash_keys", + "columnsFrom": ["rotated_from_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "leash_keys_hash_check": { + "name": "leash_keys_hash_check", + "value": "\"leash_keys\".\"hashed_key\" ~ '^[0-9a-f]{64}$'" + }, + "leash_keys_prefix_check": { + "name": "leash_keys_prefix_check", + "value": "\"leash_keys\".\"prefix\" = 'leash_sk_'" + }, + "leash_keys_last4_check": { + "name": "leash_keys_last4_check", + "value": "\"leash_keys\".\"last4\" ~ '^[A-Za-z0-9_-]{4}$'" + } + }, + "isRLSEnabled": false + }, + "public.receipts": { + "name": "receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "receipt_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_atomic": { + "name": "amount_atomic", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric(38, 6)", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "leash_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "intended_network": { + "name": "intended_network", + "type": "leash_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "pay_to": { + "name": "pay_to", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "authorization_nonce": { + "name": "authorization_nonce", + "type": "varchar(66)", + "primaryKey": false, + "notNull": true + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "authorization_valid_before": { + "name": "authorization_valid_before", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settlement_response": { + "name": "settlement_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "receipts_agent_nonce_unique": { + "name": "receipts_agent_nonce_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "authorization_nonce", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_agent_fingerprint_unique": { + "name": "receipts_agent_fingerprint_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_network_tx_hash_unique": { + "name": "receipts_network_tx_hash_unique", + "columns": [ + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"receipts\".\"tx_hash\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_cap_gate_idx": { + "name": "receipts_cap_gate_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_agent_created_idx": { + "name": "receipts_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "receipts_agent_id_agents_id_fk": { + "name": "receipts_agent_id_agents_id_fk", + "tableFrom": "receipts", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "receipts_parent_id_receipts_id_fk": { + "name": "receipts_parent_id_receipts_id_fk", + "tableFrom": "receipts", + "tableTo": "receipts", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "receipts_cycle_agent_fk": { + "name": "receipts_cycle_agent_fk", + "tableFrom": "receipts", + "tableTo": "cap_cycles", + "columnsFrom": ["cycle_id", "agent_id"], + "columnsTo": ["id", "agent_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "receipts_amount_atomic_check": { + "name": "receipts_amount_atomic_check", + "value": "\"receipts\".\"amount_atomic\" > 0 and \"receipts\".\"amount_atomic\" = trunc(\"receipts\".\"amount_atomic\")" + }, + "receipts_amount_usd_check": { + "name": "receipts_amount_usd_check", + "value": "\"receipts\".\"amount_usd\" > 0" + }, + "receipts_pay_to_check": { + "name": "receipts_pay_to_check", + "value": "\"receipts\".\"pay_to\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"receipts\".\"pay_to\") <> '0x0000000000000000000000000000000000000000'" + }, + "receipts_native_usdc_check": { + "name": "receipts_native_usdc_check", + "value": "(\"receipts\".\"network\" = 'eip155:8453'\n and lower(\"receipts\".\"asset\") = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913')\n or (\"receipts\".\"network\" = 'eip155:42161'\n and lower(\"receipts\".\"asset\") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')" + }, + "receipts_authorization_check": { + "name": "receipts_authorization_check", + "value": "\"receipts\".\"authorization_nonce\" ~ '^0x[0-9a-fA-F]{64}$'\n and \"receipts\".\"request_fingerprint\" ~ '^[0-9a-f]{64}$'" + }, + "receipts_origin_check": { + "name": "receipts_origin_check", + "value": "\"receipts\".\"origin\" is null or (jsonb_typeof(\"receipts\".\"origin\") = 'object'\n and \"receipts\".\"origin\" ? 'transport'\n and \"receipts\".\"origin\"->>'transport' in ('mcp', 'http'))" + }, + "receipts_tx_hash_check": { + "name": "receipts_tx_hash_check", + "value": "\"receipts\".\"tx_hash\" is null or \"receipts\".\"tx_hash\" ~ '^0x[0-9a-fA-F]{64}$'" + }, + "receipts_state_check": { + "name": "receipts_state_check", + "value": "(\"receipts\".\"status\" = 'pending' and \"receipts\".\"reason\" is null\n and \"receipts\".\"intended_network\" is null and \"receipts\".\"tx_hash\" is null\n and \"receipts\".\"settlement_response\" is null and \"receipts\".\"settled_at\" is null)\n or (\"receipts\".\"status\" = 'settled' and \"receipts\".\"reason\" is null\n and \"receipts\".\"intended_network\" is null and \"receipts\".\"tx_hash\" is not null\n and \"receipts\".\"settlement_response\" is not null and \"receipts\".\"settled_at\" is not null)\n or (\"receipts\".\"status\" = 'failed' and \"receipts\".\"reason\" ~ '[^[:space:]]'\n and \"receipts\".\"intended_network\" is null and \"receipts\".\"tx_hash\" is null\n and \"receipts\".\"settlement_response\" is null and \"receipts\".\"settled_at\" is null)\n or (\"receipts\".\"status\" = 'blocked' and \"receipts\".\"reason\" ~ '[^[:space:]]'\n and \"receipts\".\"intended_network\" is not null and \"receipts\".\"tx_hash\" is null\n and \"receipts\".\"settlement_response\" is null and \"receipts\".\"settled_at\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.orders": { + "name": "orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "order_number": { + "name": "order_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_ref": { + "name": "payment_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "orders_payment_ref_unique": { + "name": "orders_payment_ref_unique", + "columns": [ + { + "expression": "payment_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_merchant_env_number_unique": { + "name": "orders_merchant_env_number_unique", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_payment_merchant_env_fk": { + "name": "orders_payment_merchant_env_fk", + "tableFrom": "orders", + "tableTo": "payments", + "columnsFrom": ["payment_ref", "merchant_id", "env"], + "columnsTo": ["ref_code", "merchant_id", "env"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "orders_order_number_check": { + "name": "orders_order_number_check", + "value": "\"orders\".\"order_number\" ~ '[^[:space:]]'" + }, + "orders_payment_ref_check": { + "name": "orders_payment_ref_check", + "value": "\"orders\".\"payment_ref\" ~ '^TAB-[A-Z0-9]+$'" + } + }, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ref_code": { + "name": "ref_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true + }, + "receiver": { + "name": "receiver", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "token_address": { + "name": "token_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "token_chain_id": { + "name": "token_chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "intent_url": { + "name": "intent_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_type": { + "name": "payer_type", + "type": "payer_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "payer_email": { + "name": "payer_email", + "type": "citext", + "primaryKey": false, + "notNull": false + }, + "payer_address": { + "name": "payer_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_transaction_id": { + "name": "reported_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_token_changes": { + "name": "reported_token_changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reported_at": { + "name": "reported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verification_next_attempt_at": { + "name": "verification_next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verification_lease_token": { + "name": "verification_lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verification_lease_expires_at": { + "name": "verification_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payments_ref_code_unique": { + "name": "payments_ref_code_unique", + "columns": [ + { + "expression": "ref_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_settlement_evidence_unique": { + "name": "payments_settlement_evidence_unique", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reported_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "livemode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_reported_transaction_id_idx": { + "name": "payments_reported_transaction_id_idx", + "columns": [ + { + "expression": "reported_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_merchant_env_created_idx": { + "name": "payments_merchant_env_created_idx", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_pending_sweep_idx": { + "name": "payments_pending_sweep_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_verification_sweep_idx": { + "name": "payments_verification_sweep_idx", + "columns": [ + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reported_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verification_next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_merchant_id_merchants_id_fk": { + "name": "payments_merchant_id_merchants_id_fk", + "tableFrom": "payments", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "payments_id_merchant_env_unique": { + "name": "payments_id_merchant_env_unique", + "nullsNotDistinct": false, + "columns": ["id", "merchant_id", "env"] + }, + "payments_ref_merchant_env_unique": { + "name": "payments_ref_merchant_env_unique", + "nullsNotDistinct": false, + "columns": ["ref_code", "merchant_id", "env"] + } + }, + "policies": {}, + "checkConstraints": { + "payments_ref_code_check": { + "name": "payments_ref_code_check", + "value": "\"payments\".\"ref_code\" ~ '^TAB-[A-Z0-9]+$'" + }, + "payments_amount_check": { + "name": "payments_amount_check", + "value": "\"payments\".\"amount_usd\" > 0 and \"payments\".\"amount_usd\" < 100000000000000\n and scale(\"payments\".\"amount_usd\") <= 6" + }, + "payments_currency_check": { + "name": "payments_currency_check", + "value": "\"payments\".\"currency\" = 'USD'" + }, + "payments_chain_check": { + "name": "payments_chain_check", + "value": "\"payments\".\"token_chain_id\" = 42161" + }, + "payments_token_check": { + "name": "payments_token_check", + "value": "lower(\"payments\".\"token_address\") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831'" + }, + "payments_receiver_check": { + "name": "payments_receiver_check", + "value": "\"payments\".\"receiver\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"payments\".\"receiver\") <> '0x0000000000000000000000000000000000000000'" + }, + "payments_payer_address_check": { + "name": "payments_payer_address_check", + "value": "\"payments\".\"payer_address\" is null or (\"payments\".\"payer_address\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"payments\".\"payer_address\") <> '0x0000000000000000000000000000000000000000')" + }, + "payments_livemode_check": { + "name": "payments_livemode_check", + "value": "(\"payments\".\"env\" = 'live' and \"payments\".\"livemode\")\n or (\"payments\".\"env\" = 'test' and not \"payments\".\"livemode\")" + }, + "payments_report_check": { + "name": "payments_report_check", + "value": "(\"payments\".\"reported_transaction_id\" is null and \"payments\".\"reported_token_changes\" is null and \"payments\".\"reported_at\" is null)\n or (\"payments\".\"reported_transaction_id\" is not null and btrim(\"payments\".\"reported_transaction_id\") <> ''\n and \"payments\".\"reported_token_changes\" is not null\n and jsonb_typeof(\"payments\".\"reported_token_changes\") = 'array' and \"payments\".\"reported_at\" is not null)" + }, + "payments_settled_at_check": { + "name": "payments_settled_at_check", + "value": "(\"payments\".\"status\" = 'settled' and \"payments\".\"settled_at\" is not null)\n or (\"payments\".\"status\" <> 'settled' and \"payments\".\"settled_at\" is null)" + }, + "payments_verification_lease_check": { + "name": "payments_verification_lease_check", + "value": "(\"payments\".\"verification_lease_token\" is null and \"payments\".\"verification_lease_expires_at\" is null)\n or (\"payments\".\"verification_lease_token\" is not null\n and \"payments\".\"verification_lease_expires_at\" is not null and \"payments\".\"env\" = 'live'\n and \"payments\".\"status\" = 'pending' and \"payments\".\"reported_at\" is not null\n and \"payments\".\"reported_transaction_id\" is not null and \"payments\".\"payer_address\" is not null)" + }, + "payments_verification_schedule_check": { + "name": "payments_verification_schedule_check", + "value": "\"payments\".\"verification_next_attempt_at\" is null\n or (\"payments\".\"env\" = 'live' and \"payments\".\"reported_at\" is not null)" + }, + "payments_failure_reason_check": { + "name": "payments_failure_reason_check", + "value": "(\"payments\".\"status\" = 'failed' and \"payments\".\"failure_reason\" is not null)\n or (\"payments\".\"status\" <> 'failed' and \"payments\".\"failure_reason\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.settlements": { + "name": "settlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "payment_id": { + "name": "payment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "particle_transaction_id": { + "name": "particle_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "token_changes_json": { + "name": "token_changes_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "amount_atomic": { + "name": "amount_atomic", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "verification_method": { + "name": "verification_method", + "type": "settlement_verification_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "verification_trigger": { + "name": "verification_trigger", + "type": "settlement_verification_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settlements_payment_id_unique": { + "name": "settlements_payment_id_unique", + "columns": [ + { + "expression": "payment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlements_particle_transaction_id_unique": { + "name": "settlements_particle_transaction_id_unique", + "columns": [ + { + "expression": "particle_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlements_payment_evidence_fk": { + "name": "settlements_payment_evidence_fk", + "tableFrom": "settlements", + "tableTo": "payments", + "columnsFrom": ["payment_id", "particle_transaction_id", "livemode"], + "columnsTo": ["id", "reported_transaction_id", "livemode"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settlements_id_payment_unique": { + "name": "settlements_id_payment_unique", + "nullsNotDistinct": false, + "columns": ["id", "payment_id"] + } + }, + "policies": {}, + "checkConstraints": { + "settlements_amount_atomic_check": { + "name": "settlements_amount_atomic_check", + "value": "\"settlements\".\"amount_atomic\" > 0 and \"settlements\".\"amount_atomic\" = trunc(\"settlements\".\"amount_atomic\")\n and \"settlements\".\"amount_atomic\" < 1000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "settlements_token_changes_check": { + "name": "settlements_token_changes_check", + "value": "jsonb_typeof(\"settlements\".\"token_changes_json\") = 'array'" + }, + "settlements_tx_hash_check": { + "name": "settlements_tx_hash_check", + "value": "\"settlements\".\"tx_hash\" is null or \"settlements\".\"tx_hash\" ~ '^0x[0-9a-fA-F]{64}$'" + }, + "settlements_simulation_check": { + "name": "settlements_simulation_check", + "value": "(\"settlements\".\"verification_method\" = 'simulated_test' and not \"settlements\".\"livemode\")\n or (\"settlements\".\"verification_method\" <> 'simulated_test' and \"settlements\".\"livemode\")" + } + }, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payment_id": { + "name": "payment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "settlement_id": { + "name": "settlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retry_chain_id": { + "name": "retry_chain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_body_hash": { + "name": "request_body_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "encode(digest(\"webhook_deliveries\".\"request_body\", 'sha256'), 'hex')", + "type": "stored" + } + }, + "type": { + "name": "type", + "type": "webhook_delivery_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "webhook_delivery_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "webhook_delivery_result", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "failure_kind": { + "name": "failure_kind", + "type": "webhook_failure_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "signature_header": { + "name": "signature_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body_snippet": { + "name": "response_body_snippet", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_delivery_id": { + "name": "parent_delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_attempt": { + "name": "parent_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "superseded_by_attempt": { + "name": "superseded_by_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_deliveries_chain_attempt_unique": { + "name": "webhook_deliveries_chain_attempt_unique", + "columns": [ + { + "expression": "retry_chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_automatic_settlement_root_unique": { + "name": "webhook_deliveries_automatic_settlement_root_unique", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook_deliveries\".\"trigger\" = 'auto' and \"webhook_deliveries\".\"type\" = 'payment' and \"webhook_deliveries\".\"attempt\" = 1", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_dashboard_head_idx": { + "name": "webhook_deliveries_dashboard_head_idx", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook_deliveries\".\"trigger\" = 'auto' and \"webhook_deliveries\".\"type\" = 'payment'\n and \"webhook_deliveries\".\"superseded_by_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_due_idx": { + "name": "webhook_deliveries_due_idx", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_event_idx": { + "name": "webhook_deliveries_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_deliveries_endpoint_scope_fk": { + "name": "webhook_deliveries_endpoint_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_endpoints", + "columnsFrom": ["endpoint_id", "merchant_id", "env"], + "columnsTo": ["id", "merchant_id", "env"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_payment_scope_fk": { + "name": "webhook_deliveries_payment_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "payments", + "columnsFrom": ["payment_id", "merchant_id", "env"], + "columnsTo": ["id", "merchant_id", "env"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_settlement_payment_fk": { + "name": "webhook_deliveries_settlement_payment_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "settlements", + "columnsFrom": ["settlement_id", "payment_id"], + "columnsTo": ["id", "payment_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_chain_scope_fk": { + "name": "webhook_deliveries_chain_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": [ + "retry_chain_id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type", + "trigger" + ], + "columnsTo": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type", + "trigger" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_chain_evidence_fk": { + "name": "webhook_deliveries_chain_evidence_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["retry_chain_id", "payment_id", "settlement_id"], + "columnsTo": ["id", "payment_id", "settlement_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_parent_scope_fk": { + "name": "webhook_deliveries_parent_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": [ + "parent_delivery_id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type" + ], + "columnsTo": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_parent_evidence_fk": { + "name": "webhook_deliveries_parent_evidence_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["parent_delivery_id", "payment_id", "settlement_id"], + "columnsTo": ["id", "payment_id", "settlement_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_parent_sequence_fk": { + "name": "webhook_deliveries_parent_sequence_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["parent_delivery_id", "retry_chain_id", "parent_attempt"], + "columnsTo": ["id", "retry_chain_id", "attempt"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_successor_scope_fk": { + "name": "webhook_deliveries_successor_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["superseded_by_id", "id", "retry_chain_id", "superseded_by_attempt"], + "columnsTo": ["id", "parent_delivery_id", "retry_chain_id", "attempt"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_deliveries_id_chain_scope_unique": { + "name": "webhook_deliveries_id_chain_scope_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type", + "trigger" + ] + }, + "webhook_deliveries_id_tenant_unique": { + "name": "webhook_deliveries_id_tenant_unique", + "nullsNotDistinct": false, + "columns": ["id", "merchant_id", "env"] + }, + "webhook_deliveries_id_event_scope_unique": { + "name": "webhook_deliveries_id_event_scope_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type" + ] + }, + "webhook_deliveries_id_evidence_unique": { + "name": "webhook_deliveries_id_evidence_unique", + "nullsNotDistinct": false, + "columns": ["id", "payment_id", "settlement_id"] + }, + "webhook_deliveries_id_retry_attempt_unique": { + "name": "webhook_deliveries_id_retry_attempt_unique", + "nullsNotDistinct": false, + "columns": ["id", "retry_chain_id", "attempt"] + }, + "webhook_deliveries_id_parent_chain_attempt_unique": { + "name": "webhook_deliveries_id_parent_chain_attempt_unique", + "nullsNotDistinct": false, + "columns": ["id", "parent_delivery_id", "retry_chain_id", "attempt"] + } + }, + "policies": {}, + "checkConstraints": { + "webhook_deliveries_event_id_check": { + "name": "webhook_deliveries_event_id_check", + "value": "\"webhook_deliveries\".\"event_id\" ~ '^evt_[A-Za-z0-9_-]+$'" + }, + "webhook_deliveries_body_hash_check": { + "name": "webhook_deliveries_body_hash_check", + "value": "\"webhook_deliveries\".\"request_body_hash\" ~ '^[0-9a-f]{64}$'" + }, + "webhook_deliveries_chain_root_check": { + "name": "webhook_deliveries_chain_root_check", + "value": "((\"webhook_deliveries\".\"attempt\" = 1 and \"webhook_deliveries\".\"retry_chain_id\" = \"webhook_deliveries\".\"id\"\n and \"webhook_deliveries\".\"parent_attempt\" is null)\n or (\"webhook_deliveries\".\"attempt\" > 1 and \"webhook_deliveries\".\"retry_chain_id\" <> \"webhook_deliveries\".\"id\"\n and \"webhook_deliveries\".\"parent_delivery_id\" is not null\n and \"webhook_deliveries\".\"parent_attempt\" = \"webhook_deliveries\".\"attempt\" - 1))\n and (\"webhook_deliveries\".\"parent_delivery_id\" is null or \"webhook_deliveries\".\"parent_delivery_id\" <> \"webhook_deliveries\".\"id\")" + }, + "webhook_deliveries_attempt_check": { + "name": "webhook_deliveries_attempt_check", + "value": "\"webhook_deliveries\".\"attempt\" between 1 and 3" + }, + "webhook_deliveries_type_check": { + "name": "webhook_deliveries_type_check", + "value": "(\"webhook_deliveries\".\"type\" = 'payment' and \"webhook_deliveries\".\"payment_id\" is not null\n and \"webhook_deliveries\".\"settlement_id\" is not null)\n or (\"webhook_deliveries\".\"type\" = 'test' and \"webhook_deliveries\".\"payment_id\" is null\n and \"webhook_deliveries\".\"settlement_id\" is null)" + }, + "webhook_deliveries_status_code_check": { + "name": "webhook_deliveries_status_code_check", + "value": "\"webhook_deliveries\".\"status_code\" is null or \"webhook_deliveries\".\"status_code\" between 100 and 599" + }, + "webhook_deliveries_response_time_check": { + "name": "webhook_deliveries_response_time_check", + "value": "\"webhook_deliveries\".\"response_time_ms\" is null or \"webhook_deliveries\".\"response_time_ms\" >= 0" + }, + "webhook_deliveries_signature_check": { + "name": "webhook_deliveries_signature_check", + "value": "\"webhook_deliveries\".\"signature_header\" is null\n or \"webhook_deliveries\".\"signature_header\" ~ '^t=[0-9]+,v1=[0-9a-f]{64}$'" + }, + "webhook_deliveries_lease_check": { + "name": "webhook_deliveries_lease_check", + "value": "(\"webhook_deliveries\".\"lease_token\" is null and \"webhook_deliveries\".\"lease_expires_at\" is null)\n or (\"webhook_deliveries\".\"lease_token\" is not null and \"webhook_deliveries\".\"lease_expires_at\" is not null)" + }, + "webhook_deliveries_successor_check": { + "name": "webhook_deliveries_successor_check", + "value": "(\"webhook_deliveries\".\"superseded_by_id\" is null and \"webhook_deliveries\".\"superseded_by_attempt\" is null)\n or (\"webhook_deliveries\".\"superseded_by_id\" is not null\n and \"webhook_deliveries\".\"superseded_by_id\" <> \"webhook_deliveries\".\"id\"\n and \"webhook_deliveries\".\"superseded_by_attempt\" = \"webhook_deliveries\".\"attempt\" + 1\n and \"webhook_deliveries\".\"superseded_by_attempt\" between 2 and 3)" + }, + "webhook_deliveries_result_check": { + "name": "webhook_deliveries_result_check", + "value": "coalesce(((\"webhook_deliveries\".\"result\" = 'pending' and \"webhook_deliveries\".\"completed_at\" is null\n and \"webhook_deliveries\".\"failure_kind\" is null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"status_code\" is null and \"webhook_deliveries\".\"response_time_ms\" is null\n and \"webhook_deliveries\".\"response_body_snippet\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null)\n or (\"webhook_deliveries\".\"result\" = 'delivered' and \"webhook_deliveries\".\"completed_at\" is not null\n and \"webhook_deliveries\".\"failure_kind\" is null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"status_code\" between 200 and 299\n and \"webhook_deliveries\".\"signature_header\" is not null and \"webhook_deliveries\".\"started_at\" is not null\n and \"webhook_deliveries\".\"response_time_ms\" is not null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null)\n or (\"webhook_deliveries\".\"result\" = 'retrying' and \"webhook_deliveries\".\"completed_at\" is not null\n and \"webhook_deliveries\".\"failure_kind\" in ('http', 'network', 'timeout')\n and \"webhook_deliveries\".\"next_retry_at\" is not null and \"webhook_deliveries\".\"attempt\" < 3\n and \"webhook_deliveries\".\"signature_header\" is not null and \"webhook_deliveries\".\"started_at\" is not null\n and \"webhook_deliveries\".\"response_time_ms\" is not null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null\n and ((\"webhook_deliveries\".\"failure_kind\" = 'http' and (\"webhook_deliveries\".\"status_code\" < 200\n or \"webhook_deliveries\".\"status_code\" > 299))\n or (\"webhook_deliveries\".\"failure_kind\" in ('network', 'timeout') and \"webhook_deliveries\".\"status_code\" is null)))\n or (\"webhook_deliveries\".\"result\" in ('failed', 'timeout') and \"webhook_deliveries\".\"attempt\" < 3\n and \"webhook_deliveries\".\"completed_at\" is not null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"signature_header\" is not null and \"webhook_deliveries\".\"started_at\" is not null\n and \"webhook_deliveries\".\"response_time_ms\" is not null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is not null\n and ((\"webhook_deliveries\".\"result\" = 'failed' and \"webhook_deliveries\".\"failure_kind\" in ('http', 'network'))\n or (\"webhook_deliveries\".\"result\" = 'timeout' and \"webhook_deliveries\".\"failure_kind\" = 'timeout'))\n and ((\"webhook_deliveries\".\"failure_kind\" = 'http' and (\"webhook_deliveries\".\"status_code\" < 200\n or \"webhook_deliveries\".\"status_code\" > 299))\n or (\"webhook_deliveries\".\"failure_kind\" in ('network', 'timeout') and \"webhook_deliveries\".\"status_code\" is null)))\n or (\"webhook_deliveries\".\"result\" = 'failed' and \"webhook_deliveries\".\"failure_kind\" = 'configuration'\n and \"webhook_deliveries\".\"completed_at\" is not null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"signature_header\" is null and \"webhook_deliveries\".\"status_code\" is null\n and \"webhook_deliveries\".\"response_time_ms\" is null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null)\n or (\"webhook_deliveries\".\"result\" = 'gave_up' and \"webhook_deliveries\".\"attempt\" = 3\n and \"webhook_deliveries\".\"completed_at\" is not null\n and \"webhook_deliveries\".\"failure_kind\" in ('http', 'network', 'timeout')\n and \"webhook_deliveries\".\"next_retry_at\" is null and \"webhook_deliveries\".\"signature_header\" is not null\n and \"webhook_deliveries\".\"started_at\" is not null and \"webhook_deliveries\".\"response_time_ms\" is not null\n and \"webhook_deliveries\".\"lease_token\" is null and \"webhook_deliveries\".\"lease_expires_at\" is null\n and \"webhook_deliveries\".\"superseded_by_id\" is null\n and ((\"webhook_deliveries\".\"failure_kind\" = 'http' and (\"webhook_deliveries\".\"status_code\" < 200\n or \"webhook_deliveries\".\"status_code\" > 299))\n or (\"webhook_deliveries\".\"failure_kind\" in ('network', 'timeout') and \"webhook_deliveries\".\"status_code\" is null)))), false)" + } + }, + "isRLSEnabled": false + }, + "public.webhook_endpoints": { + "name": "webhook_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_nonce": { + "name": "secret_nonce", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "secret_auth_tag": { + "name": "secret_auth_tag", + "type": "varchar(22)", + "primaryKey": false, + "notNull": false + }, + "secret_key_version": { + "name": "secret_key_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_last4": { + "name": "secret_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "webhook_endpoints_one_active_per_env": { + "name": "webhook_endpoints_one_active_per_env", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook_endpoints\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_endpoints_merchant_id_merchants_id_fk": { + "name": "webhook_endpoints_merchant_id_merchants_id_fk", + "tableFrom": "webhook_endpoints", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_endpoints_id_scope_unique": { + "name": "webhook_endpoints_id_scope_unique", + "nullsNotDistinct": false, + "columns": ["id", "merchant_id", "env"] + } + }, + "policies": {}, + "checkConstraints": { + "webhook_endpoints_url_check": { + "name": "webhook_endpoints_url_check", + "value": "char_length(btrim(\"webhook_endpoints\".\"url\")) between 1 and 2048\n and (\"webhook_endpoints\".\"url\" ~ '^https://[^/?#[:space:]@]+([:/?#]|$)'\n or (\"webhook_endpoints\".\"env\" = 'test'\n and \"webhook_endpoints\".\"url\" ~ '^http://(127\\.0\\.0\\.1|\\[::1\\]|localhost)(:[0-9]+)?/'))\n and \"webhook_endpoints\".\"url\" !~* '^https://(localhost\\.?|127(\\.[0-9]{1,3}){3}|10(\\.[0-9]{1,3}){3}|192\\.168(\\.[0-9]{1,3}){2}|169\\.254(\\.[0-9]{1,3}){2}|172\\.(1[6-9]|2[0-9]|3[01])(\\.[0-9]{1,3}){2}|\\[(::1|f[cd][0-9a-f:]*|fe[89ab][0-9a-f:]*)\\])([:/?#]|$)'" + }, + "webhook_endpoints_last4_check": { + "name": "webhook_endpoints_last4_check", + "value": "char_length(\"webhook_endpoints\".\"secret_last4\") = 4" + }, + "webhook_endpoints_secret_envelope_check": { + "name": "webhook_endpoints_secret_envelope_check", + "value": "coalesce(((\"webhook_endpoints\".\"deleted_at\" is null\n and \"webhook_endpoints\".\"secret_ciphertext\" is not null\n and char_length(\"webhook_endpoints\".\"secret_ciphertext\") > 0\n and \"webhook_endpoints\".\"secret_nonce\" is not null\n and \"webhook_endpoints\".\"secret_nonce\" ~ '^[A-Za-z0-9_-]{16}$'\n and \"webhook_endpoints\".\"secret_auth_tag\" is not null\n and \"webhook_endpoints\".\"secret_auth_tag\" ~ '^[A-Za-z0-9_-]{22}$'\n and \"webhook_endpoints\".\"secret_key_version\" is not null\n and \"webhook_endpoints\".\"secret_key_version\" > 0)\n or (\"webhook_endpoints\".\"deleted_at\" is not null\n and \"webhook_endpoints\".\"secret_ciphertext\" is null and \"webhook_endpoints\".\"secret_nonce\" is null\n and \"webhook_endpoints\".\"secret_auth_tag\" is null and \"webhook_endpoints\".\"secret_key_version\" is null)), false)" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.api_key_permissions": { + "name": "api_key_permissions", + "schema": "public", + "values": ["full", "read_only"] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": ["secret", "publishable"] + }, + "public.environment": { + "name": "environment", + "schema": "public", + "values": ["test", "live"] + }, + "public.quickstart_source": { + "name": "quickstart_source", + "schema": "public", + "values": ["auto", "manual"] + }, + "public.receiving_address_source": { + "name": "receiving_address_source", + "schema": "public", + "values": ["magic_default", "custom"] + }, + "public.agent_event_surface": { + "name": "agent_event_surface", + "schema": "public", + "values": ["agent", "web", "pwa", "push_action", "system"] + }, + "public.agent_event_type": { + "name": "agent_event_type", + "schema": "public", + "values": ["connect", "sign", "block", "revoke"] + }, + "public.agent_status": { + "name": "agent_status", + "schema": "public", + "values": ["provisioned", "paused", "frozen", "cancelled", "nuked"] + }, + "public.cap_frequency": { + "name": "cap_frequency", + "schema": "public", + "values": ["daily", "weekly", "monthly", "never"] + }, + "public.cap_reset_reason": { + "name": "cap_reset_reason", + "schema": "public", + "values": ["schedule", "manual", "frequency_change"] + }, + "public.leash_asset": { + "name": "leash_asset", + "schema": "public", + "values": ["USDC"] + }, + "public.leash_network": { + "name": "leash_network", + "schema": "public", + "values": ["eip155:8453", "eip155:42161"] + }, + "public.receipt_status": { + "name": "receipt_status", + "schema": "public", + "values": ["pending", "settled", "failed", "blocked"] + }, + "public.payer_type": { + "name": "payer_type", + "schema": "public", + "values": ["human", "agent"] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": ["pending", "settled", "failed"] + }, + "public.settlement_verification_method": { + "name": "settlement_verification_method", + "schema": "public", + "values": ["rpc", "particle", "x402_receipt", "simulated_test"] + }, + "public.settlement_verification_trigger": { + "name": "settlement_verification_trigger", + "schema": "public", + "values": ["inline", "cron_sweep"] + }, + "public.webhook_delivery_result": { + "name": "webhook_delivery_result", + "schema": "public", + "values": ["pending", "delivered", "retrying", "failed", "timeout", "gave_up"] + }, + "public.webhook_delivery_trigger": { + "name": "webhook_delivery_trigger", + "schema": "public", + "values": ["auto", "manual"] + }, + "public.webhook_delivery_type": { + "name": "webhook_delivery_type", + "schema": "public", + "values": ["payment", "test"] + }, + "public.webhook_failure_kind": { + "name": "webhook_failure_kind", + "schema": "public", + "values": ["http", "network", "timeout", "configuration"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json index 31ab638..4e8d144 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1784155282767, "tag": "0014_glossy_madripoor", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1784272542101, + "tag": "0015_wandering_dagger", + "breakpoints": true } ] } diff --git a/apps/web/lib/auth/leash-key.integration.test.ts b/apps/web/lib/auth/leash-key.integration.test.ts new file mode 100644 index 0000000..da05ee8 --- /dev/null +++ b/apps/web/lib/auth/leash-key.integration.test.ts @@ -0,0 +1,216 @@ +import { randomUUID } from "node:crypto"; + +import { and, eq, isNull } from "drizzle-orm"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { createDatabase } from "../db/client"; +import { agentEvents, agents, leashKeys, users } from "../db/schema"; +import { + ActiveLeashKeyExistsError, + ActiveLeashKeyNotFoundError, + authenticateLeashKey, + hashLeashKey, + InvalidLeashKeyError, + issueLeashKey, + rotateLeashKey, +} from "./leash-key"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for Leash key integration tests"); + +const connection = createDatabase(databaseUrl, 4); + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provisionAgent(label: string) { + const [user] = await connection.db + .insert(users) + .values({ + email: `${label}-${randomUUID()}@example.test`, + magicIssuer: `did:ethr:${randomUUID()}`, + }) + .returning({ id: users.id }); + if (!user) throw new Error("PostgreSQL did not return the Leash owner"); + + const [agent] = await connection.db + .insert(agents) + .values({ + name: `${label} agent`, + ownerId: user.id, + signerSubject: `leash:${randomUUID()}`, + }) + .returning({ id: agents.id }); + if (!agent) throw new Error("PostgreSQL did not return the Leash agent"); + return agent; +} + +describe("Leash key lifecycle with real PostgreSQL", () => { + it("issues show-once material while persisting only its SHA-256 hash and mask", async () => { + const agent = await provisionAgent("issue"); + + const created = await issueLeashKey(connection.db, { agentId: agent.id }); + const [stored] = await connection.db + .select() + .from(leashKeys) + .where(eq(leashKeys.id, created.key.id)); + + expect(created).toEqual({ + key: expect.objectContaining({ + agentId: agent.id, + last4: created.secret.slice(-4), + prefix: "leash_sk_", + rotatedFromId: null, + }), + secret: expect.stringMatching(/^leash_sk_[A-Za-z0-9_-]{43}$/), + }); + expect(stored).toMatchObject({ + agentId: agent.id, + hashedKey: hashLeashKey(created.secret), + last4: created.secret.slice(-4), + prefix: "leash_sk_", + }); + expect(JSON.stringify(stored)).not.toContain(created.secret); + }); + + it("serializes concurrent issuance and leaves exactly one active key", async () => { + const agent = await provisionAgent("concurrent-issue"); + + const results = await Promise.allSettled([ + issueLeashKey(connection.db, { agentId: agent.id }), + issueLeashKey(connection.db, { agentId: agent.id }), + ]); + const active = await connection.db + .select({ id: leashKeys.id }) + .from(leashKeys) + .where(and(eq(leashKeys.agentId, agent.id), isNull(leashKeys.revokedAt))); + + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + const rejection = results.find((result) => result.status === "rejected"); + expect(rejection).toMatchObject({ + reason: expect.any(ActiveLeashKeyExistsError), + status: "rejected", + }); + expect(active).toHaveLength(1); + }); + + it("authenticates only an exact active bearer and atomically stamps lastUsedAt", async () => { + const agent = await provisionAgent("authenticate"); + const created = await issueLeashKey(connection.db, { agentId: agent.id }); + + await expect(authenticateLeashKey(connection.db, `Bearer ${created.secret}`)).resolves.toEqual({ + agentId: agent.id, + leashKeyId: created.key.id, + }); + + const [stored] = await connection.db + .select({ lastUsedAt: leashKeys.lastUsedAt }) + .from(leashKeys) + .where(eq(leashKeys.id, created.key.id)); + expect(stored?.lastUsedAt).toBeInstanceOf(Date); + }); + + it("returns one generic rejection for missing, malformed, unknown, and revoked keys", async () => { + const agent = await provisionAgent("invalid"); + const created = await issueLeashKey(connection.db, { agentId: agent.id }); + await connection.db + .update(leashKeys) + .set({ revokedAt: new Date() }) + .where(eq(leashKeys.id, created.key.id)); + + const unknown = `leash_sk_${"z".repeat(43)}`; + for (const header of [null, created.secret, `Bearer ${unknown}`, `Bearer ${created.secret}`]) { + await expect(authenticateLeashKey(connection.db, header)).rejects.toEqual( + expect.objectContaining({ + code: "INVALID_LEASH_KEY", + message: "The Leash key is invalid or revoked.", + name: "InvalidLeashKeyError", + }), + ); + } + const [revoked] = await connection.db + .select({ lastUsedAt: leashKeys.lastUsedAt }) + .from(leashKeys) + .where(eq(leashKeys.id, created.key.id)); + expect(revoked?.lastUsedAt).toBeNull(); + }); + + it("revokes the old key and links the one-time replacement in one transaction", async () => { + const agent = await provisionAgent("rotate"); + const original = await issueLeashKey(connection.db, { agentId: agent.id }); + + const replacement = await rotateLeashKey(connection.db, { + agentId: agent.id, + keyId: original.key.id, + }); + const rows = await connection.db + .select() + .from(leashKeys) + .where(eq(leashKeys.agentId, agent.id)); + const events = await connection.db + .select() + .from(agentEvents) + .where(eq(agentEvents.agentId, agent.id)); + const oldRow = rows.find((row) => row.id === original.key.id); + const newRow = rows.find((row) => row.id === replacement.key.id); + + expect(oldRow?.revokedAt).toBeInstanceOf(Date); + expect(newRow).toMatchObject({ + hashedKey: hashLeashKey(replacement.secret), + revokedAt: null, + rotatedFromId: original.key.id, + }); + expect(JSON.stringify(rows)).not.toContain(original.secret); + expect(JSON.stringify(rows)).not.toContain(replacement.secret); + expect(events).toEqual([ + expect.objectContaining({ + actorSurface: "web", + agentId: agent.id, + metadata: { + reason: "key_rotation", + replacementKeyId: replacement.key.id, + revokedKeyId: original.key.id, + }, + type: "revoke", + }), + ]); + expect(JSON.stringify(events)).not.toContain(original.secret); + expect(JSON.stringify(events)).not.toContain(replacement.secret); + expect(JSON.stringify(events)).not.toContain(hashLeashKey(original.secret)); + expect(JSON.stringify(events)).not.toContain(hashLeashKey(replacement.secret)); + await expect( + authenticateLeashKey(connection.db, `Bearer ${original.secret}`), + ).rejects.toBeInstanceOf(InvalidLeashKeyError); + await expect( + authenticateLeashKey(connection.db, `Bearer ${replacement.secret}`), + ).resolves.toMatchObject({ agentId: agent.id, leashKeyId: replacement.key.id }); + }); + + it("allows only one concurrent rotation of the same active key", async () => { + const agent = await provisionAgent("concurrent-rotate"); + const original = await issueLeashKey(connection.db, { agentId: agent.id }); + const target = { agentId: agent.id, keyId: original.key.id }; + + const results = await Promise.allSettled([ + rotateLeashKey(connection.db, target), + rotateLeashKey(connection.db, target), + ]); + const active = await connection.db + .select({ id: leashKeys.id }) + .from(leashKeys) + .where(and(eq(leashKeys.agentId, agent.id), isNull(leashKeys.revokedAt))); + + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + const rejection = results.find((result) => result.status === "rejected"); + expect(rejection).toMatchObject({ + reason: expect.any(ActiveLeashKeyNotFoundError), + status: "rejected", + }); + expect(active).toHaveLength(1); + }); +}); diff --git a/apps/web/lib/auth/leash-key.test.ts b/apps/web/lib/auth/leash-key.test.ts new file mode 100644 index 0000000..3b1e443 --- /dev/null +++ b/apps/web/lib/auth/leash-key.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { + generateLeashKey, + hashLeashKey, + InvalidLeashKeyError, + readBearerLeashKey, +} from "./leash-key"; + +describe("Leash key material", () => { + it("generates 256-bit show-once material and hash-at-rest metadata", () => { + const first = generateLeashKey(); + const second = generateLeashKey(); + + expect(first.secret).toMatch(/^leash_sk_[A-Za-z0-9_-]{43}$/); + expect(first).toMatchObject({ + hash: expect.stringMatching(/^[0-9a-f]{64}$/), + last4: first.secret.slice(-4), + prefix: "leash_sk_", + }); + expect(first.hash).toBe(hashLeashKey(first.secret)); + expect(second.secret).not.toBe(first.secret); + expect(second.hash).not.toBe(first.hash); + }); + + it("accepts only an exact bearer credential and returns generic failures", () => { + const generated = generateLeashKey(); + expect(readBearerLeashKey(`Bearer ${generated.secret}`)).toBe(generated.secret); + expect(readBearerLeashKey(`bearer ${generated.secret}`)).toBe(generated.secret); + + for (const header of [ + null, + generated.secret, + `Bearer ${generated.secret}`, + `Bearer ${generated.secret}x`, + `Bearer ${generated.secret}\n`, + `Bearer ${generated.secret.toUpperCase()}`, + ]) { + expect(() => readBearerLeashKey(header)).toThrow(InvalidLeashKeyError); + } + }); +}); diff --git a/apps/web/lib/auth/leash-key.ts b/apps/web/lib/auth/leash-key.ts new file mode 100644 index 0000000..e481430 --- /dev/null +++ b/apps/web/lib/auth/leash-key.ts @@ -0,0 +1,195 @@ +import { createHash, randomBytes } from "node:crypto"; + +import { and, eq, isNull, sql } from "drizzle-orm"; + +import type { Database } from "../db/client"; +import { agentEvents, agents, leashKeys } from "../db/schema"; + +const LEASH_KEY_PREFIX = "leash_sk_"; +const LEASH_KEY_MATERIAL_LENGTH = 43; + +export class InvalidLeashKeyError extends Error { + readonly code = "INVALID_LEASH_KEY"; + + constructor() { + super("The Leash key is invalid or revoked."); + this.name = "InvalidLeashKeyError"; + } +} + +export class ActiveLeashKeyExistsError extends Error { + constructor() { + super("An active Leash key already exists for this agent."); + this.name = "ActiveLeashKeyExistsError"; + } +} + +export class ActiveLeashKeyNotFoundError extends Error { + constructor() { + super("The active Leash key was not found."); + this.name = "ActiveLeashKeyNotFoundError"; + } +} + +export class LeashAgentNotFoundError extends Error { + constructor() { + super("The Leash agent was not found."); + this.name = "LeashAgentNotFoundError"; + } +} + +export interface LeashKeyScope { + agentId: string; +} + +export interface LeashKeyTarget extends LeashKeyScope { + keyId: string; +} + +export interface LeashKeyPrincipal { + agentId: string; + leashKeyId: string; +} + +const keySummary = { + agentId: leashKeys.agentId, + createdAt: leashKeys.createdAt, + id: leashKeys.id, + last4: leashKeys.last4, + lastUsedAt: leashKeys.lastUsedAt, + prefix: leashKeys.prefix, + revokedAt: leashKeys.revokedAt, + rotatedFromId: leashKeys.rotatedFromId, +}; + +export function hashLeashKey(secret: string) { + return createHash("sha256").update(secret, "utf8").digest("hex"); +} + +export function generateLeashKey() { + const secret = `${LEASH_KEY_PREFIX}${randomBytes(32).toString("base64url")}`; + return { + hash: hashLeashKey(secret), + last4: secret.slice(-4), + prefix: LEASH_KEY_PREFIX, + secret, + }; +} + +export function readBearerLeashKey(authorizationHeader: string | null) { + const bearer = authorizationHeader?.match(/^Bearer ([A-Za-z0-9_-]+)$/i)?.[1]; + if ( + !bearer || + !new RegExp(`^${LEASH_KEY_PREFIX}[A-Za-z0-9_-]{${LEASH_KEY_MATERIAL_LENGTH}}$`).test(bearer) + ) { + throw new InvalidLeashKeyError(); + } + return bearer; +} + +export async function issueLeashKey(db: Database, input: LeashKeyScope) { + return db.transaction(async (transaction) => { + const [agent] = await transaction + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.id, input.agentId)) + .for("update"); + if (!agent) throw new LeashAgentNotFoundError(); + + const [active] = await transaction + .select({ id: leashKeys.id }) + .from(leashKeys) + .where(and(eq(leashKeys.agentId, input.agentId), isNull(leashKeys.revokedAt))) + .limit(1); + if (active) throw new ActiveLeashKeyExistsError(); + + const material = generateLeashKey(); + const [key] = await transaction + .insert(leashKeys) + .values({ + agentId: input.agentId, + hashedKey: material.hash, + last4: material.last4, + prefix: material.prefix, + }) + .returning(keySummary); + if (!key) throw new Error("PostgreSQL did not return the issued Leash key"); + + return { key, secret: material.secret }; + }); +} + +export async function rotateLeashKey(db: Database, input: LeashKeyTarget) { + return db.transaction(async (transaction) => { + const [agent] = await transaction + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.id, input.agentId)) + .for("update"); + if (!agent) throw new LeashAgentNotFoundError(); + + const [previous] = await transaction + .update(leashKeys) + .set({ revokedAt: sql`clock_timestamp()` }) + .where( + and( + eq(leashKeys.id, input.keyId), + eq(leashKeys.agentId, input.agentId), + isNull(leashKeys.revokedAt), + ), + ) + .returning({ id: leashKeys.id }); + if (!previous) throw new ActiveLeashKeyNotFoundError(); + + const material = generateLeashKey(); + const [key] = await transaction + .insert(leashKeys) + .values({ + agentId: input.agentId, + hashedKey: material.hash, + last4: material.last4, + prefix: material.prefix, + rotatedFromId: previous.id, + }) + .returning(keySummary); + if (!key) throw new Error("PostgreSQL did not return the rotated Leash key"); + + const [event] = await transaction + .insert(agentEvents) + .values({ + actorSurface: "web", + agentId: input.agentId, + metadata: { + reason: "key_rotation", + replacementKeyId: key.id, + revokedKeyId: previous.id, + }, + type: "revoke", + }) + .returning({ id: agentEvents.id }); + if (!event) throw new Error("PostgreSQL did not return the Leash key rotation event"); + + return { key, secret: material.secret }; + }); +} + +export async function authenticateLeashKey( + db: Database, + authorizationHeader: string | null, +): Promise { + const secret = readBearerLeashKey(authorizationHeader); + const [principal] = await db + .update(leashKeys) + .set({ lastUsedAt: sql`clock_timestamp()` }) + .where( + and( + eq(leashKeys.hashedKey, hashLeashKey(secret)), + eq(leashKeys.prefix, LEASH_KEY_PREFIX), + isNull(leashKeys.revokedAt), + ), + ) + .returning({ agentId: leashKeys.agentId, leashKeyId: leashKeys.id }); + + if (!principal) throw new InvalidLeashKeyError(); + return principal; +} diff --git a/apps/web/lib/db/leash-schema.integration.test.ts b/apps/web/lib/db/leash-schema.integration.test.ts new file mode 100644 index 0000000..701ebe4 --- /dev/null +++ b/apps/web/lib/db/leash-schema.integration.test.ts @@ -0,0 +1,252 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import postgres from "postgres"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for Leash schema tests"); + +const sql = postgres(databaseUrl, { max: 1 }); +const payTo = "0x1111111111111111111111111111111111111111"; +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const arbitrumUsdc = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"; + +async function createOwnerAgent(label: string, agentAddress: string | null = null) { + const [owner] = await sql<{ id: string }[]>` + insert into users (email, magic_issuer) + values (${`${label}-${randomUUID()}@example.test`}, ${`did:ethr:${randomUUID()}`}) + returning id + `; + if (!owner) throw new Error("Expected an owner row"); + const [agent] = await sql<{ id: string }[]>` + insert into agents (owner_id, name, status, signer_subject, agent_address) + values ( + ${owner.id}, ${`Agent ${label}`}, 'provisioned', + ${`leash:${randomUUID()}`}, ${agentAddress} + ) + returning id + `; + if (!agent) throw new Error("Expected an agent row"); + return { agentId: agent.id, ownerId: owner.id }; +} + +async function createCycle(agentId: string) { + const [cycle] = await sql<{ id: string }[]>` + insert into cap_cycles (agent_id, started_at) + values (${agentId}, now() - interval '1 minute') + returning id + `; + if (!cycle) throw new Error("Expected a cap cycle row"); + return cycle.id; +} + +function authorization() { + return { + fingerprint: randomBytes(32).toString("hex"), + nonce: `0x${randomBytes(32).toString("hex")}`, + }; +} + +function insertReceipt( + agentId: string, + cycleId: string, + values: { + amountAtomic?: string; + asset?: string; + fingerprint?: string; + network?: string; + nonce?: string; + reason?: string | null; + status?: string; + } = {}, +) { + const auth = authorization(); + const status = values.status ?? "pending"; + const settled = status === "settled"; + const blocked = status === "blocked"; + const network = values.network ?? "eip155:8453"; + const asset = values.asset ?? (network === "eip155:42161" ? arbitrumUsdc : baseUsdc); + return sql` + insert into receipts ( + agent_id, cycle_id, status, amount_atomic, amount_usd, asset, network, + pay_to, authorization_nonce, request_fingerprint, authorization_valid_before, + origin, reason, intended_network, tx_hash, settlement_response, settled_at + ) values ( + ${agentId}, ${cycleId}, ${status}, ${values.amountAtomic ?? "1000000"}, '1.000000', + ${asset}, ${network}, ${payTo}, + ${values.nonce ?? auth.nonce}, ${values.fingerprint ?? auth.fingerprint}, + now() + interval '5 minutes', ${sql.json({ clientName: "integration", toolName: "pay", transport: "mcp" })}, + ${values.reason ?? (blocked ? "CAP_EXCEEDED" : null)}, + ${blocked ? network : null}, + ${settled ? `0x${"a".repeat(64)}` : null}, + ${settled ? sql.json({ success: true }) : null}, ${settled ? new Date() : null} + ) + returning id + `; +} + +describe("Phase 6 Leash PostgreSQL schema", () => { + beforeEach(async () => { + await sql`truncate table users cascade`; + }); + + afterAll(async () => { + await sql.end(); + }); + + it("creates every canonical Leash ledger table", async () => { + const rows = await sql<{ table_name: string }[]>` + select table_name + from information_schema.tables + where table_schema = 'public' + and table_name in ( + 'agents', 'leash_keys', 'caps', 'cap_cycles', + 'receipts', 'floats', 'agent_events' + ) + order by table_name + `; + + expect(rows.map((row) => row.table_name)).toEqual([ + "agent_events", + "agents", + "cap_cycles", + "caps", + "floats", + "leash_keys", + "receipts", + ]); + }); + + it("keeps B03 wallet provisioning nullable while enforcing owner and agent identity", async () => { + const { agentId } = await createOwnerAgent("nullable-address"); + const [stored] = await sql<{ agent_address: string | null }[]>` + select agent_address from agents where id = ${agentId} + `; + expect(stored?.agent_address).toBeNull(); + + await expect( + sql`update agents set agent_address = '0x1234' where id = ${agentId}`, + ).rejects.toMatchObject({ code: "23514" }); + await expect( + sql`update agents set status = 'running' where id = ${agentId}`, + ).rejects.toMatchObject({ code: "22P02" }); + await expect(sql` + insert into agents (owner_id, name, status, signer_subject) + values (${randomUUID()}, 'Orphan', 'provisioned', ${`leash:${randomUUID()}`}) + `).rejects.toMatchObject({ code: "23503" }); + }); + + it("enforces one active Leash key, a positive cap, and one active cycle", async () => { + const { agentId } = await createOwnerAgent("key-cap-cycle"); + const hash = randomBytes(32).toString("hex"); + await sql` + insert into leash_keys (agent_id, hashed_key, prefix, last4) + values (${agentId}, ${hash}, 'leash_sk_', 'a1B2') + `; + await expect(sql` + insert into leash_keys (agent_id, hashed_key, prefix, last4) + values (${agentId}, ${randomBytes(32).toString("hex")}, 'leash_sk_', 'c3D4') + `).rejects.toMatchObject({ code: "23505" }); + await expect(sql` + insert into caps (agent_id, amount_usd_cents, frequency) + values (${agentId}, 0, 'daily') + `).rejects.toMatchObject({ code: "23514" }); + + await sql` + insert into caps (agent_id, amount_usd_cents, frequency) + values (${agentId}, 1000, 'daily') + `; + await createCycle(agentId); + await expect(createCycle(agentId)).rejects.toMatchObject({ code: "23505" }); + }); + + it("persists only canonical receipt states with replay-safe authorization evidence", async () => { + const { agentId } = await createOwnerAgent("receipts"); + const cycleId = await createCycle(agentId); + for (const status of ["pending", "settled", "failed", "blocked"]) { + await insertReceipt(agentId, cycleId, { + ...(status === "failed" ? { reason: "SIGNER_NOT_CONFIGURED" } : {}), + status, + }); + } + const rows = await sql<{ status: string }[]>` + select status from receipts order by status + `; + expect(rows.map((row) => row.status)).toEqual(["pending", "settled", "failed", "blocked"]); + await expect( + insertReceipt(agentId, cycleId, { network: "eip155:42161" }), + ).resolves.toHaveLength(1); + + const duplicate = authorization(); + await insertReceipt(agentId, cycleId, duplicate); + await expect(insertReceipt(agentId, cycleId, { nonce: duplicate.nonce })).rejects.toMatchObject( + { + code: "23505", + }, + ); + await expect( + insertReceipt(agentId, cycleId, { fingerprint: duplicate.fingerprint }), + ).rejects.toMatchObject({ + code: "23505", + }); + await expect(insertReceipt(agentId, cycleId, { amountAtomic: "0" })).rejects.toMatchObject({ + code: "23514", + }); + await expect(insertReceipt(agentId, cycleId, { amountAtomic: "1.5" })).rejects.toMatchObject({ + code: "23514", + }); + await expect(insertReceipt(agentId, cycleId, { network: "eip155:137" })).rejects.toMatchObject({ + code: "22P02", + }); + await expect( + insertReceipt(agentId, cycleId, { + asset: "0x3333333333333333333333333333333333333333", + }), + ).rejects.toMatchObject({ + code: "23514", + }); + await sql`update receipts set origin = null where status = 'pending'`; + await expect(sql` + update receipts set status = 'settled' where status = 'pending' + `).rejects.toMatchObject({ code: "23514" }); + }); + + it("prevents receipts from borrowing another agent's cycle", async () => { + const first = await createOwnerAgent("cycle-first"); + const second = await createOwnerAgent("cycle-second"); + const firstCycle = await createCycle(first.agentId); + await expect(insertReceipt(second.agentId, firstCycle)).rejects.toMatchObject({ + code: "23503", + }); + }); + + it("stores only Base and Arbitrum native-USDC float snapshots and owned audit events", async () => { + const { agentId } = await createOwnerAgent("floats-events"); + await sql` + insert into floats (agent_id, network, asset, token_address, balance_atomic, balance_usd) + values + (${agentId}, 'eip155:8453', 'USDC', ${baseUsdc}, 0, 0), + (${agentId}, 'eip155:42161', 'USDC', ${arbitrumUsdc}, 1000000, 1) + `; + await expect(sql` + insert into floats (agent_id, network, asset, token_address, balance_atomic, balance_usd) + values (${agentId}, 'eip155:137', 'USDC', ${baseUsdc}, 1, 1) + `).rejects.toMatchObject({ code: "22P02" }); + await expect(sql` + update floats set token_address = ${arbitrumUsdc} + where agent_id = ${agentId} and network = 'eip155:8453' + `).rejects.toMatchObject({ code: "23514" }); + await expect(sql` + update floats set balance_atomic = -1 where agent_id = ${agentId} + `).rejects.toMatchObject({ code: "23514" }); + + await sql` + insert into agent_events (agent_id, type, actor_surface, metadata) + values (${agentId}, 'connect', 'agent', ${sql.json({ clientName: "integration" })}) + `; + await expect(sql` + insert into agent_events (agent_id, type, actor_surface) + values (${randomUUID()}, 'sign', 'system') + `).rejects.toMatchObject({ code: "23503" }); + }); +}); diff --git a/apps/web/lib/db/leash-schema.ts b/apps/web/lib/db/leash-schema.ts new file mode 100644 index 0000000..2a555c2 --- /dev/null +++ b/apps/web/lib/db/leash-schema.ts @@ -0,0 +1,298 @@ +import { sql } from "drizzle-orm"; +import { + type AnyPgColumn, + check, + foreignKey, + index, + integer, + jsonb, + numeric, + pgEnum, + pgTable, + text, + timestamp, + unique, + uniqueIndex, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +import { users } from "./identity-schema"; + +export const agentStatus = pgEnum("agent_status", [ + "provisioned", + "paused", + "frozen", + "cancelled", + "nuked", +]); +export const capFrequency = pgEnum("cap_frequency", ["daily", "weekly", "monthly", "never"]); +export const capResetReason = pgEnum("cap_reset_reason", [ + "schedule", + "manual", + "frequency_change", +]); +export const receiptStatus = pgEnum("receipt_status", ["pending", "settled", "failed", "blocked"]); +export const leashNetwork = pgEnum("leash_network", ["eip155:8453", "eip155:42161"]); +export const leashAsset = pgEnum("leash_asset", ["USDC"]); +export const agentEventType = pgEnum("agent_event_type", ["connect", "sign", "block", "revoke"]); +export const agentEventSurface = pgEnum("agent_event_surface", [ + "agent", + "web", + "pwa", + "push_action", + "system", +]); + +export type ReceiptOrigin = { + clientName?: string; + toolName?: string; + transport: "http" | "mcp"; +}; + +export const agents = pgTable( + "agents", + { + id: uuid("id").defaultRandom().primaryKey(), + ownerId: uuid("owner_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + status: agentStatus("status").default("provisioned").notNull(), + signerSubject: text("signer_subject").notNull(), + agentAddress: varchar("agent_address", { length: 42 }), + clientName: text("client_name"), + clientVersion: text("client_version"), + transport: text("transport"), + connectionCount: integer("connection_count").default(0).notNull(), + firstSeenAt: timestamp("first_seen_at", { withTimezone: true }), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("agents_owner_id_idx").on(table.ownerId), + uniqueIndex("agents_signer_subject_unique").on(table.signerSubject), + uniqueIndex("agents_agent_address_unique") + .on(table.agentAddress) + .where(sql`${table.agentAddress} is not null`), + check("agents_name_check", sql`${table.name} ~ '[^[:space:]]'`), + check("agents_signer_subject_check", sql`${table.signerSubject} ~ '[^[:space:]]'`), + check( + "agents_address_check", + sql`${table.agentAddress} is null or (${table.agentAddress} ~ '^0x[0-9a-fA-F]{40}$' + and lower(${table.agentAddress}) <> '0x0000000000000000000000000000000000000000')`, + ), + check("agents_connection_count_check", sql`${table.connectionCount} >= 0`), + ], +); + +export const leashKeys = pgTable( + "leash_keys", + { + id: uuid("id").defaultRandom().primaryKey(), + agentId: uuid("agent_id") + .notNull() + .references(() => agents.id, { onDelete: "cascade" }), + hashedKey: varchar("hashed_key", { length: 64 }).notNull(), + prefix: text("prefix").notNull(), + last4: varchar("last4", { length: 4 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + lastAuthFailureAt: timestamp("last_auth_failure_at", { withTimezone: true }), + lastAuthFailureCode: text("last_auth_failure_code"), + rotatedFromId: uuid("rotated_from_id").references((): AnyPgColumn => leashKeys.id), + }, + (table) => [ + uniqueIndex("leash_keys_hashed_key_unique").on(table.hashedKey), + uniqueIndex("leash_keys_one_active_per_agent") + .on(table.agentId) + .where(sql`${table.revokedAt} is null`), + check("leash_keys_hash_check", sql`${table.hashedKey} ~ '^[0-9a-f]{64}$'`), + check("leash_keys_prefix_check", sql`${table.prefix} = 'leash_sk_'`), + check("leash_keys_last4_check", sql`${table.last4} ~ '^[A-Za-z0-9_-]{4}$'`), + ], +); + +export const caps = pgTable( + "caps", + { + agentId: uuid("agent_id") + .primaryKey() + .references(() => agents.id, { onDelete: "cascade" }), + amountUsdCents: numeric("amount_usd_cents", { precision: 20, scale: 0 }), + frequency: capFrequency("frequency").notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + check("caps_amount_check", sql`${table.amountUsdCents} is null or ${table.amountUsdCents} > 0`), + ], +); + +export const capCycles = pgTable( + "cap_cycles", + { + id: uuid("id").defaultRandom().primaryKey(), + agentId: uuid("agent_id") + .notNull() + .references(() => agents.id, { onDelete: "cascade" }), + startedAt: timestamp("started_at", { withTimezone: true }).notNull(), + endedAt: timestamp("ended_at", { withTimezone: true }), + resetReason: capResetReason("reset_reason"), + }, + (table) => [ + unique("cap_cycles_id_agent_unique").on(table.id, table.agentId), + uniqueIndex("cap_cycles_one_active_per_agent") + .on(table.agentId) + .where(sql`${table.endedAt} is null`), + index("cap_cycles_agent_started_idx").on(table.agentId, table.startedAt.desc()), + check( + "cap_cycles_end_check", + sql`(${table.endedAt} is null and ${table.resetReason} is null) + or (${table.endedAt} > ${table.startedAt} and ${table.resetReason} is not null)`, + ), + ], +); + +export const receipts = pgTable( + "receipts", + { + id: uuid("id").defaultRandom().primaryKey(), + agentId: uuid("agent_id") + .notNull() + .references(() => agents.id, { onDelete: "cascade" }), + cycleId: uuid("cycle_id").notNull(), + parentId: uuid("parent_id").references((): AnyPgColumn => receipts.id), + status: receiptStatus("status").default("pending").notNull(), + reason: text("reason"), + amountAtomic: numeric("amount_atomic").notNull(), + amountUsd: numeric("amount_usd", { precision: 38, scale: 6 }).notNull(), + asset: varchar("asset", { length: 42 }).notNull(), + network: leashNetwork("network").notNull(), + intendedNetwork: leashNetwork("intended_network"), + payTo: varchar("pay_to", { length: 42 }).notNull(), + authorizationNonce: varchar("authorization_nonce", { length: 66 }).notNull(), + requestFingerprint: varchar("request_fingerprint", { length: 64 }).notNull(), + authorizationValidBefore: timestamp("authorization_valid_before", { + withTimezone: true, + }).notNull(), + origin: jsonb("origin").$type(), + settlementResponse: jsonb("settlement_response").$type>(), + txHash: varchar("tx_hash", { length: 66 }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + settledAt: timestamp("settled_at", { withTimezone: true }), + }, + (table) => [ + foreignKey({ + columns: [table.cycleId, table.agentId], + foreignColumns: [capCycles.id, capCycles.agentId], + name: "receipts_cycle_agent_fk", + }), + uniqueIndex("receipts_agent_nonce_unique").on(table.agentId, table.authorizationNonce), + uniqueIndex("receipts_agent_fingerprint_unique").on(table.agentId, table.requestFingerprint), + uniqueIndex("receipts_network_tx_hash_unique") + .on(table.network, table.txHash) + .where(sql`${table.txHash} is not null`), + index("receipts_cap_gate_idx").on(table.agentId, table.cycleId, table.status), + index("receipts_agent_created_idx").on(table.agentId, table.createdAt.desc()), + check( + "receipts_amount_atomic_check", + sql`${table.amountAtomic} > 0 and ${table.amountAtomic} = trunc(${table.amountAtomic})`, + ), + check("receipts_amount_usd_check", sql`${table.amountUsd} > 0`), + check( + "receipts_pay_to_check", + sql`${table.payTo} ~ '^0x[0-9a-fA-F]{40}$' + and lower(${table.payTo}) <> '0x0000000000000000000000000000000000000000'`, + ), + check( + "receipts_native_usdc_check", + sql`(${table.network} = 'eip155:8453' + and lower(${table.asset}) = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913') + or (${table.network} = 'eip155:42161' + and lower(${table.asset}) = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')`, + ), + check( + "receipts_authorization_check", + sql`${table.authorizationNonce} ~ '^0x[0-9a-fA-F]{64}$' + and ${table.requestFingerprint} ~ '^[0-9a-f]{64}$'`, + ), + check( + "receipts_origin_check", + sql`${table.origin} is null or (jsonb_typeof(${table.origin}) = 'object' + and ${table.origin} ? 'transport' + and ${table.origin}->>'transport' in ('mcp', 'http'))`, + ), + check( + "receipts_tx_hash_check", + sql`${table.txHash} is null or ${table.txHash} ~ '^0x[0-9a-fA-F]{64}$'`, + ), + check( + "receipts_state_check", + sql`(${table.status} = 'pending' and ${table.reason} is null + and ${table.intendedNetwork} is null and ${table.txHash} is null + and ${table.settlementResponse} is null and ${table.settledAt} is null) + or (${table.status} = 'settled' and ${table.reason} is null + and ${table.intendedNetwork} is null and ${table.txHash} is not null + and ${table.settlementResponse} is not null and ${table.settledAt} is not null) + or (${table.status} = 'failed' and ${table.reason} ~ '[^[:space:]]' + and ${table.intendedNetwork} is null and ${table.txHash} is null + and ${table.settlementResponse} is null and ${table.settledAt} is null) + or (${table.status} = 'blocked' and ${table.reason} ~ '[^[:space:]]' + and ${table.intendedNetwork} is not null and ${table.txHash} is null + and ${table.settlementResponse} is null and ${table.settledAt} is null)`, + ), + ], +); + +export const floats = pgTable( + "floats", + { + id: uuid("id").defaultRandom().primaryKey(), + agentId: uuid("agent_id") + .notNull() + .references(() => agents.id, { onDelete: "cascade" }), + network: leashNetwork("network").notNull(), + asset: leashAsset("asset").notNull(), + tokenAddress: varchar("token_address", { length: 42 }).notNull(), + balanceAtomic: numeric("balance_atomic").notNull(), + balanceUsd: numeric("balance_usd", { precision: 38, scale: 6 }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("floats_agent_network_unique").on(table.agentId, table.network), + check( + "floats_balance_atomic_check", + sql`${table.balanceAtomic} >= 0 and ${table.balanceAtomic} = trunc(${table.balanceAtomic})`, + ), + check("floats_balance_usd_check", sql`${table.balanceUsd} >= 0`), + check( + "floats_native_usdc_check", + sql`(${table.network} = 'eip155:8453' + and lower(${table.tokenAddress}) = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913') + or (${table.network} = 'eip155:42161' + and lower(${table.tokenAddress}) = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')`, + ), + ], +); + +export const agentEvents = pgTable( + "agent_events", + { + id: uuid("id").defaultRandom().primaryKey(), + agentId: uuid("agent_id") + .notNull() + .references(() => agents.id, { onDelete: "cascade" }), + type: agentEventType("type").notNull(), + actorSurface: agentEventSurface("actor_surface").notNull(), + metadata: jsonb("metadata") + .$type>() + .default(sql`'{}'::jsonb`) + .notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("agent_events_agent_created_idx").on(table.agentId, table.createdAt.desc()), + check("agent_events_metadata_check", sql`jsonb_typeof(${table.metadata}) = 'object'`), + ], +); diff --git a/apps/web/lib/db/schema.ts b/apps/web/lib/db/schema.ts index b88e30f..0448345 100644 --- a/apps/web/lib/db/schema.ts +++ b/apps/web/lib/db/schema.ts @@ -1,4 +1,5 @@ export * from "./identity-schema"; +export * from "./leash-schema"; export * from "./order-schema"; export * from "./payment-schema"; export * from "./webhook-schema"; diff --git a/apps/web/lib/leash/connect.ts b/apps/web/lib/leash/connect.ts new file mode 100644 index 0000000..eae28c8 --- /dev/null +++ b/apps/web/lib/leash/connect.ts @@ -0,0 +1,133 @@ +import { eq, sql } from "drizzle-orm"; + +import type { Database } from "../db/client"; +import { agentEvents, agents } from "../db/schema"; + +const MAX_CONNECT_BODY_BYTES = 2_048; +const MAX_CLIENT_NAME_LENGTH = 200; +const MAX_CLIENT_VERSION_LENGTH = 100; + +export type AgentTransport = "http" | "mcp"; + +export interface ConnectAgentInput { + agentId: string; + clientName: string | null; + clientVersion: string | null; + transport: AgentTransport; +} + +export interface ConnectRequest { + clientName: string | null; + clientVersion: string | null; + transport: AgentTransport; +} + +export class InvalidConnectRequestError extends Error { + constructor() { + super("The connect request is invalid."); + this.name = "InvalidConnectRequestError"; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactKeys(record: Record, allowed: readonly string[]) { + const keys = Object.keys(record); + return keys.length <= allowed.length && keys.every((key) => allowed.includes(key)); +} + +function parseClientInfo(value: unknown) { + if (!isRecord(value) || !hasExactKeys(value, ["name", "version"])) { + throw new InvalidConnectRequestError(); + } + const { name, version } = value; + if ( + typeof name !== "string" || + name.trim().length === 0 || + name.length > MAX_CLIENT_NAME_LENGTH || + (version !== undefined && + (typeof version !== "string" || + version.trim().length === 0 || + version.length > MAX_CLIENT_VERSION_LENGTH)) + ) { + throw new InvalidConnectRequestError(); + } + return { clientName: name, clientVersion: version ?? null }; +} + +export function parseConnectRequest(rawBody: string): ConnectRequest { + if (new TextEncoder().encode(rawBody).byteLength > MAX_CONNECT_BODY_BYTES) { + throw new InvalidConnectRequestError(); + } + + let value: unknown; + try { + value = JSON.parse(rawBody); + } catch { + throw new InvalidConnectRequestError(); + } + if (!isRecord(value) || !hasExactKeys(value, ["clientInfo", "transport"])) { + throw new InvalidConnectRequestError(); + } + if (value.transport !== "mcp" && value.transport !== "http") { + throw new InvalidConnectRequestError(); + } + + const identity = + value.clientInfo === undefined + ? { clientName: null, clientVersion: null } + : parseClientInfo(value.clientInfo); + return { ...identity, transport: value.transport }; +} + +export async function connectAgent(db: Database, input: ConnectAgentInput) { + return db.transaction(async (transaction) => { + const [connected] = await transaction + .update(agents) + .set({ + clientName: input.clientName, + clientVersion: input.clientVersion, + connectionCount: sql`${agents.connectionCount} + 1`, + firstSeenAt: sql`coalesce(${agents.firstSeenAt}, clock_timestamp())`, + lastSeenAt: sql`clock_timestamp()`, + transport: input.transport, + }) + .where(eq(agents.id, input.agentId)) + .returning({ + agentAddress: agents.agentAddress, + clientName: agents.clientName, + clientVersion: agents.clientVersion, + connectionCount: agents.connectionCount, + firstSeenAt: agents.firstSeenAt, + lastSeenAt: agents.lastSeenAt, + transport: agents.transport, + }); + if (!connected?.firstSeenAt || !connected.lastSeenAt) { + throw new Error("The authenticated Leash agent was not found"); + } + + await transaction.insert(agentEvents).values({ + actorSurface: "agent", + agentId: input.agentId, + metadata: { + clientName: connected.clientName, + clientVersion: connected.clientVersion, + connectionCount: connected.connectionCount, + transport: connected.transport, + }, + type: "connect", + }); + + return { + agentAddress: connected.agentAddress, + clientName: connected.clientName, + clientVersion: connected.clientVersion, + connectionCount: connected.connectionCount, + firstSeenAt: connected.firstSeenAt, + lastSeenAt: connected.lastSeenAt, + transport: input.transport, + }; + }); +} diff --git a/apps/web/lib/leash/float-balance.integration.test.ts b/apps/web/lib/leash/float-balance.integration.test.ts new file mode 100644 index 0000000..516d7bb --- /dev/null +++ b/apps/web/lib/leash/float-balance.integration.test.ts @@ -0,0 +1,64 @@ +import { createServer } from "node:http"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { readFloatBalance } from "./float-balance"; + +const agentAddress = "0x2222222222222222222222222222222222222222"; + +describe("live USDC float balance through viem", () => { + let rpcUrl = ""; + const calls: Array<{ method: string; params: unknown[] }> = []; + const server = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + calls.push({ method: body.method, params: body.params }); + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + id: body.id, + jsonrpc: "2.0", + result: `0x${BigInt(50_000).toString(16).padStart(64, "0")}`, + }), + ); + }); + + beforeAll(async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP listener"); + rpcUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); + + it("reads Base native USDC balanceOf from the configured RPC", async () => { + await expect( + readFloatBalance({ address: agentAddress, network: "eip155:8453", rpcUrl }), + ).resolves.toBe(BigInt(50_000)); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + method: "eth_call", + params: [ + { + data: expect.stringMatching(/^0x70a08231/), + to: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + }, + "latest", + ], + }); + }); + + it("rejects unsupported networks before making an RPC call", async () => { + const before = calls.length; + await expect( + readFloatBalance({ address: agentAddress, network: "eip155:137", rpcUrl }), + ).rejects.toMatchObject({ code: "UNSUPPORTED_NETWORK" }); + expect(calls).toHaveLength(before); + }); +}); diff --git a/apps/web/lib/leash/float-balance.ts b/apps/web/lib/leash/float-balance.ts new file mode 100644 index 0000000..f977658 --- /dev/null +++ b/apps/web/lib/leash/float-balance.ts @@ -0,0 +1,54 @@ +import { createPublicClient, http, isAddress } from "viem"; +import { arbitrum, base } from "viem/chains"; + +const FLOATS = { + "eip155:42161": { + chain: arbitrum, + env: "ARBITRUM_RPC_URL", + token: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "eip155:8453": { + chain: base, + env: "BASE_RPC_URL", + token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + }, +} as const; + +const BALANCE_OF_ABI = [ + { + inputs: [{ name: "account", type: "address" }], + name: "balanceOf", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, +] as const; + +export class FloatBalanceError extends Error { + constructor(readonly code: "INVALID_AGENT_ADDRESS" | "UNSUPPORTED_NETWORK") { + super( + code === "UNSUPPORTED_NETWORK" + ? "The float network is unsupported." + : "The agent address is invalid.", + ); + this.name = "FloatBalanceError"; + } +} + +export async function readFloatBalance(options: { + address: string; + network: string; + rpcUrl?: string; +}) { + if (!isAddress(options.address)) throw new FloatBalanceError("INVALID_AGENT_ADDRESS"); + const float = FLOATS[options.network as keyof typeof FLOATS]; + if (!float) throw new FloatBalanceError("UNSUPPORTED_NETWORK"); + const rpcUrl = options.rpcUrl ?? process.env[float.env] ?? float.chain.rpcUrls.default.http[0]; + const client = createPublicClient({ chain: float.chain, transport: http(rpcUrl) }); + return client.readContract({ + abi: BALANCE_OF_ABI, + address: float.token, + args: [options.address], + functionName: "balanceOf", + }); +} diff --git a/apps/web/lib/leash/pay-result-store.integration.test.ts b/apps/web/lib/leash/pay-result-store.integration.test.ts new file mode 100644 index 0000000..598d2e5 --- /dev/null +++ b/apps/web/lib/leash/pay-result-store.integration.test.ts @@ -0,0 +1,138 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createDatabase } from "../db/client"; +import { applySettlementObservation, SettlementResultConflictError } from "./pay-result-store"; +import { parseSettlementObservation } from "./settlement-evidence"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for pay-result tests"); +const connection = createDatabase(databaseUrl, 2); +const agentAddress = "0x2222222222222222222222222222222222222222"; +const payTo = "0x1111111111111111111111111111111111111111"; +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const nonce = `0x${"12".repeat(32)}` as const; +const transaction = `0x${"ab".repeat(32)}` as const; + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function pendingReceipt() { + const [owner] = await connection.client<{ id: string }[]>` + insert into users (email, magic_issuer) + values (${`${randomUUID()}@example.test`}, ${`did:ethr:${randomUUID()}`}) returning id + `; + if (!owner) throw new Error("Expected owner"); + const [agent] = await connection.client<{ id: string }[]>` + insert into agents (owner_id, name, signer_subject, agent_address) + values (${owner.id}, 'Result test', ${`leash:${randomUUID()}`}, ${agentAddress}) returning id + `; + if (!agent) throw new Error("Expected agent"); + const [cycle] = await connection.client<{ id: string }[]>` + insert into cap_cycles (agent_id, started_at) + values (${agent.id}, now() - interval '1 minute') returning id + `; + if (!cycle) throw new Error("Expected cycle"); + const [receipt] = await connection.client<{ id: string }[]>` + insert into receipts ( + agent_id, cycle_id, amount_atomic, amount_usd, asset, network, pay_to, + authorization_nonce, request_fingerprint, authorization_valid_before + ) values ( + ${agent.id}, ${cycle.id}, '25000', '0.025000', ${baseUsdc}, 'eip155:8453', + ${payTo}, ${nonce}, ${randomBytes(32).toString("hex")}, now() + interval '5 minutes' + ) returning id + `; + if (!receipt) throw new Error("Expected receipt"); + return { agentId: agent.id, receiptId: receipt.id }; +} + +function observation(receiptId: string, txHash = transaction) { + return parseSettlementObservation({ + outcome: "observed", + paymentResponse: { + network: "eip155:8453", + payer: agentAddress, + success: true, + transaction: txHash, + }, + receiptId, + }); +} + +describe("on-chain verified receipt finalization", () => { + it("keeps forged but shaped resource evidence pending", async () => { + const pending = await pendingReceipt(); + const verify = vi.fn(async () => false); + + await expect( + applySettlementObservation(connection.db, { + agentId: pending.agentId, + evidence: observation(pending.receiptId), + verify, + }), + ).resolves.toEqual({ kind: "pending", receiptId: pending.receiptId, verified: false }); + const [stored] = await connection.client<{ status: string; tx_hash: string | null }[]>` + select status, tx_hash from receipts where id = ${pending.receiptId} + `; + expect(stored).toEqual({ status: "pending", tx_hash: null }); + expect(verify).toHaveBeenCalledOnce(); + }); + + it("settles only after proof and makes identical callbacks idempotent", async () => { + const pending = await pendingReceipt(); + const verify = vi.fn(async () => true); + const evidence = observation(pending.receiptId); + + await expect( + applySettlementObservation(connection.db, { agentId: pending.agentId, evidence, verify }), + ).resolves.toEqual({ kind: "settled", receiptId: pending.receiptId, verified: true }); + await expect( + applySettlementObservation(connection.db, { agentId: pending.agentId, evidence, verify }), + ).resolves.toEqual({ kind: "settled", receiptId: pending.receiptId, verified: true }); + + const [stored] = await connection.client< + { + settlement_response: Record; + status: string; + tx_hash: string; + }[] + >`select status, tx_hash, settlement_response from receipts where id = ${pending.receiptId}`; + expect(stored).toMatchObject({ + settlement_response: { proof: "usdc_transfer_and_authorization_used" }, + status: "settled", + tx_hash: transaction, + }); + expect(verify).toHaveBeenCalledOnce(); + }); + + it("scopes observations to the authenticated agent and rejects conflicting final evidence", async () => { + const pending = await pendingReceipt(); + await expect( + applySettlementObservation(connection.db, { + agentId: randomUUID(), + evidence: observation(pending.receiptId), + verify: async () => true, + }), + ).resolves.toEqual({ kind: "not_found" }); + + const evidence = observation(pending.receiptId); + await applySettlementObservation(connection.db, { + agentId: pending.agentId, + evidence, + verify: async () => true, + }); + await expect( + applySettlementObservation(connection.db, { + agentId: pending.agentId, + evidence: observation(pending.receiptId, `0x${"cd".repeat(32)}`), + verify: async () => true, + }), + ).rejects.toBeInstanceOf(SettlementResultConflictError); + }); +}); diff --git a/apps/web/lib/leash/pay-result-store.ts b/apps/web/lib/leash/pay-result-store.ts new file mode 100644 index 0000000..68a56b5 --- /dev/null +++ b/apps/web/lib/leash/pay-result-store.ts @@ -0,0 +1,116 @@ +import { and, eq } from "drizzle-orm"; + +import type { Database } from "../db/client"; +import { agents, receipts } from "../db/schema"; +import { type parseSettlementObservation, verifySettlementOnchain } from "./settlement-evidence"; + +type SettlementEvidence = ReturnType; +type VerifySettlement = typeof verifySettlementOnchain; + +export class SettlementResultConflictError extends Error { + readonly code = "SETTLEMENT_RESULT_CONFLICT"; + readonly status = 409; + + constructor() { + super("The receipt already has different terminal evidence."); + this.name = "SettlementResultConflictError"; + } +} + +function terminalResult( + receipt: Pick, + evidence: SettlementEvidence, +) { + if (receipt.status === "settled") { + if (receipt.txHash?.toLowerCase() !== evidence.transaction.toLowerCase()) { + throw new SettlementResultConflictError(); + } + return { kind: "settled" as const, receiptId: receipt.id, verified: true }; + } + return { kind: receipt.status, receiptId: receipt.id, verified: false } as const; +} + +export async function applySettlementObservation( + db: Database, + options: { + agentId: string; + evidence: SettlementEvidence; + verify?: VerifySettlement; + }, +) { + const candidate = await db.transaction(async (transaction) => { + const [row] = await transaction + .select({ + agentAddress: agents.agentAddress, + amountAtomic: receipts.amountAtomic, + authorizationNonce: receipts.authorizationNonce, + id: receipts.id, + network: receipts.network, + payTo: receipts.payTo, + status: receipts.status, + txHash: receipts.txHash, + }) + .from(receipts) + .innerJoin(agents, eq(agents.id, receipts.agentId)) + .where( + and(eq(receipts.id, options.evidence.receiptId), eq(receipts.agentId, options.agentId)), + ) + .for("update"); + if (!row) return { kind: "not_found" as const }; + if (row.status !== "pending") return terminalResult(row, options.evidence); + if (!row.agentAddress) { + return { kind: "pending" as const, receiptId: row.id, verified: false }; + } + return { + agentAddress: row.agentAddress, + amountAtomic: row.amountAtomic, + authorizationNonce: row.authorizationNonce as `0x${string}`, + kind: "candidate" as const, + network: row.network, + payTo: row.payTo, + receiptId: row.id, + }; + }); + if (candidate.kind !== "candidate") return candidate; + + const verify = options.verify ?? verifySettlementOnchain; + const verified = await verify(options.evidence, { + agentAddress: candidate.agentAddress, + amountAtomic: candidate.amountAtomic, + authorizationNonce: candidate.authorizationNonce, + network: candidate.network, + payTo: candidate.payTo, + }); + if (!verified) { + return { kind: "pending" as const, receiptId: candidate.receiptId, verified: false }; + } + + return db.transaction(async (transaction) => { + const [current] = await transaction + .select({ id: receipts.id, status: receipts.status, txHash: receipts.txHash }) + .from(receipts) + .where(and(eq(receipts.id, candidate.receiptId), eq(receipts.agentId, options.agentId))) + .for("update"); + if (!current) return { kind: "not_found" as const }; + if (current.status !== "pending") return terminalResult(current, options.evidence); + + const [settled] = await transaction + .update(receipts) + .set({ + settledAt: new Date(), + settlementResponse: { + network: options.evidence.network, + payer: options.evidence.payer, + proof: "usdc_transfer_and_authorization_used", + success: true, + transaction: options.evidence.transaction, + }, + status: "settled", + txHash: options.evidence.transaction, + }) + .where(and(eq(receipts.id, current.id), eq(receipts.status, "pending"))) + .returning({ id: receipts.id }); + if (!settled) throw new SettlementResultConflictError(); + return { kind: "settled" as const, receiptId: settled.id, verified: true }; + }); +} diff --git a/apps/web/lib/leash/settlement-evidence.integration.test.ts b/apps/web/lib/leash/settlement-evidence.integration.test.ts new file mode 100644 index 0000000..50f2ba2 --- /dev/null +++ b/apps/web/lib/leash/settlement-evidence.integration.test.ts @@ -0,0 +1,195 @@ +import { createServer } from "node:http"; + +import { encodeAbiParameters, encodeEventTopics } from "viem"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { + InvalidSettlementObservationError, + parseSettlementObservation, + verifySettlementOnchain, +} from "./settlement-evidence"; + +const agentAddress = "0x2222222222222222222222222222222222222222"; +const payTo = "0x1111111111111111111111111111111111111111"; +const facilitator = "0x3333333333333333333333333333333333333333"; +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const transaction = `0x${"ab".repeat(32)}` as const; +const nonce = `0x${"12".repeat(32)}` as const; +const blockHash = `0x${"cd".repeat(32)}`; + +const settlementEvents = [ + { + anonymous: false, + inputs: [ + { indexed: true, name: "from", type: "address" }, + { indexed: true, name: "to", type: "address" }, + { indexed: false, name: "value", type: "uint256" }, + ], + name: "Transfer", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, name: "authorizer", type: "address" }, + { indexed: true, name: "nonce", type: "bytes32" }, + ], + name: "AuthorizationUsed", + type: "event", + }, +] as const; + +function observedResult(overrides: Record = {}) { + return { + outcome: "observed", + paymentResponse: { + network: "eip155:8453", + payer: agentAddress, + success: true, + transaction, + }, + receiptId: "550e8400-e29b-41d4-a716-446655440000", + ...overrides, + }; +} + +function rpcLog( + topics: ReturnType, + data: `0x${string}`, + logIndex: string, +) { + return { + address: baseUsdc, + blockHash, + blockNumber: "0x1", + data, + logIndex, + removed: false, + topics: topics.map((topic) => { + if (typeof topic !== "string") throw new Error("Expected fully encoded event topics"); + return topic; + }), + transactionHash: transaction, + transactionIndex: "0x0", + }; +} + +function validReceipt() { + const transferTopics = encodeEventTopics({ + abi: settlementEvents, + args: { from: agentAddress, to: payTo }, + eventName: "Transfer", + }); + const authorizationTopics = encodeEventTopics({ + abi: settlementEvents, + args: { authorizer: agentAddress, nonce }, + eventName: "AuthorizationUsed", + }); + return { + blockHash, + blockNumber: "0x1", + contractAddress: null, + cumulativeGasUsed: "0x5208", + effectiveGasPrice: "0x1", + from: facilitator, + gasUsed: "0x5208", + logs: [ + rpcLog(transferTopics, encodeAbiParameters([{ type: "uint256" }], [BigInt(25_000)]), "0x0"), + rpcLog(authorizationTopics, "0x", "0x1"), + ], + logsBloom: `0x${"00".repeat(256)}`, + status: "0x1", + to: baseUsdc, + transactionHash: transaction, + transactionIndex: "0x0", + type: "0x2", + }; +} + +describe("settlement observation and on-chain proof", () => { + let rpcUrl = ""; + let receipt = validReceipt(); + const rpcMethods: string[] = []; + const server = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + rpcMethods.push(body.method); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ id: body.id, jsonrpc: "2.0", result: receipt })); + }); + + beforeAll(async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP listener"); + rpcUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); + + it("parses only a canonical untrusted observation, never a settled claim", () => { + expect(parseSettlementObservation(observedResult())).toMatchObject({ + network: "eip155:8453", + payer: agentAddress, + receiptId: "550e8400-e29b-41d4-a716-446655440000", + transaction, + }); + expect(() => parseSettlementObservation(observedResult({ outcome: "settled" }))).toThrow( + InvalidSettlementObservationError, + ); + }); + + it("proves the exact native-USDC transfer and authorization nonce over real viem RPC", async () => { + receipt = validReceipt(); + const evidence = parseSettlementObservation(observedResult()); + + await expect( + verifySettlementOnchain(evidence, { + agentAddress, + amountAtomic: "25000", + authorizationNonce: nonce, + network: "eip155:8453", + payTo, + rpcUrl, + }), + ).resolves.toBe(true); + expect(rpcMethods.at(-1)).toBe("eth_getTransactionReceipt"); + }); + + it("keeps a shaped resource claim unproven without its matching nonce-use log", async () => { + const withoutAuthorization = validReceipt(); + withoutAuthorization.logs = withoutAuthorization.logs.slice(0, 1); + receipt = withoutAuthorization; + + await expect( + verifySettlementOnchain(parseSettlementObservation(observedResult()), { + agentAddress, + amountAtomic: "25000", + authorizationNonce: nonce, + network: "eip155:8453", + payTo, + rpcUrl, + }), + ).resolves.toBe(false); + }); + + it("rejects an observation that conflicts with the reserved payer or network before RPC", async () => { + const before = rpcMethods.length; + await expect( + verifySettlementOnchain(parseSettlementObservation(observedResult()), { + agentAddress: "0x4444444444444444444444444444444444444444", + amountAtomic: "25000", + authorizationNonce: nonce, + network: "eip155:8453", + payTo, + rpcUrl, + }), + ).resolves.toBe(false); + expect(rpcMethods).toHaveLength(before); + }); +}); diff --git a/apps/web/lib/leash/settlement-evidence.ts b/apps/web/lib/leash/settlement-evidence.ts new file mode 100644 index 0000000..ea689dc --- /dev/null +++ b/apps/web/lib/leash/settlement-evidence.ts @@ -0,0 +1,172 @@ +import { createPublicClient, decodeEventLog, getAddress, http, isAddress, type Log } from "viem"; +import { arbitrum, base } from "viem/chains"; + +const SETTLEMENT_NETWORKS = { + "eip155:42161": { + chain: arbitrum, + env: "ARBITRUM_RPC_URL", + token: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "eip155:8453": { + chain: base, + env: "BASE_RPC_URL", + token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + }, +} as const; + +const SETTLEMENT_EVENTS = [ + { + anonymous: false, + inputs: [ + { indexed: true, name: "from", type: "address" }, + { indexed: true, name: "to", type: "address" }, + { indexed: false, name: "value", type: "uint256" }, + ], + name: "Transfer", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, name: "authorizer", type: "address" }, + { indexed: true, name: "nonce", type: "bytes32" }, + ], + name: "AuthorizationUsed", + type: "event", + }, +] as const; + +type LeashNetwork = keyof typeof SETTLEMENT_NETWORKS; + +export class InvalidSettlementObservationError extends Error { + readonly code = "INVALID_SETTLEMENT_OBSERVATION"; + + constructor() { + super("The settlement observation is invalid."); + this.name = "InvalidSettlementObservationError"; + } +} + +function exactRecord(value: unknown, keys: string[]) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new InvalidSettlementObservationError(); + } + const record = value as Record; + const actual = Object.keys(record).sort(); + if (actual.length !== keys.length || actual.some((key, index) => key !== keys[index])) { + throw new InvalidSettlementObservationError(); + } + return record; +} + +function canonicalAddress(value: unknown) { + if (typeof value !== "string" || !isAddress(value)) { + throw new InvalidSettlementObservationError(); + } + return getAddress(value); +} + +export function parseSettlementObservation(value: unknown) { + const body = exactRecord(value, ["outcome", "paymentResponse", "receiptId"]); + if ( + body.outcome !== "observed" || + typeof body.receiptId !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + body.receiptId, + ) + ) { + throw new InvalidSettlementObservationError(); + } + const response = exactRecord(body.paymentResponse, [ + "network", + "payer", + "success", + "transaction", + ]); + if ( + response.success !== true || + typeof response.network !== "string" || + !(response.network in SETTLEMENT_NETWORKS) || + typeof response.transaction !== "string" || + !/^0x[0-9a-fA-F]{64}$/.test(response.transaction) + ) { + throw new InvalidSettlementObservationError(); + } + return { + network: response.network as LeashNetwork, + payer: canonicalAddress(response.payer), + receiptId: body.receiptId, + transaction: response.transaction as `0x${string}`, + }; +} + +function decodedLog(log: Log) { + try { + return decodeEventLog({ + abi: SETTLEMENT_EVENTS, + data: log.data, + strict: true, + topics: log.topics, + }); + } catch { + return null; + } +} + +export async function verifySettlementOnchain( + evidence: ReturnType, + expected: { + agentAddress: string; + amountAtomic: string; + authorizationNonce: `0x${string}`; + network: string; + payTo: string; + rpcUrl?: string; + }, +) { + if ( + !isAddress(expected.agentAddress) || + !isAddress(expected.payTo) || + evidence.network !== expected.network || + evidence.payer !== getAddress(expected.agentAddress) || + !/^[1-9][0-9]*$/.test(expected.amountAtomic) || + !/^0x[0-9a-fA-F]{64}$/.test(expected.authorizationNonce) + ) { + return false; + } + const config = SETTLEMENT_NETWORKS[evidence.network]; + const rpcUrl = expected.rpcUrl ?? process.env[config.env] ?? config.chain.rpcUrls.default.http[0]; + const client = createPublicClient({ chain: config.chain, transport: http(rpcUrl) }); + let transactionReceipt: Awaited>; + try { + transactionReceipt = await client.getTransactionReceipt({ hash: evidence.transaction }); + } catch { + return false; + } + if ( + transactionReceipt.status !== "success" || + !transactionReceipt.to || + getAddress(transactionReceipt.to) !== getAddress(config.token) + ) { + return false; + } + + let transferred = false; + let authorizationUsed = false; + for (const log of transactionReceipt.logs) { + if (getAddress(log.address) !== getAddress(config.token)) continue; + const decoded = decodedLog(log); + if (decoded?.eventName === "Transfer") { + transferred = + getAddress(decoded.args.from) === getAddress(expected.agentAddress) && + getAddress(decoded.args.to) === getAddress(expected.payTo) && + decoded.args.value === BigInt(expected.amountAtomic); + } + if (decoded?.eventName === "AuthorizationUsed") { + authorizationUsed = + getAddress(decoded.args.authorizer) === getAddress(expected.agentAddress) && + decoded.args.nonce.toLowerCase() === expected.authorizationNonce.toLowerCase(); + } + } + return transferred && authorizationUsed; +} diff --git a/apps/web/lib/leash/sign-request.test.ts b/apps/web/lib/leash/sign-request.test.ts new file mode 100644 index 0000000..07064a7 --- /dev/null +++ b/apps/web/lib/leash/sign-request.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { InvalidSignRequestError, parseSignRequest } from "./sign-request"; + +const agentAddress = "0x2222222222222222222222222222222222222222"; + +function validRequest() { + return { + amount: "25000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + network: "eip155:8453", + origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, + payTo: "0x1111111111111111111111111111111111111111", + signerRequest: { + domain: { + chainId: 8453, + name: "USD Coin", + verifyingContract: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + version: "2", + }, + message: { + from: agentAddress, + nonce: `0x${"12".repeat(32)}`, + to: "0x1111111111111111111111111111111111111111", + validAfter: "0", + validBefore: "1784271600", + value: "25000", + }, + primaryType: "TransferWithAuthorization", + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + }, + }; +} + +describe("remote EIP-3009 sign request validation", () => { + it("derives canonical authority from the exact x402 typed-data shape", () => { + expect( + parseSignRequest(validRequest(), { agentAddress, nowSeconds: 1_784_271_300 }), + ).toMatchObject({ + amountAtomic: "25000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + authorizationNonce: `0x${"12".repeat(32)}`, + authorizationValidBefore: new Date(1_784_271_600_000), + network: "eip155:8453", + payTo: "0x1111111111111111111111111111111111111111", + requestFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/), + }); + }); + + it.each([ + [ + "Permit2", + (request: ReturnType) => + (request.signerRequest.primaryType = "PermitTransferFrom"), + ], + [ + "another token", + (request: ReturnType) => + (request.asset = "0x3333333333333333333333333333333333333333"), + ], + [ + "another payer", + (request: ReturnType) => + (request.signerRequest.message.from = "0x4444444444444444444444444444444444444444"), + ], + [ + "a mismatched amount", + (request: ReturnType) => (request.amount = "25001"), + ], + [ + "a mismatched recipient", + (request: ReturnType) => + (request.payTo = "0x5555555555555555555555555555555555555555"), + ], + [ + "an expired authorization", + (request: ReturnType) => + (request.signerRequest.message.validBefore = "1784271200"), + ], + ])("rejects %s", (_label, mutate) => { + const request = validRequest(); + mutate(request); + + expect(() => parseSignRequest(request, { agentAddress, nowSeconds: 1_784_271_300 })).toThrow( + InvalidSignRequestError, + ); + }); +}); diff --git a/apps/web/lib/leash/sign-request.ts b/apps/web/lib/leash/sign-request.ts new file mode 100644 index 0000000..0135203 --- /dev/null +++ b/apps/web/lib/leash/sign-request.ts @@ -0,0 +1,183 @@ +import { createHash } from "node:crypto"; + +import { getAddress, isAddress } from "viem"; + +const BASE_NETWORK = "eip155:8453"; +const ARBITRUM_NETWORK = "eip155:42161"; +const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const ARBITRUM_USDC = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"; +const MAX_AUTHORIZATION_LIFETIME_SECONDS = 600; +const AUTHORIZATION_TYPES = [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, +]; + +export class InvalidSignRequestError extends Error { + readonly code = "INVALID_SIGN_REQUEST"; + + constructor() { + super("The signing request is invalid."); + this.name = "InvalidSignRequestError"; + } +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exactRecord(value: unknown, keys: string[]) { + if (!record(value)) throw new InvalidSignRequestError(); + const actual = Object.keys(value).sort(); + if (actual.length !== keys.length || actual.some((key, index) => key !== keys[index])) { + throw new InvalidSignRequestError(); + } + return value; +} + +function unsigned(value: unknown) { + const serialized = + typeof value === "number" && Number.isSafeInteger(value) ? String(value) : value; + if (typeof serialized !== "string" || !/^(0|[1-9][0-9]*)$/.test(serialized)) { + throw new InvalidSignRequestError(); + } + return serialized; +} + +function address(value: unknown) { + if (typeof value !== "string" || !isAddress(value)) throw new InvalidSignRequestError(); + return getAddress(value); +} + +function supportedNetwork(value: unknown) { + if (value === BASE_NETWORK) + return { asset: getAddress(BASE_USDC), chainId: "8453", network: BASE_NETWORK } as const; + if (value === ARBITRUM_NETWORK) { + return { + asset: getAddress(ARBITRUM_USDC), + chainId: "42161", + network: ARBITRUM_NETWORK, + } as const; + } + throw new InvalidSignRequestError(); +} + +function origin(value: unknown) { + if (value === undefined) return null; + const parsed = exactRecord(value, ["clientName", "toolName", "transport"]); + if ( + typeof parsed.clientName !== "string" || + parsed.clientName.length < 1 || + parsed.clientName.length > 200 || + typeof parsed.toolName !== "string" || + parsed.toolName.length < 1 || + parsed.toolName.length > 500 || + (parsed.transport !== "mcp" && parsed.transport !== "http") + ) { + throw new InvalidSignRequestError(); + } + return { + clientName: parsed.clientName, + toolName: parsed.toolName, + transport: parsed.transport as "http" | "mcp", + }; +} + +export function parseSignRequest( + value: unknown, + options: { agentAddress: string; nowSeconds?: number }, +) { + const body = exactRecord(value, [ + "amount", + "asset", + "network", + "origin", + "payTo", + "signerRequest", + ]); + const network = supportedNetwork(body.network); + const amountAtomic = unsigned(body.amount); + if (BigInt(amountAtomic) === BigInt(0)) throw new InvalidSignRequestError(); + const asset = address(body.asset); + const payTo = address(body.payTo); + if (asset !== network.asset) throw new InvalidSignRequestError(); + + const signerRequest = exactRecord(body.signerRequest, [ + "domain", + "message", + "primaryType", + "types", + ]); + if (signerRequest.primaryType !== "TransferWithAuthorization") { + throw new InvalidSignRequestError(); + } + const domain = exactRecord(signerRequest.domain, [ + "chainId", + "name", + "verifyingContract", + "version", + ]); + if ( + domain.name !== "USD Coin" || + domain.version !== "2" || + unsigned(domain.chainId) !== network.chainId || + address(domain.verifyingContract) !== asset + ) { + throw new InvalidSignRequestError(); + } + const types = exactRecord(signerRequest.types, ["TransferWithAuthorization"]); + if (JSON.stringify(types.TransferWithAuthorization) !== JSON.stringify(AUTHORIZATION_TYPES)) { + throw new InvalidSignRequestError(); + } + + const message = exactRecord(signerRequest.message, [ + "from", + "nonce", + "to", + "validAfter", + "validBefore", + "value", + ]); + const from = address(message.from); + const authorizationNonce = message.nonce; + const validAfter = unsigned(message.validAfter); + const validBefore = unsigned(message.validBefore); + const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1_000); + if ( + from !== address(options.agentAddress) || + address(message.to) !== payTo || + unsigned(message.value) !== amountAtomic || + validAfter !== "0" || + typeof authorizationNonce !== "string" || + !/^0x[0-9a-fA-F]{64}$/.test(authorizationNonce) || + BigInt(validBefore) <= BigInt(nowSeconds) || + BigInt(validBefore) > BigInt(nowSeconds + MAX_AUTHORIZATION_LIFETIME_SECONDS) + ) { + throw new InvalidSignRequestError(); + } + + const fingerprint = [ + network.network, + asset, + from, + payTo, + amountAtomic, + validAfter, + validBefore, + authorizationNonce, + ]; + return { + amountAtomic, + asset, + authorizationNonce, + authorizationValidBefore: new Date(Number(validBefore) * 1_000), + network: network.network, + origin: origin(body.origin), + payTo, + requestFingerprint: createHash("sha256").update(fingerprint.join("\0")).digest("hex"), + signerRequest: { domain, message, primaryType: signerRequest.primaryType, types }, + }; +} diff --git a/apps/web/lib/leash/sign-store.integration.test.ts b/apps/web/lib/leash/sign-store.integration.test.ts new file mode 100644 index 0000000..d51ced9 --- /dev/null +++ b/apps/web/lib/leash/sign-store.integration.test.ts @@ -0,0 +1,224 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { createDatabase } from "../db/client"; +import { completePreSigningChecks, reserveSignRequest, type SignGateError } from "./sign-store"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for sign-store tests"); + +const connection = createDatabase(databaseUrl, 4); +const agentAddress = "0x2222222222222222222222222222222222222222"; +const payTo = "0x1111111111111111111111111111111111111111"; +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const nowSeconds = 1_784_271_300; + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provision( + options: { + capCents?: string | null; + status?: "provisioned" | "paused" | "frozen" | "cancelled" | "nuked"; + } = {}, +) { + const [owner] = await connection.client<{ id: string }[]>` + insert into users (email, magic_issuer) + values (${`${randomUUID()}@example.test`}, ${`did:ethr:${randomUUID()}`}) + returning id + `; + if (!owner) throw new Error("Expected an owner"); + const [agent] = await connection.client<{ id: string }[]>` + insert into agents (owner_id, name, status, signer_subject, agent_address) + values ( + ${owner.id}, 'Sign test', ${options.status ?? "provisioned"}, + ${`leash:${randomUUID()}`}, ${agentAddress} + ) returning id + `; + if (!agent) throw new Error("Expected an agent"); + const [key] = await connection.client<{ id: string }[]>` + insert into leash_keys (agent_id, hashed_key, prefix, last4) + values (${agent.id}, ${randomBytes(32).toString("hex")}, 'leash_sk_', 'a1B2') + returning id + `; + const [cycle] = await connection.client<{ id: string }[]>` + insert into cap_cycles (agent_id, started_at) + values (${agent.id}, now() - interval '1 minute') returning id + `; + if (!key || !cycle) throw new Error("Expected key and cycle"); + if (options.capCents !== null) { + await connection.client` + insert into caps (agent_id, amount_usd_cents, frequency) + values (${agent.id}, ${options.capCents ?? "100"}, 'daily') + `; + } + return { agentId: agent.id, cycleId: cycle.id, keyId: key.id }; +} + +function signBody(amount = "250000", nonce = randomBytes(32).toString("hex")) { + return { + amount, + asset: baseUsdc, + network: "eip155:8453", + origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, + payTo, + signerRequest: { + domain: { + chainId: 8453, + name: "USD Coin", + verifyingContract: baseUsdc, + version: "2", + }, + message: { + from: agentAddress, + nonce: `0x${nonce}`, + to: payTo, + validAfter: "0", + validBefore: String(nowSeconds + 300), + value: amount, + }, + primaryType: "TransferWithAuthorization", + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + }, + }; +} + +async function insertCommitted( + agentId: string, + cycleId: string, + status: "pending" | "settled" | "failed" | "blocked", + amount: string, +) { + const terminal = status === "settled"; + const blocked = status === "blocked"; + await connection.client` + insert into receipts ( + agent_id, cycle_id, status, reason, amount_atomic, amount_usd, asset, network, + intended_network, pay_to, authorization_nonce, request_fingerprint, + authorization_valid_before, origin, tx_hash, settlement_response, settled_at + ) values ( + ${agentId}, ${cycleId}, ${status}, + ${status === "failed" ? "FLOAT_EMPTY" : blocked ? "LEASH_CAP_EXCEEDED" : null}, + ${amount}, ${amount}::numeric / 1000000, ${baseUsdc}, 'eip155:8453', + ${blocked ? "eip155:8453" : null}, ${payTo}, + ${`0x${randomBytes(32).toString("hex")}`}, ${randomBytes(32).toString("hex")}, + now() + interval '5 minutes', null, + ${terminal ? `0x${randomBytes(32).toString("hex")}` : null}, + ${terminal ? JSON.stringify({ verified: true }) : null}::jsonb, + ${terminal ? new Date().toISOString() : null}::timestamptz + ) + `; +} + +describe("atomic hosted-signer reservation gate", () => { + it.each([ + ["paused", "AGENT_PAUSED"], + ["frozen", "AGENT_FROZEN"], + ["cancelled", "AGENT_CANCELLED"], + ["nuked", "AGENT_CANCELLED"], + ] as const)("rejects %s before parsing an invalid request", async (status, code) => { + const identity = await provision({ status }); + await expect( + reserveSignRequest(connection.db, { + ...identity, + body: { invalid: true }, + nowSeconds, + }), + ).rejects.toMatchObject({ code, status: 423 } satisfies Partial); + const [count] = await connection.client<{ count: string }[]>`select count(*) from receipts`; + expect(count?.count).toBe("0"); + }); + + it("counts pending and settled only, allows exact cap, then writes a blocked attempt", async () => { + const identity = await provision({ capCents: "100" }); + await insertCommitted(identity.agentId, identity.cycleId, "settled", "300000"); + await insertCommitted(identity.agentId, identity.cycleId, "pending", "300000"); + await insertCommitted(identity.agentId, identity.cycleId, "failed", "900000"); + await insertCommitted(identity.agentId, identity.cycleId, "blocked", "900000"); + + const exact = await reserveSignRequest(connection.db, { + ...identity, + body: signBody("400000"), + nowSeconds, + }); + expect(exact).toMatchObject({ kind: "pending", replayed: false }); + const blocked = await reserveSignRequest(connection.db, { + ...identity, + body: signBody("1"), + nowSeconds, + }); + expect(blocked).toMatchObject({ code: "LEASH_CAP_EXCEEDED", kind: "blocked" }); + + const [stored] = await connection.client<{ intended_network: string; status: string }[]>` + select status, intended_network from receipts where id = ${blocked.receiptId} + `; + expect(stored).toEqual({ intended_network: "eip155:8453", status: "blocked" }); + }); + + it("serializes concurrent requests so only one can reserve the remaining cap", async () => { + const identity = await provision({ capCents: "50" }); + const results = await Promise.all( + [signBody("300000"), signBody("300000")].map((body) => + reserveSignRequest(connection.db, { ...identity, body, nowSeconds }), + ), + ); + expect(results.map((result) => result.kind).sort()).toEqual(["blocked", "pending"]); + }); + + it("reuses an identical nonce reservation without double-counting it", async () => { + const identity = await provision(); + const body = signBody(); + const first = await reserveSignRequest(connection.db, { ...identity, body, nowSeconds }); + const second = await reserveSignRequest(connection.db, { ...identity, body, nowSeconds }); + expect(second).toMatchObject({ kind: "pending", receiptId: first.receiptId, replayed: true }); + const [count] = await connection.client<{ count: string }[]>`select count(*) from receipts`; + expect(count?.count).toBe("1"); + }); + + it("fails safely before signing when pending floats overcommit or the signer is blocked", async () => { + const identity = await provision({ capCents: "200" }); + const first = await reserveSignRequest(connection.db, { + ...identity, + body: signBody("600000"), + nowSeconds, + }); + const second = await reserveSignRequest(connection.db, { + ...identity, + body: signBody("600000"), + nowSeconds, + }); + if (first.kind !== "pending" || second.kind !== "pending") throw new Error("Expected pending"); + + await expect( + completePreSigningChecks(connection.db, { + ...identity, + liveBalanceAtomic: BigInt(1_000_000), + receiptId: second.receiptId, + signerAvailable: true, + }), + ).resolves.toMatchObject({ code: "FLOAT_EMPTY", kind: "failed" }); + await expect( + completePreSigningChecks(connection.db, { + ...identity, + liveBalanceAtomic: BigInt(1_000_000), + receiptId: first.receiptId, + signerAvailable: false, + }), + ).resolves.toMatchObject({ code: "SIGNER_NOT_CONFIGURED", kind: "failed" }); + }); +}); diff --git a/apps/web/lib/leash/sign-store.ts b/apps/web/lib/leash/sign-store.ts new file mode 100644 index 0000000..ce34016 --- /dev/null +++ b/apps/web/lib/leash/sign-store.ts @@ -0,0 +1,258 @@ +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; + +import type { Database } from "../db/client"; +import { agentEvents, agents, capCycles, caps, leashKeys, receipts } from "../db/schema"; +import { InvalidSignRequestError, parseSignRequest } from "./sign-request"; + +const ATOMIC_UNITS_PER_CENT = BigInt(10_000); + +type GateCode = + | "AGENT_PAUSED" + | "AGENT_FROZEN" + | "AGENT_CANCELLED" + | "INVALID_LEASH_KEY" + | "LEASH_CAP_NOT_SET" + | "SIGNER_NOT_CONFIGURED" + | "SIGN_REQUEST_CONFLICT"; + +export class SignGateError extends Error { + constructor( + readonly code: GateCode, + readonly status: number, + ) { + super("The signing request cannot proceed."); + this.name = "SignGateError"; + } +} + +function statusError(status: typeof agents.$inferSelect.status) { + if (status === "paused") return new SignGateError("AGENT_PAUSED", 423); + if (status === "frozen") return new SignGateError("AGENT_FROZEN", 423); + if (status === "cancelled" || status === "nuked") { + return new SignGateError("AGENT_CANCELLED", 423); + } + return null; +} + +function amountUsd(amountAtomic: string) { + const value = BigInt(amountAtomic); + return `${value / BigInt(1_000_000)}.${(value % BigInt(1_000_000)).toString().padStart(6, "0")}`; +} + +function decodeBody(body: unknown) { + if (typeof body !== "string") return body; + try { + return JSON.parse(body) as unknown; + } catch { + throw new InvalidSignRequestError(); + } +} + +function pendingResult( + receiptId: string, + parsed: ReturnType, + agentAddress: string, + replayed: boolean, +) { + return { + agentAddress, + amountAtomic: parsed.amountAtomic, + kind: "pending" as const, + network: parsed.network, + receiptId, + replayed, + signerRequest: parsed.signerRequest, + }; +} + +export async function reserveSignRequest( + db: Database, + options: { + agentId: string; + body: unknown; + keyId: string; + nowSeconds?: number; + }, +) { + return db.transaction(async (transaction) => { + const [agent] = await transaction + .select({ address: agents.agentAddress, id: agents.id, status: agents.status }) + .from(agents) + .where(eq(agents.id, options.agentId)) + .for("update"); + if (!agent) throw new SignGateError("INVALID_LEASH_KEY", 401); + + const blockedStatus = statusError(agent.status); + if (blockedStatus) throw blockedStatus; + if (!agent.address) throw new SignGateError("SIGNER_NOT_CONFIGURED", 503); + + const [key] = await transaction + .select({ id: leashKeys.id }) + .from(leashKeys) + .where( + and( + eq(leashKeys.id, options.keyId), + eq(leashKeys.agentId, agent.id), + isNull(leashKeys.revokedAt), + ), + ); + if (!key) throw new SignGateError("INVALID_LEASH_KEY", 401); + + const parsed = parseSignRequest(decodeBody(options.body), { + agentAddress: agent.address, + ...(options.nowSeconds === undefined ? {} : { nowSeconds: options.nowSeconds }), + }); + const [existing] = await transaction + .select() + .from(receipts) + .where( + and( + eq(receipts.agentId, agent.id), + eq(receipts.authorizationNonce, parsed.authorizationNonce), + ), + ); + if (existing) { + if (existing.requestFingerprint !== parsed.requestFingerprint) { + throw new SignGateError("SIGN_REQUEST_CONFLICT", 409); + } + if (existing.status === "pending") { + return pendingResult(existing.id, parsed, agent.address, true); + } + return { + code: + existing.status === "blocked" + ? "LEASH_CAP_EXCEEDED" + : (existing.reason ?? "SIGN_REQUEST_CONFLICT"), + kind: existing.status, + receiptId: existing.id, + } as const; + } + + const [policy] = await transaction + .select({ amountUsdCents: caps.amountUsdCents, cycleId: capCycles.id }) + .from(caps) + .innerJoin(capCycles, and(eq(capCycles.agentId, caps.agentId), isNull(capCycles.endedAt))) + .where(eq(caps.agentId, agent.id)); + if (!policy?.amountUsdCents) throw new SignGateError("LEASH_CAP_NOT_SET", 403); + + const [usage] = await transaction + .select({ + amountAtomic: sql`coalesce(sum(${receipts.amountAtomic}), 0)::text`, + }) + .from(receipts) + .where( + and( + eq(receipts.agentId, agent.id), + eq(receipts.cycleId, policy.cycleId), + inArray(receipts.status, ["pending", "settled"]), + ), + ); + const exceedsCap = + BigInt(usage?.amountAtomic ?? "0") + BigInt(parsed.amountAtomic) > + BigInt(policy.amountUsdCents) * ATOMIC_UNITS_PER_CENT; + const status = exceedsCap ? "blocked" : "pending"; + const receiptValues: typeof receipts.$inferInsert = { + agentId: agent.id, + amountAtomic: parsed.amountAtomic, + amountUsd: amountUsd(parsed.amountAtomic), + asset: parsed.asset, + authorizationNonce: parsed.authorizationNonce, + authorizationValidBefore: parsed.authorizationValidBefore, + cycleId: policy.cycleId, + intendedNetwork: exceedsCap ? parsed.network : null, + network: parsed.network, + origin: parsed.origin, + payTo: parsed.payTo, + reason: exceedsCap ? "LEASH_CAP_EXCEEDED" : null, + requestFingerprint: parsed.requestFingerprint, + status, + }; + const [created] = await transaction + .insert(receipts) + .values(receiptValues) + .returning({ id: receipts.id }); + if (!created) throw new Error("PostgreSQL did not return the receipt reservation"); + await transaction.insert(agentEvents).values({ + actorSurface: "agent", + agentId: agent.id, + metadata: { receiptId: created.id }, + type: exceedsCap ? "block" : "sign", + }); + if (exceedsCap) { + return { + code: "LEASH_CAP_EXCEEDED" as const, + kind: "blocked" as const, + receiptId: created.id, + }; + } + return pendingResult(created.id, parsed, agent.address, false); + }); +} + +export async function completePreSigningChecks( + db: Database, + options: { + agentId: string; + keyId: string; + liveBalanceAtomic: bigint; + receiptId: string; + signerAvailable: boolean; + }, +) { + if (options.liveBalanceAtomic < BigInt(0)) throw new Error("Float balance cannot be negative"); + return db.transaction(async (transaction) => { + const [agent] = await transaction + .select({ id: agents.id, status: agents.status }) + .from(agents) + .where(eq(agents.id, options.agentId)) + .for("update"); + const [key] = await transaction + .select({ id: leashKeys.id }) + .from(leashKeys) + .where( + and( + eq(leashKeys.id, options.keyId), + eq(leashKeys.agentId, options.agentId), + isNull(leashKeys.revokedAt), + ), + ); + const [receipt] = await transaction + .select() + .from(receipts) + .where(and(eq(receipts.id, options.receiptId), eq(receipts.agentId, options.agentId))) + .for("update"); + if (!agent || !key || !receipt) throw new SignGateError("INVALID_LEASH_KEY", 401); + if (receipt.status !== "pending") { + return { code: receipt.reason, kind: receipt.status, receiptId: receipt.id } as const; + } + + const currentStatusError = statusError(agent.status); + let failure: GateCode | "FLOAT_EMPTY" | undefined = currentStatusError?.code; + if (!failure) { + const [reserved] = await transaction + .select({ amountAtomic: sql`coalesce(sum(${receipts.amountAtomic}), 0)::text` }) + .from(receipts) + .where( + and( + eq(receipts.agentId, agent.id), + eq(receipts.cycleId, receipt.cycleId), + eq(receipts.network, receipt.network), + eq(receipts.status, "pending"), + ), + ); + if (BigInt(reserved?.amountAtomic ?? "0") > options.liveBalanceAtomic) { + failure = "FLOAT_EMPTY"; + } else if (!options.signerAvailable) { + failure = "SIGNER_NOT_CONFIGURED"; + } + } + if (failure) { + await transaction + .update(receipts) + .set({ reason: failure, status: "failed" }) + .where(and(eq(receipts.id, receipt.id), eq(receipts.status, "pending"))); + return { code: failure, kind: "failed" as const, receiptId: receipt.id }; + } + return { kind: "ready" as const, receiptId: receipt.id }; + }); +} From 8d05657de2a884d1f00f6bfe088ba0f0e8ec11de Mon Sep 17 00:00:00 2001 From: Blockchain-Oracle Date: Fri, 17 Jul 2026 10:33:16 +0200 Subject: [PATCH 3/6] feat(agent): complete Phase 6 MCP proxy --- apps/agent/package.json | 21 +- apps/agent/src/bootstrap.integration.test.ts | 83 + apps/agent/src/bootstrap.ts | 108 + apps/agent/src/cli-config.test.ts | 43 + apps/agent/src/cli-config.ts | 71 + apps/agent/src/cli.integration.test.ts | 198 ++ apps/agent/src/cli.ts | 35 + apps/agent/src/eip3009-authorization.ts | 140 + apps/agent/src/errors.ts | 8 + apps/agent/src/fetch-wire.ts | 143 + .../src/fetch-wrapper.integration.test.ts | 105 +- apps/agent/src/fetch-wrapper.ts | 16 +- apps/agent/src/fetch.ts | 4 + apps/agent/src/index.ts | 9 +- .../src/paid-fetch-server.integration.test.ts | 141 + apps/agent/src/paid-fetch-server.ts | 105 + apps/agent/src/payment-client.ts | 2 +- .../proxy-unconfigured.integration.test.ts | 83 + apps/agent/src/proxy.integration.test.ts | 52 +- apps/agent/src/proxy.ts | 6 +- apps/agent/src/remote-signer-pending.test.ts | 127 + apps/agent/src/remote-signer.test.ts | 321 +- apps/agent/src/remote-signer.ts | 218 +- apps/agent/src/runtime.ts | 88 + apps/agent/src/upstream.integration.test.ts | 72 + apps/agent/src/upstream.ts | 19 + apps/agent/tsconfig.build.json | 7 + .../reporter.contract.integration.test.ts | 287 ++ .../api/agent/sign/route.integration.test.ts | 27 + apps/web/app/api/agent/sign/route.ts | 6 + apps/web/drizzle/0016_cold_riptide.sql | 41 + apps/web/drizzle/meta/0016_snapshot.json | 2830 +++++++++++++++++ apps/web/drizzle/meta/_journal.json | 7 + apps/web/lib/db/leash-control-schema.ts | 137 + apps/web/lib/db/leash-ledger-schema.ts | 77 + ...eash-migration-upgrade.integration.test.ts | 214 ++ apps/web/lib/db/leash-receipt-schema.ts | 119 + .../lib/db/leash-schema.integration.test.ts | 25 +- apps/web/lib/db/leash-schema.ts | 301 +- .../settlement-evidence.integration.test.ts | 26 + apps/web/lib/leash/settlement-evidence.ts | 4 +- apps/web/lib/leash/sign-gate.ts | 32 + .../sign-preflight-store.integration.test.ts | 182 ++ apps/web/lib/leash/sign-preflight-store.ts | 143 + apps/web/lib/leash/sign-request.test.ts | 49 + apps/web/lib/leash/sign-request.ts | 60 +- .../lib/leash/sign-store.integration.test.ts | 67 + apps/web/lib/leash/sign-store.ts | 120 +- apps/web/package.json | 1 + pnpm-lock.yaml | 3 + turbo.json | 1 + 51 files changed, 6431 insertions(+), 553 deletions(-) create mode 100644 apps/agent/src/bootstrap.integration.test.ts create mode 100644 apps/agent/src/bootstrap.ts create mode 100644 apps/agent/src/cli-config.test.ts create mode 100644 apps/agent/src/cli-config.ts create mode 100644 apps/agent/src/cli.integration.test.ts create mode 100644 apps/agent/src/cli.ts create mode 100644 apps/agent/src/eip3009-authorization.ts create mode 100644 apps/agent/src/errors.ts create mode 100644 apps/agent/src/fetch-wire.ts create mode 100644 apps/agent/src/fetch.ts create mode 100644 apps/agent/src/paid-fetch-server.integration.test.ts create mode 100644 apps/agent/src/paid-fetch-server.ts create mode 100644 apps/agent/src/proxy-unconfigured.integration.test.ts create mode 100644 apps/agent/src/remote-signer-pending.test.ts create mode 100644 apps/agent/src/runtime.ts create mode 100644 apps/agent/src/upstream.integration.test.ts create mode 100644 apps/agent/src/upstream.ts create mode 100644 apps/agent/tsconfig.build.json create mode 100644 apps/web/app/api/agent/pay/result/reporter.contract.integration.test.ts create mode 100644 apps/web/drizzle/0016_cold_riptide.sql create mode 100644 apps/web/drizzle/meta/0016_snapshot.json create mode 100644 apps/web/lib/db/leash-control-schema.ts create mode 100644 apps/web/lib/db/leash-ledger-schema.ts create mode 100644 apps/web/lib/db/leash-migration-upgrade.integration.test.ts create mode 100644 apps/web/lib/db/leash-receipt-schema.ts create mode 100644 apps/web/lib/leash/sign-gate.ts create mode 100644 apps/web/lib/leash/sign-preflight-store.integration.test.ts create mode 100644 apps/web/lib/leash/sign-preflight-store.ts diff --git a/apps/agent/package.json b/apps/agent/package.json index c03ddba..147888f 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -4,9 +4,26 @@ "private": true, "type": "module", "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "leash-mcp": "./dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./fetch": { + "types": "./dist/fetch.d.ts", + "import": "./dist/fetch.js" + } + }, + "files": [ + "dist" + ], "scripts": { - "build": "tsc --project tsconfig.json", - "dev": "tsc --watch --preserveWatchOutput --project tsconfig.json", + "build": "rm -rf dist && tsc --build tsconfig.build.json --force", + "dev": "tsc --watch --preserveWatchOutput --project tsconfig.build.json", "typecheck": "tsc --noEmit --project tsconfig.json", "test": "vitest run --passWithNoTests", "lint": "biome check ." diff --git a/apps/agent/src/bootstrap.integration.test.ts b/apps/agent/src/bootstrap.integration.test.ts new file mode 100644 index 0000000..809b75f --- /dev/null +++ b/apps/agent/src/bootstrap.integration.test.ts @@ -0,0 +1,83 @@ +import { createServer } from "node:http"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { connectLeashAgent, LeashConnectError } from "./bootstrap.js"; + +const apiKey = `leash_sk_${"b".repeat(43)}`; + +describe("Leash control-plane bootstrap over HTTP", () => { + const requests: Array<{ authorization: string | undefined; body: unknown; method: string }> = []; + let responseBody: unknown = { agent: { address: null }, client: { name: "Unknown client" } }; + let status = 200; + let origin = ""; + const server = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + requests.push({ + authorization: request.headers.authorization, + body: JSON.parse(Buffer.concat(chunks).toString("utf8")), + method: request.method ?? "", + }); + response.statusCode = status; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify(responseBody)); + }); + + beforeAll(async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP listener"); + origin = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); + + it("authenticates and omits unknown clientInfo from the initial connect", async () => { + requests.length = 0; + status = 200; + responseBody = { agent: { address: null }, client: { name: "Unknown client" } }; + + await expect( + connectLeashAgent({ apiBaseUrl: origin, apiKey, fetch: globalThis.fetch }), + ).resolves.toEqual({ address: null }); + expect(requests).toEqual([ + { + authorization: `Bearer ${apiKey}`, + body: { transport: "mcp" }, + method: "POST", + }, + ]); + }); + + it("accepts only a null or EVM address from the control plane", async () => { + const address = "0x2222222222222222222222222222222222222222"; + responseBody = { agent: { address } }; + await expect( + connectLeashAgent({ apiBaseUrl: origin, apiKey, fetch: globalThis.fetch }), + ).resolves.toEqual({ address }); + + responseBody = { agent: { address: "not-an-address" } }; + await expect( + connectLeashAgent({ apiBaseUrl: origin, apiKey, fetch: globalThis.fetch }), + ).rejects.toThrow(LeashConnectError); + }); + + it("fails closed on a rejected or malformed response", async () => { + status = 401; + responseBody = { error: { code: "UNAUTHORIZED", message: "Authentication is required." } }; + await expect( + connectLeashAgent({ apiBaseUrl: origin, apiKey, fetch: globalThis.fetch }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED", status: 401 }); + + status = 200; + responseBody = { agent: {} }; + await expect( + connectLeashAgent({ apiBaseUrl: origin, apiKey, fetch: globalThis.fetch }), + ).rejects.toMatchObject({ code: "INVALID_CONNECT_RESPONSE", status: 502 }); + }); +}); diff --git a/apps/agent/src/bootstrap.ts b/apps/agent/src/bootstrap.ts new file mode 100644 index 0000000..56bebb4 --- /dev/null +++ b/apps/agent/src/bootstrap.ts @@ -0,0 +1,108 @@ +import { isAddress } from "viem"; + +const MAX_CONNECT_RESPONSE_BYTES = 65_536; + +interface ConnectLeashAgentOptions { + apiBaseUrl: string; + apiKey: string; + fetch?: typeof globalThis.fetch; + signal?: AbortSignal; +} + +export interface ConnectedLeashAgent { + address: `0x${string}` | null; +} + +export class LeashConnectError extends Error { + constructor( + readonly code: string, + message: string, + readonly status: number, + ) { + super(message); + this.name = "LeashConnectError"; + } +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorCode(value: unknown) { + if (!record(value) || !record(value.error) || typeof value.error.code !== "string") { + return "CONNECT_FAILED"; + } + return /^[A-Z0-9_]{1,64}$/.test(value.error.code) ? value.error.code : "CONNECT_FAILED"; +} + +async function responseJson(response: Response) { + const text = await response.text(); + if (new TextEncoder().encode(text).byteLength > MAX_CONNECT_RESPONSE_BYTES) { + throw new LeashConnectError( + "INVALID_CONNECT_RESPONSE", + "The Leash control plane returned an invalid response.", + 502, + ); + } + try { + return JSON.parse(text) as unknown; + } catch { + throw new LeashConnectError( + "INVALID_CONNECT_RESPONSE", + "The Leash control plane returned an invalid response.", + 502, + ); + } +} + +export async function connectLeashAgent( + options: ConnectLeashAgentOptions, +): Promise { + const fetch_ = options.fetch ?? globalThis.fetch; + const timeout = AbortSignal.timeout(10_000); + const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + let response: Response; + try { + response = await fetch_(new URL("/api/agent/connect", options.apiBaseUrl), { + body: JSON.stringify({ transport: "mcp" }), + headers: { + accept: "application/json", + authorization: `Bearer ${options.apiKey}`, + "content-type": "application/json", + }, + method: "POST", + signal, + }); + } catch { + throw new LeashConnectError( + "CONNECT_FAILED", + "The Leash control plane could not be reached.", + 503, + ); + } + + const body = await responseJson(response); + if (!response.ok) { + throw new LeashConnectError( + errorCode(body), + "The Leash control plane rejected the connection.", + response.status, + ); + } + if (!record(body) || !record(body.agent) || !("address" in body.agent)) { + throw new LeashConnectError( + "INVALID_CONNECT_RESPONSE", + "The Leash control plane returned an invalid response.", + 502, + ); + } + const address = body.agent.address; + if (address !== null && (typeof address !== "string" || !isAddress(address))) { + throw new LeashConnectError( + "INVALID_CONNECT_RESPONSE", + "The Leash control plane returned an invalid response.", + 502, + ); + } + return { address } as ConnectedLeashAgent; +} diff --git a/apps/agent/src/cli-config.test.ts b/apps/agent/src/cli-config.test.ts new file mode 100644 index 0000000..5ca011e --- /dev/null +++ b/apps/agent/src/cli-config.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { CliConfigurationError, parseLeashCliConfig } from "./cli-config.js"; + +const validEnvironment = { + LEASH_API_BASE_URL: "https://tab.example.test", + LEASH_API_KEY: `leash_sk_${"a".repeat(43)}`, +}; + +describe("Leash MCP CLI configuration", () => { + it("parses the required environment and one absolute HTTP upstream", () => { + expect( + parseLeashCliConfig(["--upstream", "https://mcp.example.test/rpc"], validEnvironment), + ).toEqual({ + apiBaseUrl: "https://tab.example.test/", + apiKey: validEnvironment.LEASH_API_KEY, + upstreamUrl: "https://mcp.example.test/rpc", + }); + }); + + it("supports the standalone paid_fetch server", () => { + expect(parseLeashCliConfig([], validEnvironment)).toEqual({ + apiBaseUrl: "https://tab.example.test/", + apiKey: validEnvironment.LEASH_API_KEY, + upstreamUrl: null, + }); + }); + + it.each([ + [{}, []], + [{ ...validEnvironment, LEASH_API_KEY: "secret" }, []], + [{ ...validEnvironment, LEASH_API_BASE_URL: "tab.example.test" }, []], + [{ ...validEnvironment, LEASH_API_BASE_URL: "ftp://tab.example.test" }, []], + [{ ...validEnvironment, LEASH_API_BASE_URL: "https://tab.example.test/api" }, []], + [validEnvironment, ["--upstream"]], + [validEnvironment, ["--upstream", "/mcp"]], + [validEnvironment, ["--upstream", "file:///tmp/mcp"]], + [validEnvironment, ["--unknown"]], + [validEnvironment, ["--upstream", "https://one.test", "extra"]], + ])("rejects malformed or ambiguous input", (environment, arguments_) => { + expect(() => parseLeashCliConfig(arguments_, environment)).toThrow(CliConfigurationError); + }); +}); diff --git a/apps/agent/src/cli-config.ts b/apps/agent/src/cli-config.ts new file mode 100644 index 0000000..7dfbb1d --- /dev/null +++ b/apps/agent/src/cli-config.ts @@ -0,0 +1,71 @@ +const LEASH_KEY_PATTERN = /^leash_sk_[A-Za-z0-9_-]{43}$/; + +export interface LeashCliConfig { + apiBaseUrl: string; + apiKey: string; + upstreamUrl: string | null; +} + +export class CliConfigurationError extends Error { + readonly code = "INVALID_CONFIGURATION"; + + constructor(message: string) { + super(message); + this.name = "CliConfigurationError"; + } +} + +function httpUrl(value: string, field: string) { + let url: URL; + try { + url = new URL(value); + } catch { + throw new CliConfigurationError(`${field} must be an absolute HTTP URL.`); + } + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username.length > 0 || + url.password.length > 0 || + url.hash.length > 0 + ) { + throw new CliConfigurationError(`${field} must be an absolute HTTP URL.`); + } + return url; +} + +function apiBaseUrl(value: string | undefined) { + if (!value || value.trim() !== value) { + throw new CliConfigurationError("LEASH_API_BASE_URL is required."); + } + const url = httpUrl(value, "LEASH_API_BASE_URL"); + if (url.pathname !== "/" || url.search.length > 0) { + throw new CliConfigurationError("LEASH_API_BASE_URL must be an origin without a path."); + } + return url.toString(); +} + +function apiKey(value: string | undefined) { + if (!value || !LEASH_KEY_PATTERN.test(value)) { + throw new CliConfigurationError("LEASH_API_KEY is missing or malformed."); + } + return value; +} + +function upstreamUrl(arguments_: readonly string[]) { + if (arguments_.length === 0) return null; + if (arguments_.length !== 2 || arguments_[0] !== "--upstream" || !arguments_[1]) { + throw new CliConfigurationError("Usage: leash-mcp [--upstream ]"); + } + return httpUrl(arguments_[1], "--upstream").toString(); +} + +export function parseLeashCliConfig( + arguments_: readonly string[], + environment: Readonly>, +): LeashCliConfig { + return { + apiBaseUrl: apiBaseUrl(environment.LEASH_API_BASE_URL), + apiKey: apiKey(environment.LEASH_API_KEY), + upstreamUrl: upstreamUrl(arguments_), + }; +} diff --git a/apps/agent/src/cli.integration.test.ts b/apps/agent/src/cli.integration.test.ts new file mode 100644 index 0000000..f61cee7 --- /dev/null +++ b/apps/agent/src/cli.integration.test.ts @@ -0,0 +1,198 @@ +import { randomUUID } from "node:crypto"; +import { createServer } from "node:http"; +import path from "node:path"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const paymentRequired = { + accepts: [ + { + amount: "25000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + extra: { name: "USD Coin", version: "2" }, + maxTimeoutSeconds: 60, + network: "eip155:8453", + payTo: "0x1111111111111111111111111111111111111111", + scheme: "exact", + }, + ], + resource: { url: "mcp://tool/paid" }, + x402Version: 2, +}; + +function definedEnvironment(values: NodeJS.ProcessEnv) { + return Object.fromEntries( + Object.entries(values).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); +} + +function textResult(result: unknown) { + if ( + typeof result !== "object" || + result === null || + !("content" in result) || + !Array.isArray(result.content) + ) { + throw new Error("Expected tool content"); + } + const content = result.content[0]; + if (content?.type !== "text") throw new Error("Expected text tool content"); + return JSON.parse(content.text) as Record; +} + +function cliProcessArguments(arguments_: string[]) { + const builtEntry = process.env.LEASH_CLI_ENTRY; + return builtEntry + ? { args: [path.resolve(builtEntry), ...arguments_], command: process.execPath } + : { + args: [path.resolve("src/cli.ts"), ...arguments_], + command: path.resolve("node_modules/.bin/tsx"), + }; +} + +describe("leash-mcp stdio CLI", () => { + const apiKey = `leash_sk_${"d".repeat(43)}`; + const connectBodies: unknown[] = []; + const upstreamCalls: string[] = []; + const upstream = new Server( + { name: "stdio-upstream", version: "1.0.0" }, + { capabilities: { tools: {} } }, + ); + const upstreamTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: randomUUID }); + let origin = ""; + const loopback = createServer(async (request, response) => { + if (request.url === "/api/agent/connect") { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + connectBodies.push(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + expect(request.headers.authorization).toBe(`Bearer ${apiKey}`); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ agent: { address: null } })); + return; + } + if (request.url === "/mcp") { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const rawBody = Buffer.concat(chunks).toString("utf8"); + await upstreamTransport.handleRequest( + request, + response, + rawBody ? JSON.parse(rawBody) : undefined, + ); + return; + } + if (request.url === "/free") { + response.end("stdio result"); + return; + } + response.statusCode = 404; + response.end(); + }); + + beforeAll(async () => { + upstream.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [ + { inputSchema: { type: "object" }, name: "free" }, + { inputSchema: { type: "object" }, name: "paid" }, + ], + })); + upstream.setRequestHandler(CallToolRequestSchema, (request) => { + upstreamCalls.push(request.params.name); + if (request.params.name === "paid") { + return { + content: [{ text: JSON.stringify(paymentRequired), type: "text" }], + isError: true, + structuredContent: paymentRequired, + }; + } + return { content: [{ text: "proxied free result", type: "text" }] }; + }); + await upstream.connect(upstreamTransport as Transport); + await new Promise((resolve) => loopback.listen(0, "127.0.0.1", resolve)); + const address = loopback.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP listener"); + origin = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await upstream.close(); + await new Promise((resolve, reject) => + loopback.close((error) => (error ? reject(error) : resolve())), + ); + }); + + it("boots the actual TypeScript bin over stdio without corrupting protocol stdout", async () => { + connectBodies.length = 0; + const cli = cliProcessArguments([]); + const transport = new StdioClientTransport({ + args: cli.args, + command: cli.command, + cwd: process.cwd(), + env: definedEnvironment({ + ...process.env, + LEASH_API_BASE_URL: origin, + LEASH_API_KEY: apiKey, + }), + stderr: "pipe", + }); + const client = new Client({ name: "stdio-integration", version: "1.0.0" }); + const stderr: Buffer[] = []; + transport.stderr?.on("data", (chunk) => stderr.push(Buffer.from(chunk))); + try { + await client.connect(transport); + await expect(client.listTools()).resolves.toMatchObject({ + tools: [{ name: "paid_fetch" }], + }); + const result = await client.callTool({ + arguments: { url: `${origin}/free` }, + name: "paid_fetch", + }); + expect(textResult(result)).toMatchObject({ body: "stdio result", status: 200 }); + expect(connectBodies).toEqual([{ transport: "mcp" }]); + expect(Buffer.concat(stderr).toString("utf8")).toBe(""); + } finally { + await client.close(); + } + }); + + it("wires stdio through a real Streamable HTTP upstream and keeps free tools usable", async () => { + connectBodies.length = 0; + upstreamCalls.length = 0; + const cli = cliProcessArguments(["--upstream", `${origin}/mcp`]); + const transport = new StdioClientTransport({ + args: cli.args, + command: cli.command, + cwd: process.cwd(), + env: definedEnvironment({ + ...process.env, + LEASH_API_BASE_URL: origin, + LEASH_API_KEY: apiKey, + }), + stderr: "pipe", + }); + const client = new Client({ name: "proxy-integration", version: "1.0.0" }); + const stderr: Buffer[] = []; + transport.stderr?.on("data", (chunk) => stderr.push(Buffer.from(chunk))); + try { + await client.connect(transport); + await expect(client.listTools()).resolves.toMatchObject({ + tools: [{ name: "free" }, { name: "paid" }], + }); + await expect(client.callTool({ name: "free" })).resolves.toMatchObject({ + content: [{ text: "proxied free result", type: "text" }], + }); + await expect(client.callTool({ name: "paid" })).rejects.toThrow("SIGNER_NOT_CONFIGURED"); + expect(upstreamCalls).toEqual(["free", "paid"]); + expect(connectBodies).toEqual([{ transport: "mcp" }]); + expect(Buffer.concat(stderr).toString("utf8")).toBe(""); + } finally { + await client.close(); + } + }); +}); diff --git a/apps/agent/src/cli.ts b/apps/agent/src/cli.ts new file mode 100644 index 0000000..f63656a --- /dev/null +++ b/apps/agent/src/cli.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +import { LeashConnectError } from "./bootstrap.js"; +import { CliConfigurationError, parseLeashCliConfig } from "./cli-config.js"; +import { startLeashMcp } from "./runtime.js"; + +function startupMessage(error: unknown) { + if (error instanceof CliConfigurationError || error instanceof LeashConnectError) { + return error.message; + } + return "Leash MCP could not start."; +} + +async function main() { + const config = parseLeashCliConfig(process.argv.slice(2), process.env); + const runtime = await startLeashMcp({ config }); + let closing = false; + const close = () => { + if (closing) return; + closing = true; + void runtime.close().catch(() => { + process.exitCode = 1; + }); + }; + process.stdin.once("end", close); + process.once("SIGINT", close); + process.once("SIGTERM", close); +} + +try { + await main(); +} catch (error) { + process.stderr.write(`leash-mcp: ${startupMessage(error)}\n`); + process.exitCode = 1; +} diff --git a/apps/agent/src/eip3009-authorization.ts b/apps/agent/src/eip3009-authorization.ts new file mode 100644 index 0000000..cea2282 --- /dev/null +++ b/apps/agent/src/eip3009-authorization.ts @@ -0,0 +1,140 @@ +import { getAddress, type TypedData } from "viem"; + +import { ARBITRUM_NETWORK, ARBITRUM_USDC, BASE_NETWORK, BASE_USDC } from "./routing.js"; + +const MAX_AUTHORIZATION_LIFETIME_SECONDS = 600; +const AUTHORIZATION_TYPES = [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, +] as const; + +export interface SignerRequest { + domain: Record; + message: Record; + primaryType: string; + types: Record; +} + +export class InvalidEip3009AuthorizationError extends Error { + constructor() { + super("The EIP-3009 authorization is invalid."); + this.name = "InvalidEip3009AuthorizationError"; + } +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exactRecord(value: unknown, sortedKeys: string[]) { + if (!record(value)) throw new InvalidEip3009AuthorizationError(); + const keys = Object.keys(value).sort(); + if (keys.length !== sortedKeys.length || keys.some((key, index) => key !== sortedKeys[index])) { + throw new InvalidEip3009AuthorizationError(); + } + return value; +} + +function unsigned(value: unknown) { + if (typeof value === "bigint" && value >= 0n) return value.toString(); + if (typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value)) return value; + throw new InvalidEip3009AuthorizationError(); +} + +function address(value: unknown) { + if (typeof value !== "string") throw new InvalidEip3009AuthorizationError(); + try { + return getAddress(value); + } catch { + throw new InvalidEip3009AuthorizationError(); + } +} + +function network(chainIdValue: unknown) { + const chainId = + typeof chainIdValue === "number" && Number.isSafeInteger(chainIdValue) + ? String(chainIdValue) + : unsigned(chainIdValue); + if (chainId === "8453") { + return { asset: getAddress(BASE_USDC), chainId: 8453, network: BASE_NETWORK }; + } + if (chainId === "42161") { + return { asset: getAddress(ARBITRUM_USDC), chainId: 42161, network: ARBITRUM_NETWORK }; + } + throw new InvalidEip3009AuthorizationError(); +} + +export function parseExactEip3009Authorization( + value: SignerRequest, + options: { address: `0x${string}`; nowSeconds: number }, +) { + const request = exactRecord(value, ["domain", "message", "primaryType", "types"]); + if (request.primaryType !== "TransferWithAuthorization") { + throw new InvalidEip3009AuthorizationError(); + } + const domain = exactRecord(request.domain, ["chainId", "name", "verifyingContract", "version"]); + const selectedNetwork = network(domain.chainId); + const asset = address(domain.verifyingContract); + if (domain.name !== "USD Coin" || domain.version !== "2" || asset !== selectedNetwork.asset) { + throw new InvalidEip3009AuthorizationError(); + } + const types = exactRecord(request.types, ["TransferWithAuthorization"]); + if (JSON.stringify(types.TransferWithAuthorization) !== JSON.stringify(AUTHORIZATION_TYPES)) { + throw new InvalidEip3009AuthorizationError(); + } + const message = exactRecord(request.message, [ + "from", + "nonce", + "to", + "validAfter", + "validBefore", + "value", + ]); + const from = address(message.from); + const payTo = address(message.to); + const amount = unsigned(message.value); + const validAfter = unsigned(message.validAfter); + const validBefore = unsigned(message.validBefore); + const nonce = message.nonce; + if ( + from !== getAddress(options.address) || + amount === "0" || + validAfter !== "0" || + typeof nonce !== "string" || + !/^0x[0-9a-fA-F]{64}$/.test(nonce) || + BigInt(validBefore) <= BigInt(options.nowSeconds) || + BigInt(validBefore) > BigInt(options.nowSeconds + MAX_AUTHORIZATION_LIFETIME_SECONDS) + ) { + throw new InvalidEip3009AuthorizationError(); + } + const authorizationNonce = nonce as `0x${string}`; + + return { + amount, + asset, + network: selectedNetwork.network, + payTo, + typedData: { + domain: { + chainId: selectedNetwork.chainId, + name: "USD Coin", + verifyingContract: asset, + version: "2", + }, + message: { + from, + nonce: authorizationNonce, + to: payTo, + validAfter: BigInt(validAfter), + validBefore: BigInt(validBefore), + value: BigInt(amount), + }, + primaryType: "TransferWithAuthorization" as const, + types: { TransferWithAuthorization: AUTHORIZATION_TYPES } satisfies TypedData, + }, + }; +} diff --git a/apps/agent/src/errors.ts b/apps/agent/src/errors.ts new file mode 100644 index 0000000..dddec72 --- /dev/null +++ b/apps/agent/src/errors.ts @@ -0,0 +1,8 @@ +export class SignerNotConfiguredError extends Error { + readonly code = "SIGNER_NOT_CONFIGURED"; + + constructor() { + super("SIGNER_NOT_CONFIGURED: Leash signing is not configured for this agent."); + this.name = "SignerNotConfiguredError"; + } +} diff --git a/apps/agent/src/fetch-wire.ts b/apps/agent/src/fetch-wire.ts new file mode 100644 index 0000000..ecd16c2 --- /dev/null +++ b/apps/agent/src/fetch-wire.ts @@ -0,0 +1,143 @@ +const MAX_BODY_BYTES = 1_048_576; +const MAX_HEADERS = 32; +const MAX_RESPONSE_BYTES = 262_144; +const MAX_URL_LENGTH = 2_048; +const METHODS = new Set(["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"]); +const FORBIDDEN_HEADERS = new Set(["connection", "content-length", "host", "transfer-encoding"]); + +export const PAID_FETCH_INPUT_SCHEMA = { + additionalProperties: false, + properties: { + body: { maxLength: MAX_BODY_BYTES, type: "string" as const }, + headers: { + additionalProperties: { type: "string" as const }, + maxProperties: MAX_HEADERS, + type: "object" as const, + }, + method: { enum: [...METHODS], type: "string" as const }, + url: { maxLength: MAX_URL_LENGTH, type: "string" as const }, + }, + required: ["url"], + type: "object" as const, +}; + +export interface ParsedFetchRequest { + body?: string; + headers?: Record; + method: string; + url: string; +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseHeaders(value: unknown) { + if (value === undefined) return undefined; + if (!record(value) || Object.keys(value).length > MAX_HEADERS) throw new Error("headers"); + const headers: Record = {}; + let totalBytes = 0; + for (const [name, headerValue] of Object.entries(value)) { + const normalized = name.toLowerCase(); + if ( + !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name) || + FORBIDDEN_HEADERS.has(normalized) || + typeof headerValue !== "string" || + /[\r\n]/.test(headerValue) + ) { + throw new Error("headers"); + } + totalBytes += Buffer.byteLength(name) + Buffer.byteLength(headerValue); + if (totalBytes > 32_768) throw new Error("headers"); + headers[name] = headerValue; + } + return headers; +} + +export function parsePaidFetchRequest(value: unknown): ParsedFetchRequest { + if (!record(value)) throw new Error("request"); + const keys = Object.keys(value); + if (keys.some((key) => !["body", "headers", "method", "url"].includes(key))) { + throw new Error("request"); + } + if ( + typeof value.url !== "string" || + value.url.length === 0 || + value.url.length > MAX_URL_LENGTH + ) { + throw new Error("url"); + } + let url: URL; + try { + url = new URL(value.url); + } catch { + throw new Error("url"); + } + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username.length > 0 || + url.password.length > 0 + ) { + throw new Error("url"); + } + const method = value.method === undefined ? "GET" : value.method; + if (typeof method !== "string" || method !== method.toUpperCase() || !METHODS.has(method)) { + throw new Error("method"); + } + if ( + value.body !== undefined && + (typeof value.body !== "string" || + Buffer.byteLength(value.body) > MAX_BODY_BYTES || + method === "GET" || + method === "HEAD") + ) { + throw new Error("body"); + } + const headers = parseHeaders(value.headers); + return { + ...(value.body === undefined ? {} : { body: value.body }), + ...(headers === undefined ? {} : { headers }), + method, + url: url.toString(), + }; +} + +export async function readBoundedResponse(response: Response) { + if (!response.body) return { body: "", truncated: false }; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + let truncated = false; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const remaining = MAX_RESPONSE_BYTES - length; + if (value.byteLength > remaining) { + if (remaining > 0) chunks.push(value.subarray(0, remaining)); + length += Math.max(remaining, 0); + truncated = true; + await reader.cancel(); + break; + } + chunks.push(value); + length += value.byteLength; + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { body: new TextDecoder().decode(bytes), truncated }; +} + +export function readResponseHeaders(response: Response) { + const headers: Record = {}; + let count = 0; + for (const [name, value] of response.headers) { + if (count >= 64) break; + headers[name] = value.slice(0, 4_096); + count += 1; + } + return headers; +} diff --git a/apps/agent/src/fetch-wrapper.integration.test.ts b/apps/agent/src/fetch-wrapper.integration.test.ts index 14b9553..b87501d 100644 --- a/apps/agent/src/fetch-wrapper.integration.test.ts +++ b/apps/agent/src/fetch-wrapper.integration.test.ts @@ -6,12 +6,13 @@ import { encodePaymentResponseHeader, } from "@x402/core/http"; import type { PaymentRequired } from "@x402/core/types"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { privateKeyToAccount } from "viem/accounts"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { createLeashFetch } from "./fetch-wrapper.js"; -const payer = "0x2222222222222222222222222222222222222222" as const; -const signature = `0x${"ab".repeat(65)}` as const; +const payerAccount = privateKeyToAccount(`0x${"11".repeat(32)}`); +const payer = payerAccount.address; const transaction = `0x${"cd".repeat(32)}`; const paymentRequired = { accepts: [ @@ -35,25 +36,38 @@ async function jsonRequest(request: import("node:http").IncomingMessage) { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } +async function textRequest(request: import("node:http").IncomingMessage) { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + describe("Leash fetch wrapper with real x402 and HTTP wires", () => { const signRequests: unknown[] = []; + const signatures: `0x${string}`[] = []; const resultRequests: unknown[] = []; + const protectedRequestBodies: string[] = []; let origin = ""; const server = createServer(async (request, response) => { - if (request.url === "/api/agent/sign") { + const pathname = new URL(request.url ?? "/", "http://loopback").pathname; + if (pathname === "/api/agent/sign") { expect(request.headers.authorization).toBe("Bearer leash_sk_integration"); - signRequests.push(await jsonRequest(request)); + const body = await jsonRequest(request); + signRequests.push(body); + const signature = await payerAccount.signTypedData(body.signerRequest); + signatures.push(signature); response.setHeader("content-type", "application/json"); response.end(JSON.stringify({ receiptId: "receipt-1", signature })); return; } - if (request.url === "/api/agent/pay/result") { + if (pathname === "/api/agent/pay/result") { resultRequests.push(await jsonRequest(request)); response.statusCode = 204; response.end(); return; } - if (request.url === "/protected") { + if (pathname === "/protected") { + protectedRequestBodies.push(await textRequest(request)); const paymentHeader = request.headers["payment-signature"]; if (typeof paymentHeader !== "string") { response.statusCode = 402; @@ -64,7 +78,7 @@ describe("Leash fetch wrapper with real x402 and HTTP wires", () => { const payload = decodePaymentSignatureHeader(paymentHeader); expect(payload).toMatchObject({ accepted: { network: "eip155:8453" }, - payload: { signature }, + payload: { signature: signatures.at(-1) }, }); response.setHeader( "PAYMENT-RESPONSE", @@ -96,7 +110,14 @@ describe("Leash fetch wrapper with real x402 and HTTP wires", () => { ); }); - it("pays, retries, and reports the settlement using shipped x402 packages", async () => { + beforeEach(() => { + signRequests.length = 0; + signatures.length = 0; + resultRequests.length = 0; + protectedRequestBodies.length = 0; + }); + + it("pays, retries, reports settlement, and redacts receipt-origin secrets", async () => { const leashFetch = createLeashFetch({ address: payer, apiBaseUrl: origin, @@ -104,7 +125,7 @@ describe("Leash fetch wrapper with real x402 and HTTP wires", () => { fetch: globalThis.fetch, }); - const response = await leashFetch(`${origin}/protected`); + const response = await leashFetch(`${origin}/protected?api_key=receipt-secret#client-fragment`); expect(response.status).toBe(200); await expect(response.text()).resolves.toBe("protected result"); @@ -112,19 +133,59 @@ describe("Leash fetch wrapper with real x402 and HTTP wires", () => { expect(signRequests[0]).toMatchObject({ amount: "25000", network: "eip155:8453", - origin: { clientName: "leash-fetch", transport: "http" }, + origin: { + clientName: "leash-fetch", + toolName: `GET ${origin}/protected`, + transport: "http", + }, }); - expect(resultRequests).toEqual([ - { - outcome: "settled", - paymentResponse: { - network: "eip155:8453", - payer, - success: true, - transaction, + expect(JSON.stringify(signRequests[0])).not.toMatch(/receipt-secret|client-fragment/); + await expect + .poll(() => resultRequests, { timeout: 1_000 }) + .toEqual([ + { + outcome: "observed", + paymentResponse: { + network: "eip155:8453", + payer, + success: true, + transaction, + }, + receiptId: "receipt-1", }, - receiptId: "receipt-1", - }, - ]); + ]); + }); + + it("preserves a POST Request body across metadata extraction and the paid retry", async () => { + const body = JSON.stringify({ prompt: "charge this exact request" }); + const request = new Request(`${origin}/protected`, { + body, + headers: { "content-type": "application/json" }, + method: "POST", + }); + const fetchBodyStates: boolean[] = []; + const observingFetch: typeof globalThis.fetch = async (input, init) => { + if (input instanceof Request && input.url === `${origin}/protected`) { + fetchBodyStates.push(input.bodyUsed); + } + return globalThis.fetch(input, init); + }; + const leashFetch = createLeashFetch({ + address: payer, + apiBaseUrl: origin, + apiKey: "leash_sk_integration", + fetch: observingFetch, + }); + + expect(request.bodyUsed).toBe(false); + const response = await leashFetch(request); + + expect(response.status).toBe(200); + expect(fetchBodyStates).toEqual([false, false]); + expect(protectedRequestBodies).toEqual([body, body]); + expect(signRequests).toHaveLength(1); + expect(signRequests[0]).toMatchObject({ + origin: { toolName: `POST ${origin}/protected` }, + }); }); }); diff --git a/apps/agent/src/fetch-wrapper.ts b/apps/agent/src/fetch-wrapper.ts index 879816d..c54efd8 100644 --- a/apps/agent/src/fetch-wrapper.ts +++ b/apps/agent/src/fetch-wrapper.ts @@ -15,8 +15,20 @@ interface LeashFetchOptions { type FetchInput = Request | string | URL; function requestName(input: FetchInput, init?: RequestInit) { - const request = new Request(input, init); - return `${request.method} ${request.url}`; + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + const rawUrl = input instanceof Request ? input.url : input.toString(); + let safeUrl = "Invalid URL"; + try { + const url = new URL(rawUrl); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + safeUrl = url.toString(); + } catch { + // The underlying fetch reports invalid input without persisting the raw value as receipt origin. + } + return `${method.toUpperCase()} ${safeUrl}`; } export function createLeashFetch(options: LeashFetchOptions) { diff --git a/apps/agent/src/fetch.ts b/apps/agent/src/fetch.ts new file mode 100644 index 0000000..bf14d3e --- /dev/null +++ b/apps/agent/src/fetch.ts @@ -0,0 +1,4 @@ +export { createLeashFetch } from "./fetch-wrapper.js"; +export type CreateLeashFetchOptions = Parameters< + typeof import("./fetch-wrapper.js").createLeashFetch +>[0]; diff --git a/apps/agent/src/index.ts b/apps/agent/src/index.ts index 68ed344..f674a56 100644 --- a/apps/agent/src/index.ts +++ b/apps/agent/src/index.ts @@ -1,4 +1,5 @@ -// Phase 1 scaffold — Leash MCP stdio proxy + @x402/fetch wrapper. -// No runtime code in Phase 1; install + typecheck only. -// Real implementation begins in Phase 6. -export {}; +export { createLeashFetch } from "./fetch-wrapper.js"; +export type CreateLeashFetchOptions = Parameters< + typeof import("./fetch-wrapper.js").createLeashFetch +>[0]; +export { LeashRemoteSigner, RemoteSignerError } from "./remote-signer.js"; diff --git a/apps/agent/src/paid-fetch-server.integration.test.ts b/apps/agent/src/paid-fetch-server.integration.test.ts new file mode 100644 index 0000000..730e44a --- /dev/null +++ b/apps/agent/src/paid-fetch-server.integration.test.ts @@ -0,0 +1,141 @@ +import { createServer } from "node:http"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { createPaidFetchServer } from "./paid-fetch-server.js"; + +function toolJson(result: unknown) { + if ( + typeof result !== "object" || + result === null || + !("content" in result) || + !Array.isArray(result.content) + ) { + throw new Error("Expected tool content"); + } + const content = result.content[0]; + if (content?.type !== "text") throw new Error("Expected text tool content"); + return JSON.parse(content.text) as Record; +} + +describe("standalone paid_fetch MCP tool", () => { + const requests: Array<{ body: string; header: string | undefined; method: string }> = []; + let origin = ""; + const httpServer = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + requests.push({ + body: Buffer.concat(chunks).toString("utf8"), + header: request.headers["x-test"] as string | undefined, + method: request.method ?? "", + }); + if (request.url === "/paid") { + response.statusCode = 402; + response.end("payment required"); + return; + } + if (request.url === "/large") { + response.end("x".repeat(300_000)); + return; + } + response.statusCode = 201; + response.setHeader("x-origin", "loopback"); + response.end("free response"); + }); + const server = createPaidFetchServer({ + address: null, + apiBaseUrl: "https://tab.example.test", + apiKey: `leash_sk_${"c".repeat(43)}`, + fetch: globalThis.fetch, + }); + const client = new Client({ name: "integration-host", version: "1.0.0" }); + + beforeAll(async () => { + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const address = httpServer.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP listener"); + origin = `http://127.0.0.1:${address.port}`; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + }); + + afterAll(async () => { + await client.close(); + await server.close(); + await new Promise((resolve, reject) => + httpServer.close((error) => (error ? reject(error) : resolve())), + ); + }); + + it("advertises one strict universal fetch tool", async () => { + await expect(client.listTools()).resolves.toMatchObject({ + tools: [ + { + inputSchema: { + additionalProperties: false, + required: ["url"], + type: "object", + }, + name: "paid_fetch", + }, + ], + }); + }); + + it("forwards a free request and returns a bounded structured response", async () => { + requests.length = 0; + const result = await client.callTool({ + arguments: { + body: "hello", + headers: { "x-test": "forwarded" }, + method: "POST", + url: `${origin}/free`, + }, + name: "paid_fetch", + }); + + expect(result.isError).not.toBe(true); + expect(toolJson(result)).toMatchObject({ + body: "free response", + headers: { "x-origin": "loopback" }, + status: 201, + truncated: false, + }); + expect(requests).toEqual([{ body: "hello", header: "forwarded", method: "POST" }]); + }); + + it("allows free traffic but fails honestly on 402 without a signer address", async () => { + const result = await client.callTool({ + arguments: { url: `${origin}/paid` }, + name: "paid_fetch", + }); + + expect(result.isError).toBe(true); + expect(toolJson(result)).toEqual({ + error: { + code: "SIGNER_NOT_CONFIGURED", + message: "Leash signing is not configured for this agent.", + }, + }); + }); + + it("truncates oversized bodies and rejects non-HTTP or non-schema input", async () => { + const large = toolJson( + await client.callTool({ arguments: { url: `${origin}/large` }, name: "paid_fetch" }), + ); + expect(large.truncated).toBe(true); + expect(String(large.body).length).toBeLessThanOrEqual(262_144); + + for (const arguments_ of [ + { url: "file:///tmp/secret" }, + { extra: true, url: `${origin}/free` }, + { body: "not allowed", method: "GET", url: `${origin}/free` }, + ]) { + const invalid = await client.callTool({ arguments: arguments_, name: "paid_fetch" }); + expect(invalid.isError).toBe(true); + expect(toolJson(invalid)).toMatchObject({ error: { code: "INVALID_FETCH_REQUEST" } }); + } + }); +}); diff --git a/apps/agent/src/paid-fetch-server.ts b/apps/agent/src/paid-fetch-server.ts new file mode 100644 index 0000000..8e8808e --- /dev/null +++ b/apps/agent/src/paid-fetch-server.ts @@ -0,0 +1,105 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; + +import { + PAID_FETCH_INPUT_SCHEMA, + type ParsedFetchRequest, + parsePaidFetchRequest, + readBoundedResponse, + readResponseHeaders, +} from "./fetch-wire.js"; +import { createLeashFetch } from "./fetch-wrapper.js"; + +interface PaidFetchServerOptions { + address: `0x${string}` | null; + apiBaseUrl: string; + apiKey: string; + fetch?: typeof globalThis.fetch; +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function toolResponse(value: unknown, isError = false) { + return { + content: [{ text: JSON.stringify(value), type: "text" as const }], + ...(isError ? { isError: true } : {}), + }; +} + +function invalidRequest() { + return toolResponse( + { error: { code: "INVALID_FETCH_REQUEST", message: "The fetch request is invalid." } }, + true, + ); +} + +export function createPaidFetchServer(options: PaidFetchServerOptions) { + const server = new Server( + { name: "leash-mcp", version: "0.0.1" }, + { capabilities: { tools: {} } }, + ); + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [ + { + description: + "Fetch an HTTP resource and pay a supported x402 challenge within Leash policy.", + inputSchema: PAID_FETCH_INPUT_SCHEMA, + name: "paid_fetch", + }, + ], + })); + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name !== "paid_fetch") return invalidRequest(); + let input: ParsedFetchRequest; + try { + input = parsePaidFetchRequest(request.params.arguments); + } catch { + return invalidRequest(); + } + const baseFetch = options.fetch ?? globalThis.fetch; + const fetch_ = options.address + ? createLeashFetch({ + address: options.address, + apiBaseUrl: options.apiBaseUrl, + apiKey: options.apiKey, + clientName: server.getClientVersion()?.name ?? "Unknown client", + fetch: baseFetch, + }) + : baseFetch; + let response: Response; + try { + response = await fetch_(input.url, { + ...(input.body === undefined ? {} : { body: input.body }), + ...(input.headers === undefined ? {} : { headers: input.headers }), + method: input.method, + }); + } catch (error) { + const remoteCode = record(error) && typeof error.code === "string" ? error.code : ""; + const code = /^[A-Z0-9_]{1,64}$/.test(remoteCode) ? remoteCode : "FETCH_FAILED"; + return toolResponse({ error: { code, message: "The fetch request failed." } }, true); + } + if (!options.address && response.status === 402) { + await response.body?.cancel(); + return toolResponse( + { + error: { + code: "SIGNER_NOT_CONFIGURED", + message: "Leash signing is not configured for this agent.", + }, + }, + true, + ); + } + const content = await readBoundedResponse(response); + return toolResponse({ + ...content, + headers: readResponseHeaders(response), + status: response.status, + statusText: response.statusText, + url: response.url, + }); + }); + return server; +} diff --git a/apps/agent/src/payment-client.ts b/apps/agent/src/payment-client.ts index 9bc6cff..ac63382 100644 --- a/apps/agent/src/payment-client.ts +++ b/apps/agent/src/payment-client.ts @@ -9,5 +9,5 @@ export function createLeashPaymentClient(signer: LeashRemoteSigner) { return new x402Client(selectLeashPaymentRequirements) .register(BASE_NETWORK, scheme) .register(ARBITRUM_NETWORK, scheme) - .onPaymentResponse((context) => signer.reportSettledPayment(context)); + .onPaymentResponse((context) => signer.reportPaymentObservation(context)); } diff --git a/apps/agent/src/proxy-unconfigured.integration.test.ts b/apps/agent/src/proxy-unconfigured.integration.test.ts new file mode 100644 index 0000000..946258d --- /dev/null +++ b/apps/agent/src/proxy-unconfigured.integration.test.ts @@ -0,0 +1,83 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { createLeashProxyServer } from "./proxy.js"; + +const paymentRequired = { + accepts: [ + { + amount: "25000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + extra: { name: "USD Coin", version: "2" }, + maxTimeoutSeconds: 60, + network: "eip155:8453", + payTo: "0x1111111111111111111111111111111111111111", + scheme: "exact", + }, + ], + resource: { url: "mcp://tool/paid" }, + x402Version: 2, +}; + +describe("Leash MCP proxy without a configured signer", () => { + const upstreamServer = new Server( + { name: "free-upstream", version: "1.0.0" }, + { capabilities: { tools: {} } }, + ); + const upstreamClient = new Client({ name: "leash-upstream", version: "0.0.1" }); + const downstream = new Client({ name: "integration-host", version: "1.0.0" }); + const calls: string[] = []; + const proxy = createLeashProxyServer({ upstream: upstreamClient }); + + beforeAll(async () => { + upstreamServer.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [ + { inputSchema: { type: "object" }, name: "free" }, + { inputSchema: { type: "object" }, name: "paid" }, + ], + })); + upstreamServer.setRequestHandler(CallToolRequestSchema, (request) => { + calls.push(request.params.name); + if (request.params.name === "paid") { + return { + content: [{ text: JSON.stringify(paymentRequired), type: "text" }], + isError: true, + structuredContent: paymentRequired, + }; + } + return { content: [{ text: "free result", type: "text" }] }; + }); + const [upstreamClientTransport, upstreamServerTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([ + upstreamServer.connect(upstreamServerTransport), + upstreamClient.connect(upstreamClientTransport), + ]); + const [downstreamTransport, proxyTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([proxy.connect(proxyTransport), downstream.connect(downstreamTransport)]); + }); + + afterAll(async () => { + await downstream.close(); + await proxy.close(); + await upstreamClient.close(); + await upstreamServer.close(); + }); + + it("lists and calls free upstream tools", async () => { + await expect(downstream.listTools()).resolves.toMatchObject({ + tools: [{ name: "free" }, { name: "paid" }], + }); + await expect(downstream.callTool({ name: "free" })).resolves.toMatchObject({ + content: [{ text: "free result", type: "text" }], + }); + }); + + it("reports SIGNER_NOT_CONFIGURED on a challenge without retrying upstream", async () => { + calls.length = 0; + await expect(downstream.callTool({ name: "paid" })).rejects.toThrow("SIGNER_NOT_CONFIGURED"); + expect(calls).toEqual(["paid"]); + }); +}); diff --git a/apps/agent/src/proxy.integration.test.ts b/apps/agent/src/proxy.integration.test.ts index 7276a17..9bf5520 100644 --- a/apps/agent/src/proxy.integration.test.ts +++ b/apps/agent/src/proxy.integration.test.ts @@ -4,14 +4,15 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import type { PaymentRequired } from "@x402/core/types"; import { MCP_PAYMENT_META_KEY, MCP_PAYMENT_RESPONSE_META_KEY } from "@x402/mcp"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { privateKeyToAccount } from "viem/accounts"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createLeashPaymentClient } from "./payment-client.js"; import { createLeashProxyServer } from "./proxy.js"; import { LeashRemoteSigner } from "./remote-signer.js"; -const payer = "0x2222222222222222222222222222222222222222" as const; -const signature = `0x${"ab".repeat(65)}` as const; +const payerAccount = privateKeyToAccount(`0x${"11".repeat(32)}`); +const payer = payerAccount.address; const transaction = `0x${"cd".repeat(32)}`; const paymentRequired = { accepts: [ @@ -39,15 +40,23 @@ describe("Leash MCP proxy with real SDK transports", () => { const signBodies: unknown[] = []; const resultBodies: unknown[] = []; const paidMetadata: unknown[] = []; + let resultStatus = 204; const remoteFetch = vi.fn(async (input: Request | string | URL, init?: RequestInit) => { const path = new URL(input.toString()).pathname; if (path === "/api/agent/sign") { - signBodies.push(JSON.parse(String(init?.body))); - return Response.json({ receiptId: "receipt-mcp", signature }); + const body = JSON.parse(String(init?.body)); + signBodies.push(body); + const signature = await payerAccount.signTypedData(body.signerRequest); + return Response.json({ receiptId: `receipt-mcp-${signBodies.length}`, signature }); } if (path === "/api/agent/pay/result") { resultBodies.push(JSON.parse(String(init?.body))); - return new Response(null, { status: 204 }); + return resultStatus === 204 + ? new Response(null, { status: 204 }) + : Response.json( + { error: { code: "OUTAGE", message: "offline" } }, + { status: resultStatus }, + ); } return Response.json({ error: { code: "NOT_FOUND", message: "Not found." } }, { status: 404 }); }); @@ -56,10 +65,10 @@ describe("Leash MCP proxy with real SDK transports", () => { apiBaseUrl: "https://tab.example.test", apiKey: "leash_sk_integration", fetch: remoteFetch, + reportRetryDelayMs: 1, }); const proxy = createLeashProxyServer({ paymentClient: createLeashPaymentClient(signer), - signer, upstream: upstreamClient, }); @@ -109,6 +118,13 @@ describe("Leash MCP proxy with real SDK transports", () => { await upstreamServer.close(); }); + beforeEach(() => { + paidMetadata.length = 0; + resultBodies.length = 0; + resultStatus = 204; + signBodies.length = 0; + }); + it("forwards tools, pays once, and preserves request/result metadata", async () => { await expect(downstream.listTools()).resolves.toMatchObject({ tools: [{ name: "search" }] }); const result = await downstream.callTool({ @@ -129,15 +145,33 @@ describe("Leash MCP proxy with real SDK transports", () => { }); expect(resultBodies).toEqual([ { - outcome: "settled", + outcome: "observed", paymentResponse: { network: "eip155:8453", payer, success: true, transaction, }, - receiptId: "receipt-mcp", + receiptId: "receipt-mcp-1", }, ]); }); + + it("returns the already-paid tool result once when observation reporting is offline", async () => { + resultStatus = 503; + + const result = await downstream.callTool({ + arguments: { query: "outage" }, + name: "search", + }); + await signer.flushPaymentObservations(); + + expect(result).toMatchObject({ + content: [{ text: "paid result", type: "text" }], + structuredContent: { answer: 42 }, + }); + expect(signBodies).toHaveLength(1); + expect(paidMetadata).toHaveLength(1); + expect(resultBodies).toHaveLength(3); + }); }); diff --git a/apps/agent/src/proxy.ts b/apps/agent/src/proxy.ts index 5e1f0bb..04b65e7 100644 --- a/apps/agent/src/proxy.ts +++ b/apps/agent/src/proxy.ts @@ -6,12 +6,11 @@ import type { SettleResponse } from "@x402/core/types"; import { MCP_PAYMENT_META_KEY, MCP_PAYMENT_RESPONSE_META_KEY } from "@x402/mcp"; import { detectMcpPaymentRequired } from "./detect.js"; +import { SignerNotConfiguredError } from "./errors.js"; import { withPaymentOrigin } from "./origin-context.js"; -import type { LeashRemoteSigner } from "./remote-signer.js"; interface LeashProxyOptions { - paymentClient: x402Client; - signer: LeashRemoteSigner; + paymentClient?: x402Client; upstream: Client; } @@ -66,6 +65,7 @@ export function createLeashProxyServer(options: LeashProxyOptions) { if (!initialResult) throw new Error("Upstream tool returned no result"); return initialResult; } + if (!options.paymentClient) throw new SignerNotConfiguredError(); const paymentPayload = await options.paymentClient.createPaymentPayload(challenge); const paidResult = await options.upstream.callTool( diff --git a/apps/agent/src/remote-signer-pending.test.ts b/apps/agent/src/remote-signer-pending.test.ts new file mode 100644 index 0000000..2bb0fdb --- /dev/null +++ b/apps/agent/src/remote-signer-pending.test.ts @@ -0,0 +1,127 @@ +import type { PaymentResponseContext } from "@x402/core/client"; +import { privateKeyToAccount } from "viem/accounts"; +import { describe, expect, it } from "vitest"; + +import { LeashRemoteSigner } from "./remote-signer.js"; + +const nowSeconds = 1_784_271_300; +const account = privateKeyToAccount(`0x${"33".repeat(32)}`); +const transaction = `0x${"ef".repeat(32)}`; + +function signerRequest() { + return { + domain: { + chainId: 8453, + name: "USD Coin", + verifyingContract: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + version: "2", + }, + message: { + from: account.address, + nonce: `0x${"34".repeat(32)}`, + to: "0x1111111111111111111111111111111111111111", + validAfter: 0n, + validBefore: BigInt(nowSeconds + 60), + value: 25_000n, + }, + primaryType: "TransferWithAuthorization", + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + }; +} + +function paymentContext(signature: `0x${string}`): PaymentResponseContext { + const requirements = { + amount: "25000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + extra: { name: "USD Coin", version: "2" }, + maxTimeoutSeconds: 60, + network: "eip155:8453" as const, + payTo: "0x1111111111111111111111111111111111111111", + scheme: "exact", + }; + return { + paymentPayload: { accepted: requirements, payload: { signature }, x402Version: 2 }, + requirements, + settleResponse: { + network: "eip155:8453", + payer: account.address, + success: true, + transaction, + }, + }; +} + +async function signedReporter(fetch: typeof globalThis.fetch) { + const request = signerRequest(); + const signature = await account.signTypedData( + request as unknown as Parameters[0], + ); + let signRequests = 0; + const signer = new LeashRemoteSigner({ + address: account.address, + apiBaseUrl: "https://tab.example.test/", + apiKey: "leash_sk_secret", + fetch: async (input, init) => { + if (new URL(input.toString()).pathname === "/api/agent/sign") { + signRequests += 1; + return Response.json({ receiptId: "receipt-pending", signature }); + } + return fetch(input, init); + }, + nowSeconds: () => nowSeconds, + reportAttempts: 3, + reportRetryDelayMs: 0, + reportTimeoutMs: 50, + }); + await signer.signTypedData(request); + return { signRequests: () => signRequests, signature, signer }; +} + +describe("Leash pending settlement observation retries", () => { + it("retries a 202 acknowledgement to its bound and retains correlation while pending", async () => { + let attempts = 0; + const { signRequests, signature, signer } = await signedReporter(async () => { + attempts += 1; + return Response.json( + { receiptId: "receipt-pending", status: "pending", verified: false }, + { status: 202 }, + ); + }); + + await signer.reportPaymentObservation(paymentContext(signature)); + await signer.flushPaymentObservations(); + + expect(attempts).toBe(3); + expect(signRequests()).toBe(1); + expect(signer.receiptIdForSignature(signature)).toBe("receipt-pending"); + }); + + it("clears correlation when a later bounded retry receives verified proof", async () => { + let attempts = 0; + const { signRequests, signature, signer } = await signedReporter(async () => { + attempts += 1; + return attempts < 3 + ? Response.json( + { receiptId: "receipt-pending", status: "pending", verified: false }, + { status: 202 }, + ) + : Response.json({ receiptId: "receipt-pending", status: "settled", verified: true }); + }); + + await signer.reportPaymentObservation(paymentContext(signature)); + await signer.flushPaymentObservations(); + + expect(attempts).toBe(3); + expect(signRequests()).toBe(1); + expect(signer.receiptIdForSignature(signature)).toBeNull(); + }); +}); diff --git a/apps/agent/src/remote-signer.test.ts b/apps/agent/src/remote-signer.test.ts index 51e3c39..b374ce9 100644 --- a/apps/agent/src/remote-signer.test.ts +++ b/apps/agent/src/remote-signer.test.ts @@ -1,75 +1,300 @@ +import type { PaymentResponseContext } from "@x402/core/client"; +import { privateKeyToAccount } from "viem/accounts"; import { describe, expect, it, vi } from "vitest"; import { LeashRemoteSigner, type RemoteSignerError } from "./remote-signer.js"; -const signature = `0x${"ab".repeat(65)}` as const; -const signerRequest = { - domain: { - chainId: 8453, - name: "USD Coin", - verifyingContract: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - version: "2", - }, - message: { - from: "0x2222222222222222222222222222222222222222", - nonce: `0x${"12".repeat(32)}`, - to: "0x1111111111111111111111111111111111111111", - validAfter: "0", - validBefore: "9999999999", - value: "25000", - }, - primaryType: "TransferWithAuthorization", - types: { - TransferWithAuthorization: [ - { name: "from", type: "address" }, - { name: "to", type: "address" }, - { name: "value", type: "uint256" }, - ], - }, -}; +const nowSeconds = 1_784_271_300; +const account = privateKeyToAccount(`0x${"11".repeat(32)}`); +const otherAccount = privateKeyToAccount(`0x${"22".repeat(32)}`); +const transaction = `0x${"cd".repeat(32)}`; + +function signWith(signer: typeof account, request: ReturnType) { + return signer.signTypedData(request as unknown as Parameters[0]); +} -describe("Leash remote signer wire", () => { - it("posts exact payment authority and correlates the returned receipt", async () => { +function validSignerRequest() { + return { + domain: { + chainId: 8453, + name: "USD Coin", + verifyingContract: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + version: "2", + }, + message: { + from: account.address, + nonce: `0x${"12".repeat(32)}`, + to: "0x1111111111111111111111111111111111111111", + validAfter: 0n, + validBefore: BigInt(nowSeconds + 60), + value: 25_000n, + }, + primaryType: "TransferWithAuthorization", + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + }; +} + +function paymentContext(signature: `0x${string}`): PaymentResponseContext { + return { + paymentPayload: { + accepted: { + amount: "25000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + extra: { name: "USD Coin", version: "2" }, + maxTimeoutSeconds: 60, + network: "eip155:8453", + payTo: "0x1111111111111111111111111111111111111111", + scheme: "exact", + }, + payload: { signature }, + x402Version: 2, + }, + requirements: { + amount: "25000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + extra: { name: "USD Coin", version: "2" }, + maxTimeoutSeconds: 60, + network: "eip155:8453", + payTo: "0x1111111111111111111111111111111111111111", + scheme: "exact", + }, + settleResponse: { + network: "eip155:8453", + payer: account.address, + success: true, + transaction, + }, + }; +} + +function signerWithFetch(fetch: typeof globalThis.fetch) { + return new LeashRemoteSigner({ + address: account.address, + apiBaseUrl: "https://tab.example.test/", + apiKey: "leash_sk_secret", + fetch, + nowSeconds: () => nowSeconds, + reportRetryDelayMs: 1, + reportTimeoutMs: 10, + }); +} + +describe("Leash remote signer authorization gate", () => { + it("posts only an exact EIP-3009 native-USDC authority and verifies its signature", async () => { + const signerRequest = validSignerRequest(); + const signature = await signWith(account, signerRequest); const fetch = vi.fn(async (_input: Request | string | URL, init?: RequestInit) => { expect(init?.headers).toMatchObject({ authorization: "Bearer leash_sk_secret" }); expect(JSON.parse(String(init?.body))).toEqual({ amount: "25000", asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", network: "eip155:8453", - origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, payTo: "0x1111111111111111111111111111111111111111", - signerRequest, + signerRequest: { + ...signerRequest, + message: { + ...signerRequest.message, + validAfter: "0", + validBefore: String(nowSeconds + 60), + value: "25000", + }, + }, }); return Response.json({ receiptId: "receipt-1", signature }); }); - const signer = new LeashRemoteSigner({ - address: "0x2222222222222222222222222222222222222222", - apiBaseUrl: "https://tab.example.test/", - apiKey: "leash_sk_secret", - fetch, - origin: () => ({ clientName: "Claude Code", toolName: "search", transport: "mcp" }), - }); + const signer = signerWithFetch(fetch); await expect(signer.signTypedData(signerRequest)).resolves.toBe(signature); - expect(signer.takeReceiptId(signature)).toBe("receipt-1"); - expect(signer.takeReceiptId(signature)).toBeNull(); + expect(signer.receiptIdForSignature(signature)).toBe("receipt-1"); + expect(signer.receiptIdForSignature(signature)).toBe("receipt-1"); }); - it("preserves a fail-closed backend error code without inventing a signature", async () => { - const signer = new LeashRemoteSigner({ - address: "0x2222222222222222222222222222222222222222", - apiBaseUrl: "https://tab.example.test", - apiKey: "leash_sk_secret", - fetch: async () => - Response.json( - { error: { code: "SIGNER_NOT_CONFIGURED", message: "Signer is not configured." } }, - { status: 409 }, - ), + it.each([ + [ + "Permit2", + (request: ReturnType) => + (request.primaryType = "PermitTransferFrom"), + ], + [ + "partial EIP-3009 types", + (request: ReturnType) => + request.types.TransferWithAuthorization.pop(), + ], + [ + "another token", + (request: ReturnType) => + (request.domain.verifyingContract = "0x3333333333333333333333333333333333333333"), + ], + [ + "another payer", + (request: ReturnType) => + (request.message.from = otherAccount.address), + ], + [ + "a zero amount", + (request: ReturnType) => (request.message.value = 0n), + ], + [ + "a non-canonical amount", + (request: ReturnType) => + (request.message.value = "025000" as never), + ], + [ + "a short nonce", + (request: ReturnType) => (request.message.nonce = "0x12"), + ], + [ + "a nonzero validAfter", + (request: ReturnType) => (request.message.validAfter = 1n), + ], + [ + "an expired validBefore", + (request: ReturnType) => + (request.message.validBefore = BigInt(nowSeconds)), + ], + [ + "an overlong validBefore", + (request: ReturnType) => + (request.message.validBefore = BigInt(nowSeconds + 601)), + ], + ])("rejects %s before contacting /sign", async (_label, mutate) => { + const request = validSignerRequest(); + mutate(request); + const fetch = vi.fn(async () => Response.json({})); + const signer = signerWithFetch(fetch); + + await expect(signer.signTypedData(request)).rejects.toMatchObject({ + code: "INVALID_SIGNER_REQUEST", }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("rejects a well-shaped signature that does not recover the configured signer", async () => { + const signerRequest = validSignerRequest(); + const forgedSignature = await signWith(otherAccount, signerRequest); + const signer = signerWithFetch(async () => + Response.json({ receiptId: "receipt-forged", signature: forgedSignature }), + ); await expect(signer.signTypedData(signerRequest)).rejects.toMatchObject({ + code: "INVALID_SIGNER_RESPONSE", + status: 502, + } satisfies Partial); + expect(signer.receiptIdForSignature(forgedSignature)).toBeNull(); + }); + + it("preserves a fail-closed backend error code without inventing a signature", async () => { + const signer = signerWithFetch(async () => + Response.json( + { error: { code: "SIGNER_NOT_CONFIGURED", message: "Signer is not configured." } }, + { status: 409 }, + ), + ); + + await expect(signer.signTypedData(validSignerRequest())).rejects.toMatchObject({ code: "SIGNER_NOT_CONFIGURED", status: 409, } satisfies Partial); }); }); + +describe("Leash payment observation reporting", () => { + it("treats forged-but-shaped resource metadata as observed and keeps its receipt", async () => { + const signerRequest = validSignerRequest(); + const signature = await signWith(account, signerRequest); + const resultBodies: unknown[] = []; + const signer = signerWithFetch(async (input, init) => { + if (new URL(input.toString()).pathname === "/api/agent/sign") { + return Response.json({ receiptId: "receipt-observed", signature }); + } + resultBodies.push(JSON.parse(String(init?.body))); + return new Response(null, { status: 204 }); + }); + await signer.signTypedData(signerRequest); + + await expect( + signer.reportPaymentObservation(paymentContext(signature)), + ).resolves.toBeUndefined(); + await signer.flushPaymentObservations(); + + expect(resultBodies).toEqual([ + { + outcome: "observed", + paymentResponse: { + network: "eip155:8453", + payer: account.address, + success: true, + transaction, + }, + receiptId: "receipt-observed", + }, + ]); + expect(signer.receiptIdForSignature(signature)).toBe("receipt-observed"); + }); + + it("does not reject or lose correlation when the result endpoint returns 5xx", async () => { + const signerRequest = validSignerRequest(); + const signature = await signWith(account, signerRequest); + let reportAttempts = 0; + const signer = signerWithFetch(async (input) => { + if (new URL(input.toString()).pathname === "/api/agent/sign") { + return Response.json({ receiptId: "receipt-outage", signature }); + } + reportAttempts += 1; + return Response.json({ error: { code: "OUTAGE", message: "offline" } }, { status: 503 }); + }); + await signer.signTypedData(signerRequest); + + await expect( + signer.reportPaymentObservation(paymentContext(signature)), + ).resolves.toBeUndefined(); + await signer.flushPaymentObservations(); + + expect(signer.receiptIdForSignature(signature)).toBe("receipt-outage"); + expect(reportAttempts).toBe(3); + }); + + it("clears correlation only after the Tab server acknowledges on-chain proof", async () => { + const signerRequest = validSignerRequest(); + const signature = await signWith(account, signerRequest); + const signer = signerWithFetch(async (input) => + new URL(input.toString()).pathname === "/api/agent/sign" + ? Response.json({ receiptId: "receipt-proven", signature }) + : Response.json({ receiptId: "receipt-proven", status: "settled", verified: true }), + ); + await signer.signTypedData(signerRequest); + + await signer.reportPaymentObservation(paymentContext(signature)); + await signer.flushPaymentObservations(); + + expect(signer.receiptIdForSignature(signature)).toBeNull(); + }); + + it("returns immediately when reporting hangs and bounds the background attempt", async () => { + const signerRequest = validSignerRequest(); + const signature = await signWith(account, signerRequest); + const signer = signerWithFetch(async (input) => { + if (new URL(input.toString()).pathname === "/api/agent/sign") { + return Response.json({ receiptId: "receipt-timeout", signature }); + } + return new Promise(() => undefined); + }); + await signer.signTypedData(signerRequest); + + await expect( + signer.reportPaymentObservation(paymentContext(signature)), + ).resolves.toBeUndefined(); + await signer.flushPaymentObservations(); + + expect(signer.receiptIdForSignature(signature)).toBe("receipt-timeout"); + }); +}); diff --git a/apps/agent/src/remote-signer.ts b/apps/agent/src/remote-signer.ts index 401ea95..007ad73 100644 --- a/apps/agent/src/remote-signer.ts +++ b/apps/agent/src/remote-signer.ts @@ -1,9 +1,13 @@ import type { PaymentResponseContext } from "@x402/core/client"; import type { ClientEvmSigner } from "@x402/evm"; -import { isAddress } from "viem"; +import { isAddress, isAddressEqual, recoverTypedDataAddress } from "viem"; +import { + InvalidEip3009AuthorizationError, + parseExactEip3009Authorization, + type SignerRequest, +} from "./eip3009-authorization.js"; import { currentPaymentOrigin } from "./origin-context.js"; -import { ARBITRUM_NETWORK, BASE_NETWORK } from "./routing.js"; export interface PaymentOrigin { clientName: string; @@ -16,14 +20,11 @@ interface RemoteSignerOptions { apiBaseUrl: string; apiKey: string; fetch?: typeof globalThis.fetch; + nowSeconds?: () => number; origin?: () => PaymentOrigin | undefined; -} - -interface SignerRequest { - domain: Record; - message: Record; - primaryType: string; - types: Record; + reportAttempts?: number; + reportRetryDelayMs?: number; + reportTimeoutMs?: number; } export class RemoteSignerError extends Error { @@ -37,27 +38,6 @@ export class RemoteSignerError extends Error { } } -function stringValue(value: unknown, field: string) { - if (typeof value === "bigint") return value.toString(); - if (typeof value === "number" && Number.isSafeInteger(value)) return String(value); - if (typeof value === "string" && value.length > 0) return value; - throw new RemoteSignerError("INVALID_SIGNER_REQUEST", `${field} is invalid.`, 400); -} - -function networkFromChainId(value: unknown) { - const chainId = stringValue(value, "domain.chainId"); - if (chainId === "8453") return BASE_NETWORK; - if (chainId === "42161") return ARBITRUM_NETWORK; - throw new RemoteSignerError("UNSUPPORTED_NETWORK", "The signing network is unsupported.", 400); -} - -function addressValue(value: unknown, field: string) { - if (typeof value !== "string" || !isAddress(value)) { - throw new RemoteSignerError("INVALID_SIGNER_REQUEST", `${field} is invalid.`, 400); - } - return value; -} - function jsonBody(value: unknown) { return JSON.stringify(value, (_key, field) => typeof field === "bigint" ? field.toString() : field, @@ -85,7 +65,12 @@ export class LeashRemoteSigner implements ClientEvmSigner { readonly #apiKey: string; readonly #endpoint: URL; readonly #fetch: typeof globalThis.fetch; + readonly #inFlightObservations = new Set>(); + readonly #nowSeconds: () => number; readonly #origin: (() => PaymentOrigin | undefined) | undefined; + readonly #reportAttempts: number; + readonly #reportRetryDelayMs: number; + readonly #reportTimeoutMs: number; readonly #resultEndpoint: URL; readonly #receiptBySignature = new Map(); @@ -96,24 +81,44 @@ export class LeashRemoteSigner implements ClientEvmSigner { this.#apiKey = options.apiKey; this.#endpoint = new URL("/api/agent/sign", options.apiBaseUrl); this.#fetch = options.fetch ?? globalThis.fetch; + this.#nowSeconds = options.nowSeconds ?? (() => Math.floor(Date.now() / 1_000)); this.#origin = options.origin ?? currentPaymentOrigin; + this.#reportAttempts = options.reportAttempts ?? 3; + this.#reportRetryDelayMs = options.reportRetryDelayMs ?? 250; + this.#reportTimeoutMs = options.reportTimeoutMs ?? 2_000; + if ( + !Number.isSafeInteger(this.#reportAttempts) || + this.#reportAttempts < 1 || + !Number.isSafeInteger(this.#reportRetryDelayMs) || + this.#reportRetryDelayMs < 0 || + !Number.isSafeInteger(this.#reportTimeoutMs) || + this.#reportTimeoutMs < 1 + ) { + throw new Error("Leash result-report timeout is invalid"); + } this.#resultEndpoint = new URL("/api/agent/pay/result", options.apiBaseUrl); } async signTypedData(signerRequest: SignerRequest): Promise<`0x${string}`> { - const network = networkFromChainId(signerRequest.domain.chainId); - const amount = stringValue(signerRequest.message.value, "message.value"); - const payTo = addressValue(signerRequest.message.to, "message.to"); - const asset = addressValue(signerRequest.domain.verifyingContract, "domain.verifyingContract"); + let authorization: ReturnType; + try { + authorization = parseExactEip3009Authorization(signerRequest, { + address: this.address, + nowSeconds: this.#nowSeconds(), + }); + } catch (error) { + if (!(error instanceof InvalidEip3009AuthorizationError)) throw error; + throw new RemoteSignerError("INVALID_SIGNER_REQUEST", "The signer request is invalid.", 400); + } const origin = this.#origin?.(); const response = await this.#fetch(this.#endpoint, { body: jsonBody({ - amount, - asset, - network, + amount: authorization.amount, + asset: authorization.asset, + network: authorization.network, ...(origin ? { origin } : {}), - payTo, - signerRequest, + payTo: authorization.payTo, + signerRequest: authorization.typedData, }), headers: { authorization: `Bearer ${this.#apiKey}`, "content-type": "application/json" }, method: "POST", @@ -132,17 +137,104 @@ export class LeashRemoteSigner implements ClientEvmSigner { 502, ); } - this.#receiptBySignature.set(body.signature, body.receiptId); - return body.signature as `0x${string}`; + const signature = body.signature as `0x${string}`; + try { + const recovered = await recoverTypedDataAddress({ + ...authorization.typedData, + signature, + }); + if (!isAddressEqual(recovered, this.address)) throw new Error("Signer mismatch"); + } catch { + throw new RemoteSignerError( + "INVALID_SIGNER_RESPONSE", + "The signer response is invalid.", + 502, + ); + } + this.#receiptBySignature.set(signature.toLowerCase(), body.receiptId); + return signature; + } + + receiptIdForSignature(signature: string) { + return this.#receiptBySignature.get(signature.toLowerCase()) ?? null; + } + + async #sendObservationAttempt(body: unknown) { + const controller = new AbortController(); + let timeout: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timeout = setTimeout(() => { + controller.abort(); + resolve(null); + }, this.#reportTimeoutMs); + }); + try { + const response = await Promise.race([ + this.#fetch(this.#resultEndpoint, { + body: JSON.stringify(body), + headers: { authorization: `Bearer ${this.#apiKey}`, "content-type": "application/json" }, + method: "POST", + signal: controller.signal, + }), + deadline, + ]); + if (!response) return { retry: true, verified: false }; + if (!response.ok) { + return { + retry: response.status === 429 || response.status >= 500, + verified: false, + }; + } + if (response.status === 204) return { retry: false, verified: false }; + try { + const acknowledgement = (await response.json()) as { + receiptId?: unknown; + status?: unknown; + verified?: unknown; + }; + const acknowledgedReceipt = + typeof acknowledgement.receiptId === "string" ? acknowledgement.receiptId : undefined; + return { + retry: + response.status === 202 && + acknowledgement.status === "pending" && + acknowledgement.verified === false && + acknowledgedReceipt !== undefined, + verified: + response.status === 200 && + acknowledgement.verified === true && + acknowledgement.status === "settled" && + acknowledgedReceipt !== undefined, + verifiedReceiptId: acknowledgedReceipt, + }; + } catch { + return { retry: false, verified: false }; + } + } catch { + return { retry: true, verified: false }; + } finally { + if (timeout) clearTimeout(timeout); + } } - takeReceiptId(signature: string) { - const receiptId = this.#receiptBySignature.get(signature) ?? null; - this.#receiptBySignature.delete(signature); - return receiptId; + async #sendObservation(body: unknown, receiptId: string, signature: string) { + for (let attempt = 0; attempt < this.#reportAttempts; attempt += 1) { + const result = await this.#sendObservationAttempt(body); + if (result.verified && result.verifiedReceiptId === receiptId) { + const normalizedSignature = signature.toLowerCase(); + if (this.#receiptBySignature.get(normalizedSignature) === receiptId) { + this.#receiptBySignature.delete(normalizedSignature); + } + return; + } + if (!result.retry || attempt === this.#reportAttempts - 1) return; + await new Promise((resolve) => + setTimeout(resolve, this.#reportRetryDelayMs * (attempt + 1)), + ); + } } - async reportSettledPayment(context: PaymentResponseContext) { + reportPaymentObservation(context: PaymentResponseContext): Promise { const settlement = context.settleResponse; const signature = context.paymentPayload.payload.signature; if ( @@ -153,17 +245,31 @@ export class LeashRemoteSigner implements ClientEvmSigner { settlement.payer?.toLowerCase() !== this.address.toLowerCase() || !/^0x[0-9a-fA-F]{64}$/.test(settlement.transaction) ) { - return; + return Promise.resolve(); } - const receiptId = this.#receiptBySignature.get(signature); - if (!receiptId) return; + const receiptId = this.#receiptBySignature.get(signature.toLowerCase()); + if (!receiptId) return Promise.resolve(); - const response = await this.#fetch(this.#resultEndpoint, { - body: JSON.stringify({ outcome: "settled", paymentResponse: settlement, receiptId }), - headers: { authorization: `Bearer ${this.#apiKey}`, "content-type": "application/json" }, - method: "POST", - }); - if (!response.ok) throw await responseError(response); - this.#receiptBySignature.delete(signature); + const task = this.#sendObservation( + { + outcome: "observed", + paymentResponse: { + network: settlement.network, + payer: settlement.payer, + success: true, + transaction: settlement.transaction, + }, + receiptId, + }, + receiptId, + signature, + ); + this.#inFlightObservations.add(task); + void task.finally(() => this.#inFlightObservations.delete(task)); + return Promise.resolve(); + } + + async flushPaymentObservations() { + await Promise.allSettled([...this.#inFlightObservations]); } } diff --git a/apps/agent/src/runtime.ts b/apps/agent/src/runtime.ts new file mode 100644 index 0000000..08c412b --- /dev/null +++ b/apps/agent/src/runtime.ts @@ -0,0 +1,88 @@ +import type { Readable, Writable } from "node:stream"; + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { connectLeashAgent } from "./bootstrap.js"; +import type { LeashCliConfig } from "./cli-config.js"; +import { createPaidFetchServer } from "./paid-fetch-server.js"; +import { createLeashPaymentClient } from "./payment-client.js"; +import { createLeashProxyServer } from "./proxy.js"; +import { LeashRemoteSigner } from "./remote-signer.js"; +import { connectStreamableHttpUpstream } from "./upstream.js"; + +interface StartLeashMcpOptions { + config: LeashCliConfig; + fetch?: typeof globalThis.fetch; + stdin?: Readable; + stdout?: Writable; +} + +export interface LeashMcpRuntime { + close(): Promise; +} + +export async function startLeashMcp(options: StartLeashMcpOptions): Promise { + const fetch_ = options.fetch ?? globalThis.fetch; + const agent = await connectLeashAgent({ + apiBaseUrl: options.config.apiBaseUrl, + apiKey: options.config.apiKey, + fetch: fetch_, + }); + + let signer: LeashRemoteSigner | undefined; + let upstream: Awaited> | undefined; + if (options.config.upstreamUrl) { + upstream = await connectStreamableHttpUpstream(options.config.upstreamUrl, fetch_); + if (agent.address) { + signer = new LeashRemoteSigner({ + address: agent.address, + apiBaseUrl: options.config.apiBaseUrl, + apiKey: options.config.apiKey, + fetch: fetch_, + }); + } + } + + const server = upstream + ? createLeashProxyServer({ + ...(signer ? { paymentClient: createLeashPaymentClient(signer) } : {}), + upstream, + }) + : createPaidFetchServer({ + address: agent.address, + apiBaseUrl: options.config.apiBaseUrl, + apiKey: options.config.apiKey, + fetch: fetch_, + }); + + let resourceClosePromise: Promise | undefined; + const closeResources = () => { + resourceClosePromise ??= (async () => { + if (signer) await signer.flushPaymentObservations(); + if (upstream) await upstream.close(); + })(); + return resourceClosePromise; + }; + server.onclose = () => { + void closeResources(); + }; + + const transport = new StdioServerTransport(options.stdin, options.stdout); + try { + await server.connect(transport); + } catch (error) { + await closeResources(); + throw error; + } + + let closePromise: Promise | undefined; + return { + close() { + closePromise ??= (async () => { + await server.close(); + await closeResources(); + })(); + return closePromise; + }, + }; +} diff --git a/apps/agent/src/upstream.integration.test.ts b/apps/agent/src/upstream.integration.test.ts new file mode 100644 index 0000000..c27d75d --- /dev/null +++ b/apps/agent/src/upstream.integration.test.ts @@ -0,0 +1,72 @@ +import { randomUUID } from "node:crypto"; +import { createServer } from "node:http"; + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { connectStreamableHttpUpstream } from "./upstream.js"; + +describe("real Streamable HTTP upstream client", () => { + const upstream = new Server( + { name: "loopback-upstream", version: "1.0.0" }, + { capabilities: { tools: {} } }, + ); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: randomUUID }); + const httpServer = createServer(async (request, response) => { + try { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const text = Buffer.concat(chunks).toString("utf8"); + await transport.handleRequest(request, response, text ? JSON.parse(text) : undefined); + } catch (error) { + response.statusCode = 500; + response.end(error instanceof Error ? error.message : String(error)); + } + }); + let endpoint = ""; + + beforeAll(async () => { + upstream.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [{ inputSchema: { type: "object" }, name: "echo" }], + })); + upstream.setRequestHandler(CallToolRequestSchema, (request) => ({ + content: [{ text: JSON.stringify(request.params.arguments), type: "text" }], + })); + await upstream.connect(transport as Transport); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const address = httpServer.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP listener"); + endpoint = `http://127.0.0.1:${address.port}/mcp`; + }); + + afterAll(async () => { + await upstream.close(); + await new Promise((resolve, reject) => + httpServer.close((error) => (error ? reject(error) : resolve())), + ); + }); + + it("initializes, lists, and calls through the shipped client transport", async () => { + const inspectingFetch: typeof globalThis.fetch = async (input, init) => { + const response = await globalThis.fetch(input, init); + if (!response.ok) { + throw new Error(`loopback ${response.status}: ${await response.clone().text()}`); + } + return response; + }; + const client = await connectStreamableHttpUpstream(endpoint, inspectingFetch); + try { + await expect(client.listTools()).resolves.toMatchObject({ tools: [{ name: "echo" }] }); + await expect( + client.callTool({ arguments: { value: "real-wire" }, name: "echo" }), + ).resolves.toMatchObject({ + content: [{ text: JSON.stringify({ value: "real-wire" }), type: "text" }], + }); + } finally { + await client.close(); + } + }); +}); diff --git a/apps/agent/src/upstream.ts b/apps/agent/src/upstream.ts new file mode 100644 index 0000000..0d30ff0 --- /dev/null +++ b/apps/agent/src/upstream.ts @@ -0,0 +1,19 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; + +export async function connectStreamableHttpUpstream( + endpoint: string, + fetch: typeof globalThis.fetch = globalThis.fetch, +) { + const client = new Client({ name: "leash-mcp-upstream", version: "0.0.1" }); + const transport = new StreamableHTTPClientTransport(new URL(endpoint), { fetch }); + try { + // SDK 1.29's exact-optional transport declarations are structurally incompatible. + await client.connect(transport as Transport); + return client; + } catch (error) { + await transport.close().catch(() => undefined); + throw error; + } +} diff --git a/apps/agent/tsconfig.build.json b/apps/agent/tsconfig.build.json new file mode 100644 index 0000000..8e879d2 --- /dev/null +++ b/apps/agent/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "./tsconfig.build.tsbuildinfo" + }, + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.integration.test.ts"] +} diff --git a/apps/web/app/api/agent/pay/result/reporter.contract.integration.test.ts b/apps/web/app/api/agent/pay/result/reporter.contract.integration.test.ts new file mode 100644 index 0000000..1cb8031 --- /dev/null +++ b/apps/web/app/api/agent/pay/result/reporter.contract.integration.test.ts @@ -0,0 +1,287 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { createServer } from "node:http"; + +import { LeashRemoteSigner } from "@tab/agent"; +import { NextRequest } from "next/server"; +import { encodeAbiParameters, encodeEventTopics } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { issueLeashKey } from "../../../../../lib/auth/leash-key"; +import { createDatabase } from "../../../../../lib/db/client"; +import { agents, capCycles, receipts, users } from "../../../../../lib/db/schema"; +import { closeServerDatabase } from "../../../../../lib/db/server"; +import { POST } from "./route"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for reporter contract tests"); +const connection = createDatabase(databaseUrl, 2); +const account = privateKeyToAccount(`0x${"11".repeat(32)}`); +const payTo = "0x1111111111111111111111111111111111111111"; +const baseUsdc = "0x833589fCD6EDb6E08f4c7C32D4f71b54bdA02913"; +const nonce = `0x${"12".repeat(32)}` as const; +const transaction = `0x${"ab".repeat(32)}` as const; +const blockHash = `0x${"cd".repeat(32)}`; +const originalRpcUrl = process.env.BASE_RPC_URL; + +const events = [ + { + anonymous: false, + inputs: [ + { indexed: true, name: "from", type: "address" }, + { indexed: true, name: "to", type: "address" }, + { indexed: false, name: "value", type: "uint256" }, + ], + name: "Transfer", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, name: "authorizer", type: "address" }, + { indexed: true, name: "nonce", type: "bytes32" }, + ], + name: "AuthorizationUsed", + type: "event", + }, +] as const; + +function rpcLog(topics: ReturnType, data: `0x${string}`, index: string) { + return { + address: baseUsdc, + blockHash, + blockNumber: "0x1", + data, + logIndex: index, + removed: false, + topics: topics.map((topic) => { + if (typeof topic !== "string") throw new Error("Expected encoded event topics"); + return topic; + }), + transactionHash: transaction, + transactionIndex: "0x0", + }; +} + +function rpcReceipt(includeAuthorization: boolean) { + const transfer = rpcLog( + encodeEventTopics({ + abi: events, + args: { from: account.address, to: payTo }, + eventName: "Transfer", + }), + encodeAbiParameters([{ type: "uint256" }], [BigInt(25_000)]), + "0x0", + ); + const authorization = rpcLog( + encodeEventTopics({ + abi: events, + args: { authorizer: account.address, nonce }, + eventName: "AuthorizationUsed", + }), + "0x", + "0x1", + ); + return { + blockHash, + blockNumber: "0x1", + contractAddress: null, + cumulativeGasUsed: "0x5208", + effectiveGasPrice: "0x1", + from: "0x3333333333333333333333333333333333333333", + gasUsed: "0x5208", + logs: includeAuthorization ? [transfer, authorization] : [transfer], + logsBloom: `0x${"00".repeat(256)}`, + status: "0x1", + to: baseUsdc, + transactionHash: transaction, + transactionIndex: "0x0", + type: "0x2", + }; +} + +async function requestBody(request: import("node:http").IncomingMessage) { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +describe("agent reporter -> web settlement route contract", () => { + let apiOrigin = ""; + let currentReceiptId = ""; + let includeAuthorization = false; + const rpcServer = createServer(async (request, response) => { + const body = JSON.parse(await requestBody(request)); + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ id: body.id, jsonrpc: "2.0", result: rpcReceipt(includeAuthorization) }), + ); + }); + const apiServer = createServer(async (request, response) => { + const body = await requestBody(request); + if (request.url === "/api/agent/sign") { + const parsed = JSON.parse(body); + const signature = await account.signTypedData(parsed.signerRequest); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ receiptId: currentReceiptId, signature })); + return; + } + if (request.url === "/api/agent/pay/result") { + const routed = await POST( + new NextRequest("http://localhost/api/agent/pay/result", { + body, + headers: { + authorization: request.headers.authorization ?? "", + "content-type": "application/json", + }, + method: "POST", + }), + ); + response.statusCode = routed.status; + response.setHeader("content-type", routed.headers.get("content-type") ?? "application/json"); + response.end(await routed.text()); + return; + } + response.statusCode = 404; + response.end(); + }); + + beforeAll(async () => { + await new Promise((resolve) => rpcServer.listen(0, "127.0.0.1", resolve)); + const rpcAddress = rpcServer.address(); + if (!rpcAddress || typeof rpcAddress === "string") throw new Error("Expected RPC listener"); + process.env.BASE_RPC_URL = `http://127.0.0.1:${rpcAddress.port}`; + await new Promise((resolve) => apiServer.listen(0, "127.0.0.1", resolve)); + const apiAddress = apiServer.address(); + if (!apiAddress || typeof apiAddress === "string") throw new Error("Expected API listener"); + apiOrigin = `http://127.0.0.1:${apiAddress.port}`; + }); + + beforeEach(async () => { + currentReceiptId = ""; + includeAuthorization = false; + await connection.client`truncate table users cascade`; + }); + + afterAll(async () => { + if (originalRpcUrl === undefined) delete process.env.BASE_RPC_URL; + else process.env.BASE_RPC_URL = originalRpcUrl; + await closeServerDatabase(); + await connection.client.end(); + await Promise.all( + [apiServer, rpcServer].map( + (server) => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + ), + ); + }); + + it("retains correlation on 202 and clears it only after a verified 200", async () => { + const [user] = await connection.db + .insert(users) + .values({ email: `${randomUUID()}@example.test`, magicIssuer: `did:ethr:${randomUUID()}` }) + .returning({ id: users.id }); + if (!user) throw new Error("Expected user"); + const [agent] = await connection.db + .insert(agents) + .values({ + agentAddress: account.address, + name: "Reporter contract", + ownerId: user.id, + signerSubject: `leash:${randomUUID()}`, + }) + .returning({ id: agents.id }); + if (!agent) throw new Error("Expected agent"); + const key = await issueLeashKey(connection.db, { agentId: agent.id }); + const [cycle] = await connection.db + .insert(capCycles) + .values({ agentId: agent.id, startedAt: new Date() }) + .returning({ id: capCycles.id }); + if (!cycle) throw new Error("Expected cycle"); + const validBefore = Math.floor(Date.now() / 1_000) + 300; + const [receipt] = await connection.db + .insert(receipts) + .values({ + agentId: agent.id, + amountAtomic: "25000", + amountUsd: "0.025000", + asset: baseUsdc, + authorizationNonce: nonce, + authorizationValidBefore: new Date(validBefore * 1_000), + cycleId: cycle.id, + network: "eip155:8453", + payTo, + requestFingerprint: randomBytes(32).toString("hex"), + }) + .returning({ id: receipts.id }); + if (!receipt) throw new Error("Expected receipt"); + currentReceiptId = receipt.id; + + const signer = new LeashRemoteSigner({ + address: account.address, + apiBaseUrl: apiOrigin, + apiKey: key.secret, + fetch: globalThis.fetch, + reportRetryDelayMs: 1, + }); + const signerRequest = { + domain: { chainId: 8453, name: "USD Coin", verifyingContract: baseUsdc, version: "2" }, + message: { + from: account.address, + nonce, + to: payTo, + validAfter: BigInt(0), + validBefore: BigInt(validBefore), + value: BigInt(25_000), + }, + primaryType: "TransferWithAuthorization", + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + }; + const signature = await signer.signTypedData(signerRequest); + const requirements = { + amount: "25000", + asset: baseUsdc, + extra: { name: "USD Coin", version: "2" }, + maxTimeoutSeconds: 60, + network: "eip155:8453", + payTo, + scheme: "exact", + } as const; + const context = { + paymentPayload: { accepted: requirements, payload: { signature }, x402Version: 2 }, + requirements, + settleResponse: { + network: "eip155:8453", + payer: account.address, + success: true, + transaction, + }, + } satisfies Parameters[0]; + + await signer.reportPaymentObservation(context); + await signer.flushPaymentObservations(); + expect(signer.receiptIdForSignature(signature)).toBe(receipt.id); + expect(await connection.db.select({ status: receipts.status }).from(receipts)).toEqual([ + { status: "pending" }, + ]); + + includeAuthorization = true; + await signer.reportPaymentObservation(context); + await signer.flushPaymentObservations(); + expect(signer.receiptIdForSignature(signature)).toBeNull(); + expect(await connection.db.select({ status: receipts.status }).from(receipts)).toEqual([ + { status: "settled" }, + ]); + }); +}); diff --git a/apps/web/app/api/agent/sign/route.integration.test.ts b/apps/web/app/api/agent/sign/route.integration.test.ts index 59a4a3c..2778ada 100644 --- a/apps/web/app/api/agent/sign/route.integration.test.ts +++ b/apps/web/app/api/agent/sign/route.integration.test.ts @@ -99,6 +99,7 @@ function request(secret: string | null, body: unknown, raw = false) { describe("POST /api/agent/sign", () => { let liveBalance = BigInt(1_000_000); + let rpcUnavailable = false; const rpcMethods: string[] = []; const server = createServer(async (incoming, response) => { const chunks: Buffer[] = []; @@ -106,6 +107,16 @@ describe("POST /api/agent/sign", () => { const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); rpcMethods.push(body.method); response.setHeader("content-type", "application/json"); + if (rpcUnavailable) { + response.end( + JSON.stringify({ + error: { code: -32_000, message: "RPC unavailable" }, + id: body.id, + jsonrpc: "2.0", + }), + ); + return; + } response.end( JSON.stringify({ id: body.id, @@ -124,6 +135,7 @@ describe("POST /api/agent/sign", () => { beforeEach(async () => { liveBalance = BigInt(1_000_000); + rpcUnavailable = false; rpcMethods.length = 0; await connection.client`truncate table users cascade`; }); @@ -190,6 +202,21 @@ describe("POST /api/agent/sign", () => { expect(rpcMethods).toEqual(["eth_call"]); }); + it("terminalizes a reservation when RPC fails before any signature can escape", async () => { + const identity = await provision(); + rpcUnavailable = true; + const response = await POST(request(identity.secret, signBody())); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "FLOAT_CHECK_UNAVAILABLE" }, + }); + const [stored] = await connection.db + .select({ reason: receipts.reason, status: receipts.status }) + .from(receipts); + expect(stored).toEqual({ reason: "FLOAT_CHECK_UNAVAILABLE", status: "failed" }); + }); + it("returns the honest signer block with a failed receipt and no signature or hash", async () => { const identity = await provision(); const response = await POST(request(identity.secret, signBody())); diff --git a/apps/web/app/api/agent/sign/route.ts b/apps/web/app/api/agent/sign/route.ts index ebe1271..fe6d08b 100644 --- a/apps/web/app/api/agent/sign/route.ts +++ b/apps/web/app/api/agent/sign/route.ts @@ -6,6 +6,7 @@ import { readFloatBalance } from "../../../../lib/leash/float-balance"; import { InvalidSignRequestError } from "../../../../lib/leash/sign-request"; import { completePreSigningChecks, + failSignRequestBeforeSigning, reserveSignRequest, SignGateError, } from "../../../../lib/leash/sign-store"; @@ -50,6 +51,11 @@ export async function POST(request: NextRequest) { network: reservation.network, }); } catch { + await failSignRequestBeforeSigning(database, { + agentId: principal.agentId, + reason: "FLOAT_CHECK_UNAVAILABLE", + receiptId: reservation.receiptId, + }); return signError("FLOAT_CHECK_UNAVAILABLE", 503); } diff --git a/apps/web/drizzle/0016_cold_riptide.sql b/apps/web/drizzle/0016_cold_riptide.sql new file mode 100644 index 0000000..92af306 --- /dev/null +++ b/apps/web/drizzle/0016_cold_riptide.sql @@ -0,0 +1,41 @@ +DO $migration$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM "receipts" + GROUP BY "agent_id", lower("authorization_nonce") + HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23505', + MESSAGE = 'Cannot canonicalize receipts.authorization_nonce: case-insensitive authorization nonce collisions exist.', + DETAIL = 'No receipt nonce evidence was changed.', + HINT = 'Resolve the colliding receipt records explicitly, then rerun this migration.'; + END IF; +END +$migration$;--> statement-breakpoint +UPDATE "receipts" +SET "authorization_nonce" = lower("authorization_nonce") +WHERE "authorization_nonce" <> lower("authorization_nonce");--> statement-breakpoint +UPDATE "receipts" +SET "reason" = 'LEGACY_REASON_MISSING' +WHERE "status" IN ('failed', 'blocked') + AND "reason" IS NULL;--> statement-breakpoint +ALTER TABLE "receipts" DROP CONSTRAINT "receipts_authorization_check";--> statement-breakpoint +ALTER TABLE "receipts" DROP CONSTRAINT "receipts_state_check";--> statement-breakpoint +ALTER TABLE "receipts" ADD CONSTRAINT "receipts_authorization_check" CHECK ("receipts"."authorization_nonce" ~ '^0x[0-9a-f]{64}$' + and "receipts"."request_fingerprint" ~ '^[0-9a-f]{64}$');--> statement-breakpoint +ALTER TABLE "receipts" ADD CONSTRAINT "receipts_state_check" CHECK (("receipts"."status" = 'pending' and "receipts"."reason" is null + and "receipts"."intended_network" is null and "receipts"."tx_hash" is null + and "receipts"."settlement_response" is null and "receipts"."settled_at" is null) + or ("receipts"."status" = 'settled' and "receipts"."reason" is null + and "receipts"."intended_network" is null and "receipts"."tx_hash" is not null + and "receipts"."settlement_response" is not null and "receipts"."settled_at" is not null) + or ("receipts"."status" = 'failed' and "receipts"."reason" is not null + and "receipts"."reason" ~ '[^[:space:]]' and "receipts"."intended_network" is null + and "receipts"."tx_hash" is null and "receipts"."settlement_response" is null + and "receipts"."settled_at" is null) + or ("receipts"."status" = 'blocked' and "receipts"."reason" is not null + and "receipts"."reason" ~ '[^[:space:]]' and "receipts"."intended_network" is not null + and "receipts"."tx_hash" is null and "receipts"."settlement_response" is null + and "receipts"."settled_at" is null)); diff --git a/apps/web/drizzle/meta/0016_snapshot.json b/apps/web/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000..527f77d --- /dev/null +++ b/apps/web/drizzle/meta/0016_snapshot.json @@ -0,0 +1,2830 @@ +{ + "id": "8e03e866-5a49-4245-8457-19e2a96beb06", + "prevId": "9e4fb6ee-e4b8-4fe1-af6f-9abf7ab2070c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "api_key_permissions", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last4": { + "name": "last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotated_from_id": { + "name": "rotated_from_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_public_key_unique": { + "name": "api_keys_public_key_unique", + "columns": [ + { + "expression": "public_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_secret_hash_unique": { + "name": "api_keys_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_one_active_publishable_per_env": { + "name": "api_keys_one_active_publishable_per_env", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"api_keys\".\"revoked_at\" is null and \"api_keys\".\"type\" = 'publishable'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_merchant_id_merchants_id_fk": { + "name": "api_keys_merchant_id_merchants_id_fk", + "tableFrom": "api_keys", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_rotated_from_id_api_keys_id_fk": { + "name": "api_keys_rotated_from_id_api_keys_id_fk", + "tableFrom": "api_keys", + "tableTo": "api_keys", + "columnsFrom": ["rotated_from_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "api_keys_material_check": { + "name": "api_keys_material_check", + "value": "(\"api_keys\".\"type\" = 'publishable' and \"api_keys\".\"public_key\" is not null and \"api_keys\".\"secret_hash\" is null)\n or (\"api_keys\".\"type\" = 'secret' and \"api_keys\".\"public_key\" is null and \"api_keys\".\"secret_hash\" is not null\n and \"api_keys\".\"secret_hash\" ~ '^[0-9a-f]{64}$')" + }, + "api_keys_permissions_check": { + "name": "api_keys_permissions_check", + "value": "(\"api_keys\".\"type\" = 'publishable' and \"api_keys\".\"permissions\" is null)\n or (\"api_keys\".\"type\" = 'secret' and \"api_keys\".\"permissions\" is not null)" + }, + "api_keys_prefix_check": { + "name": "api_keys_prefix_check", + "value": "(\"api_keys\".\"type\" = 'publishable' and (\n (\"api_keys\".\"env\" = 'test' and \"api_keys\".\"prefix\" = 'pk_test_')\n or (\"api_keys\".\"env\" = 'live' and \"api_keys\".\"prefix\" = 'pk_live_')\n )) or (\"api_keys\".\"type\" = 'secret' and (\n (\"api_keys\".\"env\" = 'test' and \"api_keys\".\"prefix\" = 'sk_test_')\n or (\"api_keys\".\"env\" = 'live' and \"api_keys\".\"prefix\" = 'sk_live_')\n ))" + }, + "api_keys_public_key_prefix_check": { + "name": "api_keys_public_key_prefix_check", + "value": "\"api_keys\".\"type\" = 'secret' or (\n left(\"api_keys\".\"public_key\", length(\"api_keys\".\"prefix\")) = \"api_keys\".\"prefix\"\n and \"api_keys\".\"public_key\" ~ '^pk_(test|live)_[A-Za-z0-9_-]+$'\n )" + } + }, + "isRLSEnabled": false + }, + "public.merchants": { + "name": "merchants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "business_name": { + "name": "business_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_etag": { + "name": "logo_etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_upload_count": { + "name": "logo_upload_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "logo_upload_window_started_at": { + "name": "logo_upload_window_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "receiving_address": { + "name": "receiving_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "receiving_address_source": { + "name": "receiving_address_source", + "type": "receiving_address_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'magic_default'" + }, + "live_activated_at": { + "name": "live_activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "merchants_user_id_unique": { + "name": "merchants_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "merchants_user_id_users_id_fk": { + "name": "merchants_user_id_users_id_fk", + "tableFrom": "merchants", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "merchants_receiving_address_check": { + "name": "merchants_receiving_address_check", + "value": "\"merchants\".\"receiving_address\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"merchants\".\"receiving_address\") <> '0x0000000000000000000000000000000000000000'" + } + }, + "isRLSEnabled": false + }, + "public.quickstart_progress": { + "name": "quickstart_progress", + "schema": "", + "columns": { + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_key": { + "name": "step_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "quickstart_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "quickstart_progress_merchant_id_merchants_id_fk": { + "name": "quickstart_progress_merchant_id_merchants_id_fk", + "tableFrom": "quickstart_progress", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quickstart_progress_merchant_id_step_key_pk": { + "name": "quickstart_progress_merchant_id_step_key_pk", + "columns": ["merchant_id", "step_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "citext", + "primaryKey": false, + "notNull": true + }, + "magic_issuer": { + "name": "magic_issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_magic_issuer_unique": { + "name": "users_magic_issuer_unique", + "columns": [ + { + "expression": "magic_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "agent_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'provisioned'" + }, + "signer_subject": { + "name": "signer_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_address": { + "name": "agent_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_version": { + "name": "client_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_count": { + "name": "connection_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_signer_subject_unique": { + "name": "agents_signer_subject_unique", + "columns": [ + { + "expression": "signer_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_agent_address_unique": { + "name": "agents_agent_address_unique", + "columns": [ + { + "expression": "agent_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agents\".\"agent_address\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agents_name_check": { + "name": "agents_name_check", + "value": "\"agents\".\"name\" ~ '[^[:space:]]'" + }, + "agents_signer_subject_check": { + "name": "agents_signer_subject_check", + "value": "\"agents\".\"signer_subject\" ~ '[^[:space:]]'" + }, + "agents_address_check": { + "name": "agents_address_check", + "value": "\"agents\".\"agent_address\" is null or (\"agents\".\"agent_address\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"agents\".\"agent_address\") <> '0x0000000000000000000000000000000000000000')" + }, + "agents_connection_count_check": { + "name": "agents_connection_count_check", + "value": "\"agents\".\"connection_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.cap_cycles": { + "name": "cap_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reset_reason": { + "name": "reset_reason", + "type": "cap_reset_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "cap_cycles_one_active_per_agent": { + "name": "cap_cycles_one_active_per_agent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cap_cycles\".\"ended_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "cap_cycles_agent_started_idx": { + "name": "cap_cycles_agent_started_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cap_cycles_agent_id_agents_id_fk": { + "name": "cap_cycles_agent_id_agents_id_fk", + "tableFrom": "cap_cycles", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cap_cycles_id_agent_unique": { + "name": "cap_cycles_id_agent_unique", + "nullsNotDistinct": false, + "columns": ["id", "agent_id"] + } + }, + "policies": {}, + "checkConstraints": { + "cap_cycles_end_check": { + "name": "cap_cycles_end_check", + "value": "(\"cap_cycles\".\"ended_at\" is null and \"cap_cycles\".\"reset_reason\" is null)\n or (\"cap_cycles\".\"ended_at\" > \"cap_cycles\".\"started_at\" and \"cap_cycles\".\"reset_reason\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.caps": { + "name": "caps", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "amount_usd_cents": { + "name": "amount_usd_cents", + "type": "numeric(20, 0)", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "cap_frequency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "caps_agent_id_agents_id_fk": { + "name": "caps_agent_id_agents_id_fk", + "tableFrom": "caps", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "caps_amount_check": { + "name": "caps_amount_check", + "value": "\"caps\".\"amount_usd_cents\" is null or \"caps\".\"amount_usd_cents\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.leash_keys": { + "name": "leash_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hashed_key": { + "name": "hashed_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last4": { + "name": "last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_auth_failure_at": { + "name": "last_auth_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_auth_failure_code": { + "name": "last_auth_failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotated_from_id": { + "name": "rotated_from_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "leash_keys_hashed_key_unique": { + "name": "leash_keys_hashed_key_unique", + "columns": [ + { + "expression": "hashed_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leash_keys_one_active_per_agent": { + "name": "leash_keys_one_active_per_agent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leash_keys\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leash_keys_agent_id_agents_id_fk": { + "name": "leash_keys_agent_id_agents_id_fk", + "tableFrom": "leash_keys", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leash_keys_rotated_from_id_leash_keys_id_fk": { + "name": "leash_keys_rotated_from_id_leash_keys_id_fk", + "tableFrom": "leash_keys", + "tableTo": "leash_keys", + "columnsFrom": ["rotated_from_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "leash_keys_hash_check": { + "name": "leash_keys_hash_check", + "value": "\"leash_keys\".\"hashed_key\" ~ '^[0-9a-f]{64}$'" + }, + "leash_keys_prefix_check": { + "name": "leash_keys_prefix_check", + "value": "\"leash_keys\".\"prefix\" = 'leash_sk_'" + }, + "leash_keys_last4_check": { + "name": "leash_keys_last4_check", + "value": "\"leash_keys\".\"last4\" ~ '^[A-Za-z0-9_-]{4}$'" + } + }, + "isRLSEnabled": false + }, + "public.agent_events": { + "name": "agent_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_surface": { + "name": "actor_surface", + "type": "agent_event_surface", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_events_agent_created_idx": { + "name": "agent_events_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_events_agent_id_agents_id_fk": { + "name": "agent_events_agent_id_agents_id_fk", + "tableFrom": "agent_events", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_events_metadata_check": { + "name": "agent_events_metadata_check", + "value": "jsonb_typeof(\"agent_events\".\"metadata\") = 'object'" + } + }, + "isRLSEnabled": false + }, + "public.floats": { + "name": "floats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "leash_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "leash_asset", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_address": { + "name": "token_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "balance_atomic": { + "name": "balance_atomic", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "balance_usd": { + "name": "balance_usd", + "type": "numeric(38, 6)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "floats_agent_network_unique": { + "name": "floats_agent_network_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "floats_agent_id_agents_id_fk": { + "name": "floats_agent_id_agents_id_fk", + "tableFrom": "floats", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "floats_balance_atomic_check": { + "name": "floats_balance_atomic_check", + "value": "\"floats\".\"balance_atomic\" >= 0 and \"floats\".\"balance_atomic\" = trunc(\"floats\".\"balance_atomic\")" + }, + "floats_balance_usd_check": { + "name": "floats_balance_usd_check", + "value": "\"floats\".\"balance_usd\" >= 0" + }, + "floats_native_usdc_check": { + "name": "floats_native_usdc_check", + "value": "(\"floats\".\"network\" = 'eip155:8453'\n and lower(\"floats\".\"token_address\") = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913')\n or (\"floats\".\"network\" = 'eip155:42161'\n and lower(\"floats\".\"token_address\") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')" + } + }, + "isRLSEnabled": false + }, + "public.receipts": { + "name": "receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "receipt_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_atomic": { + "name": "amount_atomic", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric(38, 6)", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "leash_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "intended_network": { + "name": "intended_network", + "type": "leash_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "pay_to": { + "name": "pay_to", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "authorization_nonce": { + "name": "authorization_nonce", + "type": "varchar(66)", + "primaryKey": false, + "notNull": true + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "authorization_valid_before": { + "name": "authorization_valid_before", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settlement_response": { + "name": "settlement_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "receipts_agent_nonce_unique": { + "name": "receipts_agent_nonce_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "authorization_nonce", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_agent_fingerprint_unique": { + "name": "receipts_agent_fingerprint_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_network_tx_hash_unique": { + "name": "receipts_network_tx_hash_unique", + "columns": [ + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"receipts\".\"tx_hash\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_cap_gate_idx": { + "name": "receipts_cap_gate_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_agent_created_idx": { + "name": "receipts_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "receipts_agent_id_agents_id_fk": { + "name": "receipts_agent_id_agents_id_fk", + "tableFrom": "receipts", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "receipts_parent_id_receipts_id_fk": { + "name": "receipts_parent_id_receipts_id_fk", + "tableFrom": "receipts", + "tableTo": "receipts", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "receipts_cycle_agent_fk": { + "name": "receipts_cycle_agent_fk", + "tableFrom": "receipts", + "tableTo": "cap_cycles", + "columnsFrom": ["cycle_id", "agent_id"], + "columnsTo": ["id", "agent_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "receipts_amount_atomic_check": { + "name": "receipts_amount_atomic_check", + "value": "\"receipts\".\"amount_atomic\" > 0 and \"receipts\".\"amount_atomic\" = trunc(\"receipts\".\"amount_atomic\")" + }, + "receipts_amount_usd_check": { + "name": "receipts_amount_usd_check", + "value": "\"receipts\".\"amount_usd\" > 0" + }, + "receipts_pay_to_check": { + "name": "receipts_pay_to_check", + "value": "\"receipts\".\"pay_to\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"receipts\".\"pay_to\") <> '0x0000000000000000000000000000000000000000'" + }, + "receipts_native_usdc_check": { + "name": "receipts_native_usdc_check", + "value": "(\"receipts\".\"network\" = 'eip155:8453'\n and lower(\"receipts\".\"asset\") = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913')\n or (\"receipts\".\"network\" = 'eip155:42161'\n and lower(\"receipts\".\"asset\") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')" + }, + "receipts_authorization_check": { + "name": "receipts_authorization_check", + "value": "\"receipts\".\"authorization_nonce\" ~ '^0x[0-9a-f]{64}$'\n and \"receipts\".\"request_fingerprint\" ~ '^[0-9a-f]{64}$'" + }, + "receipts_origin_check": { + "name": "receipts_origin_check", + "value": "\"receipts\".\"origin\" is null or (jsonb_typeof(\"receipts\".\"origin\") = 'object'\n and \"receipts\".\"origin\" ? 'transport'\n and \"receipts\".\"origin\"->>'transport' in ('mcp', 'http'))" + }, + "receipts_tx_hash_check": { + "name": "receipts_tx_hash_check", + "value": "\"receipts\".\"tx_hash\" is null or \"receipts\".\"tx_hash\" ~ '^0x[0-9a-fA-F]{64}$'" + }, + "receipts_state_check": { + "name": "receipts_state_check", + "value": "(\"receipts\".\"status\" = 'pending' and \"receipts\".\"reason\" is null\n and \"receipts\".\"intended_network\" is null and \"receipts\".\"tx_hash\" is null\n and \"receipts\".\"settlement_response\" is null and \"receipts\".\"settled_at\" is null)\n or (\"receipts\".\"status\" = 'settled' and \"receipts\".\"reason\" is null\n and \"receipts\".\"intended_network\" is null and \"receipts\".\"tx_hash\" is not null\n and \"receipts\".\"settlement_response\" is not null and \"receipts\".\"settled_at\" is not null)\n or (\"receipts\".\"status\" = 'failed' and \"receipts\".\"reason\" is not null\n and \"receipts\".\"reason\" ~ '[^[:space:]]' and \"receipts\".\"intended_network\" is null\n and \"receipts\".\"tx_hash\" is null and \"receipts\".\"settlement_response\" is null\n and \"receipts\".\"settled_at\" is null)\n or (\"receipts\".\"status\" = 'blocked' and \"receipts\".\"reason\" is not null\n and \"receipts\".\"reason\" ~ '[^[:space:]]' and \"receipts\".\"intended_network\" is not null\n and \"receipts\".\"tx_hash\" is null and \"receipts\".\"settlement_response\" is null\n and \"receipts\".\"settled_at\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.orders": { + "name": "orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "order_number": { + "name": "order_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_ref": { + "name": "payment_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "orders_payment_ref_unique": { + "name": "orders_payment_ref_unique", + "columns": [ + { + "expression": "payment_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_merchant_env_number_unique": { + "name": "orders_merchant_env_number_unique", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_payment_merchant_env_fk": { + "name": "orders_payment_merchant_env_fk", + "tableFrom": "orders", + "tableTo": "payments", + "columnsFrom": ["payment_ref", "merchant_id", "env"], + "columnsTo": ["ref_code", "merchant_id", "env"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "orders_order_number_check": { + "name": "orders_order_number_check", + "value": "\"orders\".\"order_number\" ~ '[^[:space:]]'" + }, + "orders_payment_ref_check": { + "name": "orders_payment_ref_check", + "value": "\"orders\".\"payment_ref\" ~ '^TAB-[A-Z0-9]+$'" + } + }, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ref_code": { + "name": "ref_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true + }, + "receiver": { + "name": "receiver", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "token_address": { + "name": "token_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "token_chain_id": { + "name": "token_chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "intent_url": { + "name": "intent_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_type": { + "name": "payer_type", + "type": "payer_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "payer_email": { + "name": "payer_email", + "type": "citext", + "primaryKey": false, + "notNull": false + }, + "payer_address": { + "name": "payer_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_transaction_id": { + "name": "reported_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_token_changes": { + "name": "reported_token_changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reported_at": { + "name": "reported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verification_next_attempt_at": { + "name": "verification_next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verification_lease_token": { + "name": "verification_lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verification_lease_expires_at": { + "name": "verification_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payments_ref_code_unique": { + "name": "payments_ref_code_unique", + "columns": [ + { + "expression": "ref_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_settlement_evidence_unique": { + "name": "payments_settlement_evidence_unique", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reported_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "livemode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_reported_transaction_id_idx": { + "name": "payments_reported_transaction_id_idx", + "columns": [ + { + "expression": "reported_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_merchant_env_created_idx": { + "name": "payments_merchant_env_created_idx", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_pending_sweep_idx": { + "name": "payments_pending_sweep_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_verification_sweep_idx": { + "name": "payments_verification_sweep_idx", + "columns": [ + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reported_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verification_next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_merchant_id_merchants_id_fk": { + "name": "payments_merchant_id_merchants_id_fk", + "tableFrom": "payments", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "payments_id_merchant_env_unique": { + "name": "payments_id_merchant_env_unique", + "nullsNotDistinct": false, + "columns": ["id", "merchant_id", "env"] + }, + "payments_ref_merchant_env_unique": { + "name": "payments_ref_merchant_env_unique", + "nullsNotDistinct": false, + "columns": ["ref_code", "merchant_id", "env"] + } + }, + "policies": {}, + "checkConstraints": { + "payments_ref_code_check": { + "name": "payments_ref_code_check", + "value": "\"payments\".\"ref_code\" ~ '^TAB-[A-Z0-9]+$'" + }, + "payments_amount_check": { + "name": "payments_amount_check", + "value": "\"payments\".\"amount_usd\" > 0 and \"payments\".\"amount_usd\" < 100000000000000\n and scale(\"payments\".\"amount_usd\") <= 6" + }, + "payments_currency_check": { + "name": "payments_currency_check", + "value": "\"payments\".\"currency\" = 'USD'" + }, + "payments_chain_check": { + "name": "payments_chain_check", + "value": "\"payments\".\"token_chain_id\" = 42161" + }, + "payments_token_check": { + "name": "payments_token_check", + "value": "lower(\"payments\".\"token_address\") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831'" + }, + "payments_receiver_check": { + "name": "payments_receiver_check", + "value": "\"payments\".\"receiver\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"payments\".\"receiver\") <> '0x0000000000000000000000000000000000000000'" + }, + "payments_payer_address_check": { + "name": "payments_payer_address_check", + "value": "\"payments\".\"payer_address\" is null or (\"payments\".\"payer_address\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"payments\".\"payer_address\") <> '0x0000000000000000000000000000000000000000')" + }, + "payments_livemode_check": { + "name": "payments_livemode_check", + "value": "(\"payments\".\"env\" = 'live' and \"payments\".\"livemode\")\n or (\"payments\".\"env\" = 'test' and not \"payments\".\"livemode\")" + }, + "payments_report_check": { + "name": "payments_report_check", + "value": "(\"payments\".\"reported_transaction_id\" is null and \"payments\".\"reported_token_changes\" is null and \"payments\".\"reported_at\" is null)\n or (\"payments\".\"reported_transaction_id\" is not null and btrim(\"payments\".\"reported_transaction_id\") <> ''\n and \"payments\".\"reported_token_changes\" is not null\n and jsonb_typeof(\"payments\".\"reported_token_changes\") = 'array' and \"payments\".\"reported_at\" is not null)" + }, + "payments_settled_at_check": { + "name": "payments_settled_at_check", + "value": "(\"payments\".\"status\" = 'settled' and \"payments\".\"settled_at\" is not null)\n or (\"payments\".\"status\" <> 'settled' and \"payments\".\"settled_at\" is null)" + }, + "payments_verification_lease_check": { + "name": "payments_verification_lease_check", + "value": "(\"payments\".\"verification_lease_token\" is null and \"payments\".\"verification_lease_expires_at\" is null)\n or (\"payments\".\"verification_lease_token\" is not null\n and \"payments\".\"verification_lease_expires_at\" is not null and \"payments\".\"env\" = 'live'\n and \"payments\".\"status\" = 'pending' and \"payments\".\"reported_at\" is not null\n and \"payments\".\"reported_transaction_id\" is not null and \"payments\".\"payer_address\" is not null)" + }, + "payments_verification_schedule_check": { + "name": "payments_verification_schedule_check", + "value": "\"payments\".\"verification_next_attempt_at\" is null\n or (\"payments\".\"env\" = 'live' and \"payments\".\"reported_at\" is not null)" + }, + "payments_failure_reason_check": { + "name": "payments_failure_reason_check", + "value": "(\"payments\".\"status\" = 'failed' and \"payments\".\"failure_reason\" is not null)\n or (\"payments\".\"status\" <> 'failed' and \"payments\".\"failure_reason\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.settlements": { + "name": "settlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "payment_id": { + "name": "payment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "particle_transaction_id": { + "name": "particle_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "token_changes_json": { + "name": "token_changes_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "amount_atomic": { + "name": "amount_atomic", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "verification_method": { + "name": "verification_method", + "type": "settlement_verification_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "verification_trigger": { + "name": "verification_trigger", + "type": "settlement_verification_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settlements_payment_id_unique": { + "name": "settlements_payment_id_unique", + "columns": [ + { + "expression": "payment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlements_particle_transaction_id_unique": { + "name": "settlements_particle_transaction_id_unique", + "columns": [ + { + "expression": "particle_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlements_payment_evidence_fk": { + "name": "settlements_payment_evidence_fk", + "tableFrom": "settlements", + "tableTo": "payments", + "columnsFrom": ["payment_id", "particle_transaction_id", "livemode"], + "columnsTo": ["id", "reported_transaction_id", "livemode"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settlements_id_payment_unique": { + "name": "settlements_id_payment_unique", + "nullsNotDistinct": false, + "columns": ["id", "payment_id"] + } + }, + "policies": {}, + "checkConstraints": { + "settlements_amount_atomic_check": { + "name": "settlements_amount_atomic_check", + "value": "\"settlements\".\"amount_atomic\" > 0 and \"settlements\".\"amount_atomic\" = trunc(\"settlements\".\"amount_atomic\")\n and \"settlements\".\"amount_atomic\" < 1000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "settlements_token_changes_check": { + "name": "settlements_token_changes_check", + "value": "jsonb_typeof(\"settlements\".\"token_changes_json\") = 'array'" + }, + "settlements_tx_hash_check": { + "name": "settlements_tx_hash_check", + "value": "\"settlements\".\"tx_hash\" is null or \"settlements\".\"tx_hash\" ~ '^0x[0-9a-fA-F]{64}$'" + }, + "settlements_simulation_check": { + "name": "settlements_simulation_check", + "value": "(\"settlements\".\"verification_method\" = 'simulated_test' and not \"settlements\".\"livemode\")\n or (\"settlements\".\"verification_method\" <> 'simulated_test' and \"settlements\".\"livemode\")" + } + }, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payment_id": { + "name": "payment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "settlement_id": { + "name": "settlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retry_chain_id": { + "name": "retry_chain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_body_hash": { + "name": "request_body_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "encode(digest(\"webhook_deliveries\".\"request_body\", 'sha256'), 'hex')", + "type": "stored" + } + }, + "type": { + "name": "type", + "type": "webhook_delivery_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "webhook_delivery_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "webhook_delivery_result", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "failure_kind": { + "name": "failure_kind", + "type": "webhook_failure_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "signature_header": { + "name": "signature_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body_snippet": { + "name": "response_body_snippet", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_delivery_id": { + "name": "parent_delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_attempt": { + "name": "parent_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "superseded_by_attempt": { + "name": "superseded_by_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_deliveries_chain_attempt_unique": { + "name": "webhook_deliveries_chain_attempt_unique", + "columns": [ + { + "expression": "retry_chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_automatic_settlement_root_unique": { + "name": "webhook_deliveries_automatic_settlement_root_unique", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook_deliveries\".\"trigger\" = 'auto' and \"webhook_deliveries\".\"type\" = 'payment' and \"webhook_deliveries\".\"attempt\" = 1", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_dashboard_head_idx": { + "name": "webhook_deliveries_dashboard_head_idx", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook_deliveries\".\"trigger\" = 'auto' and \"webhook_deliveries\".\"type\" = 'payment'\n and \"webhook_deliveries\".\"superseded_by_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_due_idx": { + "name": "webhook_deliveries_due_idx", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_event_idx": { + "name": "webhook_deliveries_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_deliveries_endpoint_scope_fk": { + "name": "webhook_deliveries_endpoint_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_endpoints", + "columnsFrom": ["endpoint_id", "merchant_id", "env"], + "columnsTo": ["id", "merchant_id", "env"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_payment_scope_fk": { + "name": "webhook_deliveries_payment_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "payments", + "columnsFrom": ["payment_id", "merchant_id", "env"], + "columnsTo": ["id", "merchant_id", "env"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_settlement_payment_fk": { + "name": "webhook_deliveries_settlement_payment_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "settlements", + "columnsFrom": ["settlement_id", "payment_id"], + "columnsTo": ["id", "payment_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_chain_scope_fk": { + "name": "webhook_deliveries_chain_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": [ + "retry_chain_id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type", + "trigger" + ], + "columnsTo": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type", + "trigger" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_chain_evidence_fk": { + "name": "webhook_deliveries_chain_evidence_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["retry_chain_id", "payment_id", "settlement_id"], + "columnsTo": ["id", "payment_id", "settlement_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_parent_scope_fk": { + "name": "webhook_deliveries_parent_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": [ + "parent_delivery_id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type" + ], + "columnsTo": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_parent_evidence_fk": { + "name": "webhook_deliveries_parent_evidence_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["parent_delivery_id", "payment_id", "settlement_id"], + "columnsTo": ["id", "payment_id", "settlement_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_parent_sequence_fk": { + "name": "webhook_deliveries_parent_sequence_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["parent_delivery_id", "retry_chain_id", "parent_attempt"], + "columnsTo": ["id", "retry_chain_id", "attempt"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_successor_scope_fk": { + "name": "webhook_deliveries_successor_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["superseded_by_id", "id", "retry_chain_id", "superseded_by_attempt"], + "columnsTo": ["id", "parent_delivery_id", "retry_chain_id", "attempt"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_deliveries_id_chain_scope_unique": { + "name": "webhook_deliveries_id_chain_scope_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type", + "trigger" + ] + }, + "webhook_deliveries_id_tenant_unique": { + "name": "webhook_deliveries_id_tenant_unique", + "nullsNotDistinct": false, + "columns": ["id", "merchant_id", "env"] + }, + "webhook_deliveries_id_event_scope_unique": { + "name": "webhook_deliveries_id_event_scope_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type" + ] + }, + "webhook_deliveries_id_evidence_unique": { + "name": "webhook_deliveries_id_evidence_unique", + "nullsNotDistinct": false, + "columns": ["id", "payment_id", "settlement_id"] + }, + "webhook_deliveries_id_retry_attempt_unique": { + "name": "webhook_deliveries_id_retry_attempt_unique", + "nullsNotDistinct": false, + "columns": ["id", "retry_chain_id", "attempt"] + }, + "webhook_deliveries_id_parent_chain_attempt_unique": { + "name": "webhook_deliveries_id_parent_chain_attempt_unique", + "nullsNotDistinct": false, + "columns": ["id", "parent_delivery_id", "retry_chain_id", "attempt"] + } + }, + "policies": {}, + "checkConstraints": { + "webhook_deliveries_event_id_check": { + "name": "webhook_deliveries_event_id_check", + "value": "\"webhook_deliveries\".\"event_id\" ~ '^evt_[A-Za-z0-9_-]+$'" + }, + "webhook_deliveries_body_hash_check": { + "name": "webhook_deliveries_body_hash_check", + "value": "\"webhook_deliveries\".\"request_body_hash\" ~ '^[0-9a-f]{64}$'" + }, + "webhook_deliveries_chain_root_check": { + "name": "webhook_deliveries_chain_root_check", + "value": "((\"webhook_deliveries\".\"attempt\" = 1 and \"webhook_deliveries\".\"retry_chain_id\" = \"webhook_deliveries\".\"id\"\n and \"webhook_deliveries\".\"parent_attempt\" is null)\n or (\"webhook_deliveries\".\"attempt\" > 1 and \"webhook_deliveries\".\"retry_chain_id\" <> \"webhook_deliveries\".\"id\"\n and \"webhook_deliveries\".\"parent_delivery_id\" is not null\n and \"webhook_deliveries\".\"parent_attempt\" = \"webhook_deliveries\".\"attempt\" - 1))\n and (\"webhook_deliveries\".\"parent_delivery_id\" is null or \"webhook_deliveries\".\"parent_delivery_id\" <> \"webhook_deliveries\".\"id\")" + }, + "webhook_deliveries_attempt_check": { + "name": "webhook_deliveries_attempt_check", + "value": "\"webhook_deliveries\".\"attempt\" between 1 and 3" + }, + "webhook_deliveries_type_check": { + "name": "webhook_deliveries_type_check", + "value": "(\"webhook_deliveries\".\"type\" = 'payment' and \"webhook_deliveries\".\"payment_id\" is not null\n and \"webhook_deliveries\".\"settlement_id\" is not null)\n or (\"webhook_deliveries\".\"type\" = 'test' and \"webhook_deliveries\".\"payment_id\" is null\n and \"webhook_deliveries\".\"settlement_id\" is null)" + }, + "webhook_deliveries_status_code_check": { + "name": "webhook_deliveries_status_code_check", + "value": "\"webhook_deliveries\".\"status_code\" is null or \"webhook_deliveries\".\"status_code\" between 100 and 599" + }, + "webhook_deliveries_response_time_check": { + "name": "webhook_deliveries_response_time_check", + "value": "\"webhook_deliveries\".\"response_time_ms\" is null or \"webhook_deliveries\".\"response_time_ms\" >= 0" + }, + "webhook_deliveries_signature_check": { + "name": "webhook_deliveries_signature_check", + "value": "\"webhook_deliveries\".\"signature_header\" is null\n or \"webhook_deliveries\".\"signature_header\" ~ '^t=[0-9]+,v1=[0-9a-f]{64}$'" + }, + "webhook_deliveries_lease_check": { + "name": "webhook_deliveries_lease_check", + "value": "(\"webhook_deliveries\".\"lease_token\" is null and \"webhook_deliveries\".\"lease_expires_at\" is null)\n or (\"webhook_deliveries\".\"lease_token\" is not null and \"webhook_deliveries\".\"lease_expires_at\" is not null)" + }, + "webhook_deliveries_successor_check": { + "name": "webhook_deliveries_successor_check", + "value": "(\"webhook_deliveries\".\"superseded_by_id\" is null and \"webhook_deliveries\".\"superseded_by_attempt\" is null)\n or (\"webhook_deliveries\".\"superseded_by_id\" is not null\n and \"webhook_deliveries\".\"superseded_by_id\" <> \"webhook_deliveries\".\"id\"\n and \"webhook_deliveries\".\"superseded_by_attempt\" = \"webhook_deliveries\".\"attempt\" + 1\n and \"webhook_deliveries\".\"superseded_by_attempt\" between 2 and 3)" + }, + "webhook_deliveries_result_check": { + "name": "webhook_deliveries_result_check", + "value": "coalesce(((\"webhook_deliveries\".\"result\" = 'pending' and \"webhook_deliveries\".\"completed_at\" is null\n and \"webhook_deliveries\".\"failure_kind\" is null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"status_code\" is null and \"webhook_deliveries\".\"response_time_ms\" is null\n and \"webhook_deliveries\".\"response_body_snippet\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null)\n or (\"webhook_deliveries\".\"result\" = 'delivered' and \"webhook_deliveries\".\"completed_at\" is not null\n and \"webhook_deliveries\".\"failure_kind\" is null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"status_code\" between 200 and 299\n and \"webhook_deliveries\".\"signature_header\" is not null and \"webhook_deliveries\".\"started_at\" is not null\n and \"webhook_deliveries\".\"response_time_ms\" is not null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null)\n or (\"webhook_deliveries\".\"result\" = 'retrying' and \"webhook_deliveries\".\"completed_at\" is not null\n and \"webhook_deliveries\".\"failure_kind\" in ('http', 'network', 'timeout')\n and \"webhook_deliveries\".\"next_retry_at\" is not null and \"webhook_deliveries\".\"attempt\" < 3\n and \"webhook_deliveries\".\"signature_header\" is not null and \"webhook_deliveries\".\"started_at\" is not null\n and \"webhook_deliveries\".\"response_time_ms\" is not null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null\n and ((\"webhook_deliveries\".\"failure_kind\" = 'http' and (\"webhook_deliveries\".\"status_code\" < 200\n or \"webhook_deliveries\".\"status_code\" > 299))\n or (\"webhook_deliveries\".\"failure_kind\" in ('network', 'timeout') and \"webhook_deliveries\".\"status_code\" is null)))\n or (\"webhook_deliveries\".\"result\" in ('failed', 'timeout') and \"webhook_deliveries\".\"attempt\" < 3\n and \"webhook_deliveries\".\"completed_at\" is not null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"signature_header\" is not null and \"webhook_deliveries\".\"started_at\" is not null\n and \"webhook_deliveries\".\"response_time_ms\" is not null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is not null\n and ((\"webhook_deliveries\".\"result\" = 'failed' and \"webhook_deliveries\".\"failure_kind\" in ('http', 'network'))\n or (\"webhook_deliveries\".\"result\" = 'timeout' and \"webhook_deliveries\".\"failure_kind\" = 'timeout'))\n and ((\"webhook_deliveries\".\"failure_kind\" = 'http' and (\"webhook_deliveries\".\"status_code\" < 200\n or \"webhook_deliveries\".\"status_code\" > 299))\n or (\"webhook_deliveries\".\"failure_kind\" in ('network', 'timeout') and \"webhook_deliveries\".\"status_code\" is null)))\n or (\"webhook_deliveries\".\"result\" = 'failed' and \"webhook_deliveries\".\"failure_kind\" = 'configuration'\n and \"webhook_deliveries\".\"completed_at\" is not null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"signature_header\" is null and \"webhook_deliveries\".\"status_code\" is null\n and \"webhook_deliveries\".\"response_time_ms\" is null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null)\n or (\"webhook_deliveries\".\"result\" = 'gave_up' and \"webhook_deliveries\".\"attempt\" = 3\n and \"webhook_deliveries\".\"completed_at\" is not null\n and \"webhook_deliveries\".\"failure_kind\" in ('http', 'network', 'timeout')\n and \"webhook_deliveries\".\"next_retry_at\" is null and \"webhook_deliveries\".\"signature_header\" is not null\n and \"webhook_deliveries\".\"started_at\" is not null and \"webhook_deliveries\".\"response_time_ms\" is not null\n and \"webhook_deliveries\".\"lease_token\" is null and \"webhook_deliveries\".\"lease_expires_at\" is null\n and \"webhook_deliveries\".\"superseded_by_id\" is null\n and ((\"webhook_deliveries\".\"failure_kind\" = 'http' and (\"webhook_deliveries\".\"status_code\" < 200\n or \"webhook_deliveries\".\"status_code\" > 299))\n or (\"webhook_deliveries\".\"failure_kind\" in ('network', 'timeout') and \"webhook_deliveries\".\"status_code\" is null)))), false)" + } + }, + "isRLSEnabled": false + }, + "public.webhook_endpoints": { + "name": "webhook_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_nonce": { + "name": "secret_nonce", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "secret_auth_tag": { + "name": "secret_auth_tag", + "type": "varchar(22)", + "primaryKey": false, + "notNull": false + }, + "secret_key_version": { + "name": "secret_key_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_last4": { + "name": "secret_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "webhook_endpoints_one_active_per_env": { + "name": "webhook_endpoints_one_active_per_env", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook_endpoints\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_endpoints_merchant_id_merchants_id_fk": { + "name": "webhook_endpoints_merchant_id_merchants_id_fk", + "tableFrom": "webhook_endpoints", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_endpoints_id_scope_unique": { + "name": "webhook_endpoints_id_scope_unique", + "nullsNotDistinct": false, + "columns": ["id", "merchant_id", "env"] + } + }, + "policies": {}, + "checkConstraints": { + "webhook_endpoints_url_check": { + "name": "webhook_endpoints_url_check", + "value": "char_length(btrim(\"webhook_endpoints\".\"url\")) between 1 and 2048\n and (\"webhook_endpoints\".\"url\" ~ '^https://[^/?#[:space:]@]+([:/?#]|$)'\n or (\"webhook_endpoints\".\"env\" = 'test'\n and \"webhook_endpoints\".\"url\" ~ '^http://(127\\.0\\.0\\.1|\\[::1\\]|localhost)(:[0-9]+)?/'))\n and \"webhook_endpoints\".\"url\" !~* '^https://(localhost\\.?|127(\\.[0-9]{1,3}){3}|10(\\.[0-9]{1,3}){3}|192\\.168(\\.[0-9]{1,3}){2}|169\\.254(\\.[0-9]{1,3}){2}|172\\.(1[6-9]|2[0-9]|3[01])(\\.[0-9]{1,3}){2}|\\[(::1|f[cd][0-9a-f:]*|fe[89ab][0-9a-f:]*)\\])([:/?#]|$)'" + }, + "webhook_endpoints_last4_check": { + "name": "webhook_endpoints_last4_check", + "value": "char_length(\"webhook_endpoints\".\"secret_last4\") = 4" + }, + "webhook_endpoints_secret_envelope_check": { + "name": "webhook_endpoints_secret_envelope_check", + "value": "coalesce(((\"webhook_endpoints\".\"deleted_at\" is null\n and \"webhook_endpoints\".\"secret_ciphertext\" is not null\n and char_length(\"webhook_endpoints\".\"secret_ciphertext\") > 0\n and \"webhook_endpoints\".\"secret_nonce\" is not null\n and \"webhook_endpoints\".\"secret_nonce\" ~ '^[A-Za-z0-9_-]{16}$'\n and \"webhook_endpoints\".\"secret_auth_tag\" is not null\n and \"webhook_endpoints\".\"secret_auth_tag\" ~ '^[A-Za-z0-9_-]{22}$'\n and \"webhook_endpoints\".\"secret_key_version\" is not null\n and \"webhook_endpoints\".\"secret_key_version\" > 0)\n or (\"webhook_endpoints\".\"deleted_at\" is not null\n and \"webhook_endpoints\".\"secret_ciphertext\" is null and \"webhook_endpoints\".\"secret_nonce\" is null\n and \"webhook_endpoints\".\"secret_auth_tag\" is null and \"webhook_endpoints\".\"secret_key_version\" is null)), false)" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.api_key_permissions": { + "name": "api_key_permissions", + "schema": "public", + "values": ["full", "read_only"] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": ["secret", "publishable"] + }, + "public.environment": { + "name": "environment", + "schema": "public", + "values": ["test", "live"] + }, + "public.quickstart_source": { + "name": "quickstart_source", + "schema": "public", + "values": ["auto", "manual"] + }, + "public.receiving_address_source": { + "name": "receiving_address_source", + "schema": "public", + "values": ["magic_default", "custom"] + }, + "public.agent_status": { + "name": "agent_status", + "schema": "public", + "values": ["provisioned", "paused", "frozen", "cancelled", "nuked"] + }, + "public.cap_frequency": { + "name": "cap_frequency", + "schema": "public", + "values": ["daily", "weekly", "monthly", "never"] + }, + "public.cap_reset_reason": { + "name": "cap_reset_reason", + "schema": "public", + "values": ["schedule", "manual", "frequency_change"] + }, + "public.leash_network": { + "name": "leash_network", + "schema": "public", + "values": ["eip155:8453", "eip155:42161"] + }, + "public.agent_event_surface": { + "name": "agent_event_surface", + "schema": "public", + "values": ["agent", "web", "pwa", "push_action", "system"] + }, + "public.agent_event_type": { + "name": "agent_event_type", + "schema": "public", + "values": ["connect", "sign", "block", "revoke"] + }, + "public.leash_asset": { + "name": "leash_asset", + "schema": "public", + "values": ["USDC"] + }, + "public.receipt_status": { + "name": "receipt_status", + "schema": "public", + "values": ["pending", "settled", "failed", "blocked"] + }, + "public.payer_type": { + "name": "payer_type", + "schema": "public", + "values": ["human", "agent"] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": ["pending", "settled", "failed"] + }, + "public.settlement_verification_method": { + "name": "settlement_verification_method", + "schema": "public", + "values": ["rpc", "particle", "x402_receipt", "simulated_test"] + }, + "public.settlement_verification_trigger": { + "name": "settlement_verification_trigger", + "schema": "public", + "values": ["inline", "cron_sweep"] + }, + "public.webhook_delivery_result": { + "name": "webhook_delivery_result", + "schema": "public", + "values": ["pending", "delivered", "retrying", "failed", "timeout", "gave_up"] + }, + "public.webhook_delivery_trigger": { + "name": "webhook_delivery_trigger", + "schema": "public", + "values": ["auto", "manual"] + }, + "public.webhook_delivery_type": { + "name": "webhook_delivery_type", + "schema": "public", + "values": ["payment", "test"] + }, + "public.webhook_failure_kind": { + "name": "webhook_failure_kind", + "schema": "public", + "values": ["http", "network", "timeout", "configuration"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json index 4e8d144..97070e1 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1784272542101, "tag": "0015_wandering_dagger", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1784275503075, + "tag": "0016_cold_riptide", + "breakpoints": true } ] } diff --git a/apps/web/lib/db/leash-control-schema.ts b/apps/web/lib/db/leash-control-schema.ts new file mode 100644 index 0000000..f997aba --- /dev/null +++ b/apps/web/lib/db/leash-control-schema.ts @@ -0,0 +1,137 @@ +import { sql } from "drizzle-orm"; +import { + type AnyPgColumn, + check, + index, + integer, + numeric, + pgEnum, + pgTable, + text, + timestamp, + unique, + uniqueIndex, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +import { users } from "./identity-schema"; + +export const agentStatus = pgEnum("agent_status", [ + "provisioned", + "paused", + "frozen", + "cancelled", + "nuked", +]); +export const capFrequency = pgEnum("cap_frequency", ["daily", "weekly", "monthly", "never"]); +export const capResetReason = pgEnum("cap_reset_reason", [ + "schedule", + "manual", + "frequency_change", +]); +export const leashNetwork = pgEnum("leash_network", ["eip155:8453", "eip155:42161"]); + +export const agents = pgTable( + "agents", + { + id: uuid("id").defaultRandom().primaryKey(), + ownerId: uuid("owner_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + status: agentStatus("status").default("provisioned").notNull(), + signerSubject: text("signer_subject").notNull(), + agentAddress: varchar("agent_address", { length: 42 }), + clientName: text("client_name"), + clientVersion: text("client_version"), + transport: text("transport"), + connectionCount: integer("connection_count").default(0).notNull(), + firstSeenAt: timestamp("first_seen_at", { withTimezone: true }), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("agents_owner_id_idx").on(table.ownerId), + uniqueIndex("agents_signer_subject_unique").on(table.signerSubject), + uniqueIndex("agents_agent_address_unique") + .on(table.agentAddress) + .where(sql`${table.agentAddress} is not null`), + check("agents_name_check", sql`${table.name} ~ '[^[:space:]]'`), + check("agents_signer_subject_check", sql`${table.signerSubject} ~ '[^[:space:]]'`), + check( + "agents_address_check", + sql`${table.agentAddress} is null or (${table.agentAddress} ~ '^0x[0-9a-fA-F]{40}$' + and lower(${table.agentAddress}) <> '0x0000000000000000000000000000000000000000')`, + ), + check("agents_connection_count_check", sql`${table.connectionCount} >= 0`), + ], +); + +export const leashKeys = pgTable( + "leash_keys", + { + id: uuid("id").defaultRandom().primaryKey(), + agentId: uuid("agent_id") + .notNull() + .references(() => agents.id, { onDelete: "cascade" }), + hashedKey: varchar("hashed_key", { length: 64 }).notNull(), + prefix: text("prefix").notNull(), + last4: varchar("last4", { length: 4 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + lastAuthFailureAt: timestamp("last_auth_failure_at", { withTimezone: true }), + lastAuthFailureCode: text("last_auth_failure_code"), + rotatedFromId: uuid("rotated_from_id").references((): AnyPgColumn => leashKeys.id), + }, + (table) => [ + uniqueIndex("leash_keys_hashed_key_unique").on(table.hashedKey), + uniqueIndex("leash_keys_one_active_per_agent") + .on(table.agentId) + .where(sql`${table.revokedAt} is null`), + check("leash_keys_hash_check", sql`${table.hashedKey} ~ '^[0-9a-f]{64}$'`), + check("leash_keys_prefix_check", sql`${table.prefix} = 'leash_sk_'`), + check("leash_keys_last4_check", sql`${table.last4} ~ '^[A-Za-z0-9_-]{4}$'`), + ], +); + +export const caps = pgTable( + "caps", + { + agentId: uuid("agent_id") + .primaryKey() + .references(() => agents.id, { onDelete: "cascade" }), + amountUsdCents: numeric("amount_usd_cents", { precision: 20, scale: 0 }), + frequency: capFrequency("frequency").notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + check("caps_amount_check", sql`${table.amountUsdCents} is null or ${table.amountUsdCents} > 0`), + ], +); + +export const capCycles = pgTable( + "cap_cycles", + { + id: uuid("id").defaultRandom().primaryKey(), + agentId: uuid("agent_id") + .notNull() + .references(() => agents.id, { onDelete: "cascade" }), + startedAt: timestamp("started_at", { withTimezone: true }).notNull(), + endedAt: timestamp("ended_at", { withTimezone: true }), + resetReason: capResetReason("reset_reason"), + }, + (table) => [ + unique("cap_cycles_id_agent_unique").on(table.id, table.agentId), + uniqueIndex("cap_cycles_one_active_per_agent") + .on(table.agentId) + .where(sql`${table.endedAt} is null`), + index("cap_cycles_agent_started_idx").on(table.agentId, table.startedAt.desc()), + check( + "cap_cycles_end_check", + sql`(${table.endedAt} is null and ${table.resetReason} is null) + or (${table.endedAt} > ${table.startedAt} and ${table.resetReason} is not null)`, + ), + ], +); diff --git a/apps/web/lib/db/leash-ledger-schema.ts b/apps/web/lib/db/leash-ledger-schema.ts new file mode 100644 index 0000000..fb7594e --- /dev/null +++ b/apps/web/lib/db/leash-ledger-schema.ts @@ -0,0 +1,77 @@ +import { sql } from "drizzle-orm"; +import { + check, + index, + jsonb, + numeric, + pgEnum, + pgTable, + timestamp, + uniqueIndex, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +import { agents, leashNetwork } from "./leash-control-schema"; + +export const leashAsset = pgEnum("leash_asset", ["USDC"]); +export const agentEventType = pgEnum("agent_event_type", ["connect", "sign", "block", "revoke"]); +export const agentEventSurface = pgEnum("agent_event_surface", [ + "agent", + "web", + "pwa", + "push_action", + "system", +]); + +export const floats = pgTable( + "floats", + { + id: uuid("id").defaultRandom().primaryKey(), + agentId: uuid("agent_id") + .notNull() + .references(() => agents.id, { onDelete: "cascade" }), + network: leashNetwork("network").notNull(), + asset: leashAsset("asset").notNull(), + tokenAddress: varchar("token_address", { length: 42 }).notNull(), + balanceAtomic: numeric("balance_atomic").notNull(), + balanceUsd: numeric("balance_usd", { precision: 38, scale: 6 }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("floats_agent_network_unique").on(table.agentId, table.network), + check( + "floats_balance_atomic_check", + sql`${table.balanceAtomic} >= 0 and ${table.balanceAtomic} = trunc(${table.balanceAtomic})`, + ), + check("floats_balance_usd_check", sql`${table.balanceUsd} >= 0`), + check( + "floats_native_usdc_check", + sql`(${table.network} = 'eip155:8453' + and lower(${table.tokenAddress}) = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913') + or (${table.network} = 'eip155:42161' + and lower(${table.tokenAddress}) = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')`, + ), + ], +); + +export const agentEvents = pgTable( + "agent_events", + { + id: uuid("id").defaultRandom().primaryKey(), + agentId: uuid("agent_id") + .notNull() + .references(() => agents.id, { onDelete: "cascade" }), + type: agentEventType("type").notNull(), + actorSurface: agentEventSurface("actor_surface").notNull(), + metadata: jsonb("metadata") + .$type>() + .default(sql`'{}'::jsonb`) + .notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("agent_events_agent_created_idx").on(table.agentId, table.createdAt.desc()), + check("agent_events_metadata_check", sql`jsonb_typeof(${table.metadata}) = 'object'`), + ], +); diff --git a/apps/web/lib/db/leash-migration-upgrade.integration.test.ts b/apps/web/lib/db/leash-migration-upgrade.integration.test.ts new file mode 100644 index 0000000..113afb9 --- /dev/null +++ b/apps/web/lib/db/leash-migration-upgrade.integration.test.ts @@ -0,0 +1,214 @@ +import { createHash, randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +import postgres from "postgres"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required for PostgreSQL integration tests"); +} + +const sql = postgres(databaseUrl, { max: 1 }); +const schemas = new Set(); +const NETWORK = "eip155:8453"; +const ASSET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const AGENT_ADDRESS = "0x1111111111111111111111111111111111111111"; +const PAY_TO = "0x2222222222222222222222222222222222222222"; +const AMOUNT_ATOMIC = "1000"; +const VALID_BEFORE = "2000000000"; + +function fingerprint(nonce: string) { + return createHash("sha256") + .update( + [NETWORK, ASSET, AGENT_ADDRESS, PAY_TO, AMOUNT_ATOMIC, "0", VALID_BEFORE, nonce].join("\0"), + ) + .digest("hex"); +} + +function newSchemaName() { + return `leash_upgrade_${randomUUID().replaceAll("-", "")}`; +} + +async function createLegacyReceiptsSchema(schema: string) { + await sql.unsafe(`create schema "${schema}"`); + schemas.add(schema); + await sql.unsafe(` + create table "${schema}"."agents" ( + "id" uuid primary key, + "agent_address" varchar(42) + ); + create table "${schema}"."receipts" ( + "id" uuid primary key, + "agent_id" uuid not null, + "status" text default 'pending' not null, + "reason" text, + "intended_network" text, + "tx_hash" varchar(66), + "settlement_response" jsonb, + "settled_at" timestamp with time zone, + "amount_atomic" numeric not null, + "asset" varchar(42) not null, + "network" text not null, + "pay_to" varchar(42) not null, + "authorization_nonce" varchar(66) not null, + "request_fingerprint" varchar(64) not null, + "authorization_valid_before" timestamp with time zone not null, + constraint "receipts_authorization_check" check ( + "authorization_nonce" ~ '^0x[0-9a-fA-F]{64}$' + and "request_fingerprint" ~ '^[0-9a-f]{64}$' + ), + constraint "receipts_state_check" check ( + ("status" = 'pending' and "reason" is null + and "intended_network" is null and "tx_hash" is null + and "settlement_response" is null and "settled_at" is null) + or ("status" = 'failed' and "reason" ~ '[^[:space:]]' + and "intended_network" is null and "tx_hash" is null + and "settlement_response" is null and "settled_at" is null) + or ("status" = 'blocked' and "reason" ~ '[^[:space:]]' + and "intended_network" is not null and "tx_hash" is null + and "settlement_response" is null and "settled_at" is null) + ) + ); + create unique index "receipts_agent_nonce_unique" + on "${schema}"."receipts" ("agent_id", "authorization_nonce"); + create unique index "receipts_agent_fingerprint_unique" + on "${schema}"."receipts" ("agent_id", "request_fingerprint"); + `); +} + +async function applyPhase6Upgrade(schema: string) { + const source = await readFile( + new URL("../../drizzle/0016_cold_riptide.sql", import.meta.url), + "utf8", + ); + const statements = source + .split("--> statement-breakpoint") + .map((statement) => statement.trim()) + .filter(Boolean); + + await sql.begin(async (transaction) => { + await transaction.unsafe(`set local search_path to "${schema}", public`); + for (const statement of statements) { + await transaction.unsafe(statement); + } + }); +} + +describe("Phase 6 receipt migration upgrade", () => { + afterEach(async () => { + for (const schema of schemas) { + await sql.unsafe(`drop schema if exists "${schema}" cascade`); + schemas.delete(schema); + } + }); + + afterAll(async () => { + await sql.end(); + }); + + it("canonicalizes 0015-compatible mixed-case nonces before tightening the check", async () => { + const schema = newSchemaName(); + const id = randomUUID(); + const agentId = randomUUID(); + const mixedCaseNonce = `0x${"aA".repeat(32)}`; + const canonicalNonce = mixedCaseNonce.toLowerCase(); + await createLegacyReceiptsSchema(schema); + + await sql.unsafe(` + insert into "${schema}"."receipts" + (id, agent_id, amount_atomic, asset, network, pay_to, + authorization_nonce, request_fingerprint, authorization_valid_before) + values ('${id}', '${agentId}', '${AMOUNT_ATOMIC}', '${ASSET}', '${NETWORK}', '${PAY_TO}', + '${mixedCaseNonce}', '${fingerprint(mixedCaseNonce)}', to_timestamp(${VALID_BEFORE})) + `); + + await applyPhase6Upgrade(schema); + + const rows = await sql.unsafe<{ authorization_nonce: string; request_fingerprint: string }[]>(` + select authorization_nonce, request_fingerprint + from "${schema}"."receipts" where id = '${id}' + `); + expect(rows).toEqual([ + { + authorization_nonce: canonicalNonce, + request_fingerprint: fingerprint(mixedCaseNonce), + }, + ]); + + await expect( + sql.unsafe(` + update "${schema}"."receipts" + set authorization_nonce = '${mixedCaseNonce}' where id = '${id}' + `), + ).rejects.toMatchObject({ code: "23514" }); + }); + + it("aborts descriptively without changing evidence when canonical nonces collide", async () => { + const schema = newSchemaName(); + const agentId = randomUUID(); + const mixedCaseNonce = `0x${"aB".repeat(32)}`; + const canonicalNonce = mixedCaseNonce.toLowerCase(); + await createLegacyReceiptsSchema(schema); + + await sql.unsafe(` + insert into "${schema}"."agents" (id, agent_address) + values ('${agentId}', '${AGENT_ADDRESS}'); + insert into "${schema}"."receipts" + (id, agent_id, amount_atomic, asset, network, pay_to, + authorization_nonce, request_fingerprint, authorization_valid_before) + values + ('${randomUUID()}', '${agentId}', '${AMOUNT_ATOMIC}', '${ASSET}', '${NETWORK}', '${PAY_TO}', + '${mixedCaseNonce}', '${fingerprint(mixedCaseNonce)}', to_timestamp(${VALID_BEFORE})), + ('${randomUUID()}', '${agentId}', '${AMOUNT_ATOMIC}', '${ASSET}', '${NETWORK}', '${PAY_TO}', + '${canonicalNonce}', '${fingerprint(canonicalNonce)}', to_timestamp(${VALID_BEFORE})) + `); + + await expect(applyPhase6Upgrade(schema)).rejects.toMatchObject({ + code: "23505", + message: expect.stringContaining("case-insensitive authorization nonce collisions"), + }); + + const rows = await sql.unsafe<{ authorization_nonce: string }[]>(` + select authorization_nonce from "${schema}"."receipts" order by authorization_nonce + `); + expect(rows.map((row) => row.authorization_nonce).sort()).toEqual( + [mixedCaseNonce, canonicalNonce].sort(), + ); + }); + + it("marks legacy failed and blocked rows whose reason was missing", async () => { + const schema = newSchemaName(); + const agentId = randomUUID(); + await createLegacyReceiptsSchema(schema); + + await sql.unsafe(` + insert into "${schema}"."receipts" + (id, agent_id, status, intended_network, amount_atomic, asset, network, pay_to, + authorization_nonce, request_fingerprint, authorization_valid_before) + values + ('${randomUUID()}', '${agentId}', 'failed', null, '${AMOUNT_ATOMIC}', '${ASSET}', + '${NETWORK}', '${PAY_TO}', '0x${"1".repeat(64)}', '${"2".repeat(64)}', + to_timestamp(${VALID_BEFORE})), + ('${randomUUID()}', '${agentId}', 'blocked', '${NETWORK}', '${AMOUNT_ATOMIC}', '${ASSET}', + '${NETWORK}', '${PAY_TO}', '0x${"3".repeat(64)}', '${"4".repeat(64)}', + to_timestamp(${VALID_BEFORE})) + `); + + await applyPhase6Upgrade(schema); + + const rows = await sql.unsafe<{ reason: string; status: string }[]>(` + select status, reason from "${schema}"."receipts" order by status + `); + expect(rows).toEqual([ + { reason: "LEGACY_REASON_MISSING", status: "blocked" }, + { reason: "LEGACY_REASON_MISSING", status: "failed" }, + ]); + await expect( + sql.unsafe(` + update "${schema}"."receipts" set reason = null where status in ('failed', 'blocked') + `), + ).rejects.toMatchObject({ code: "23514" }); + }); +}); diff --git a/apps/web/lib/db/leash-receipt-schema.ts b/apps/web/lib/db/leash-receipt-schema.ts new file mode 100644 index 0000000..7ce026c --- /dev/null +++ b/apps/web/lib/db/leash-receipt-schema.ts @@ -0,0 +1,119 @@ +import { sql } from "drizzle-orm"; +import { + type AnyPgColumn, + check, + foreignKey, + index, + jsonb, + numeric, + pgEnum, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +import { agents, capCycles, leashNetwork } from "./leash-control-schema"; + +export const receiptStatus = pgEnum("receipt_status", ["pending", "settled", "failed", "blocked"]); + +export type ReceiptOrigin = { + clientName?: string; + toolName?: string; + transport: "http" | "mcp"; +}; + +export const receipts = pgTable( + "receipts", + { + id: uuid("id").defaultRandom().primaryKey(), + agentId: uuid("agent_id") + .notNull() + .references(() => agents.id, { onDelete: "cascade" }), + cycleId: uuid("cycle_id").notNull(), + parentId: uuid("parent_id").references((): AnyPgColumn => receipts.id), + status: receiptStatus("status").default("pending").notNull(), + reason: text("reason"), + amountAtomic: numeric("amount_atomic").notNull(), + amountUsd: numeric("amount_usd", { precision: 38, scale: 6 }).notNull(), + asset: varchar("asset", { length: 42 }).notNull(), + network: leashNetwork("network").notNull(), + intendedNetwork: leashNetwork("intended_network"), + payTo: varchar("pay_to", { length: 42 }).notNull(), + authorizationNonce: varchar("authorization_nonce", { length: 66 }).notNull(), + requestFingerprint: varchar("request_fingerprint", { length: 64 }).notNull(), + authorizationValidBefore: timestamp("authorization_valid_before", { + withTimezone: true, + }).notNull(), + origin: jsonb("origin").$type(), + settlementResponse: jsonb("settlement_response").$type>(), + txHash: varchar("tx_hash", { length: 66 }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + settledAt: timestamp("settled_at", { withTimezone: true }), + }, + (table) => [ + foreignKey({ + columns: [table.cycleId, table.agentId], + foreignColumns: [capCycles.id, capCycles.agentId], + name: "receipts_cycle_agent_fk", + }), + uniqueIndex("receipts_agent_nonce_unique").on(table.agentId, table.authorizationNonce), + uniqueIndex("receipts_agent_fingerprint_unique").on(table.agentId, table.requestFingerprint), + uniqueIndex("receipts_network_tx_hash_unique") + .on(table.network, table.txHash) + .where(sql`${table.txHash} is not null`), + index("receipts_cap_gate_idx").on(table.agentId, table.cycleId, table.status), + index("receipts_agent_created_idx").on(table.agentId, table.createdAt.desc()), + check( + "receipts_amount_atomic_check", + sql`${table.amountAtomic} > 0 and ${table.amountAtomic} = trunc(${table.amountAtomic})`, + ), + check("receipts_amount_usd_check", sql`${table.amountUsd} > 0`), + check( + "receipts_pay_to_check", + sql`${table.payTo} ~ '^0x[0-9a-fA-F]{40}$' + and lower(${table.payTo}) <> '0x0000000000000000000000000000000000000000'`, + ), + check( + "receipts_native_usdc_check", + sql`(${table.network} = 'eip155:8453' + and lower(${table.asset}) = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913') + or (${table.network} = 'eip155:42161' + and lower(${table.asset}) = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')`, + ), + check( + "receipts_authorization_check", + sql`${table.authorizationNonce} ~ '^0x[0-9a-f]{64}$' + and ${table.requestFingerprint} ~ '^[0-9a-f]{64}$'`, + ), + check( + "receipts_origin_check", + sql`${table.origin} is null or (jsonb_typeof(${table.origin}) = 'object' + and ${table.origin} ? 'transport' + and ${table.origin}->>'transport' in ('mcp', 'http'))`, + ), + check( + "receipts_tx_hash_check", + sql`${table.txHash} is null or ${table.txHash} ~ '^0x[0-9a-fA-F]{64}$'`, + ), + check( + "receipts_state_check", + sql`(${table.status} = 'pending' and ${table.reason} is null + and ${table.intendedNetwork} is null and ${table.txHash} is null + and ${table.settlementResponse} is null and ${table.settledAt} is null) + or (${table.status} = 'settled' and ${table.reason} is null + and ${table.intendedNetwork} is null and ${table.txHash} is not null + and ${table.settlementResponse} is not null and ${table.settledAt} is not null) + or (${table.status} = 'failed' and ${table.reason} is not null + and ${table.reason} ~ '[^[:space:]]' and ${table.intendedNetwork} is null + and ${table.txHash} is null and ${table.settlementResponse} is null + and ${table.settledAt} is null) + or (${table.status} = 'blocked' and ${table.reason} is not null + and ${table.reason} ~ '[^[:space:]]' and ${table.intendedNetwork} is not null + and ${table.txHash} is null and ${table.settlementResponse} is null + and ${table.settledAt} is null)`, + ), + ], +); diff --git a/apps/web/lib/db/leash-schema.integration.test.ts b/apps/web/lib/db/leash-schema.integration.test.ts index 701ebe4..ca5a121 100644 --- a/apps/web/lib/db/leash-schema.integration.test.ts +++ b/apps/web/lib/db/leash-schema.integration.test.ts @@ -66,6 +66,11 @@ function insertReceipt( const blocked = status === "blocked"; const network = values.network ?? "eip155:8453"; const asset = values.asset ?? (network === "eip155:42161" ? arbitrumUsdc : baseUsdc); + const reason = Object.hasOwn(values, "reason") + ? (values.reason ?? null) + : blocked + ? "CAP_EXCEEDED" + : null; return sql` insert into receipts ( agent_id, cycle_id, status, amount_atomic, amount_usd, asset, network, @@ -76,7 +81,7 @@ function insertReceipt( ${asset}, ${network}, ${payTo}, ${values.nonce ?? auth.nonce}, ${values.fingerprint ?? auth.fingerprint}, now() + interval '5 minutes', ${sql.json({ clientName: "integration", toolName: "pay", transport: "mcp" })}, - ${values.reason ?? (blocked ? "CAP_EXCEEDED" : null)}, + ${reason}, ${blocked ? network : null}, ${settled ? `0x${"a".repeat(64)}` : null}, ${settled ? sql.json({ success: true }) : null}, ${settled ? new Date() : null} @@ -211,6 +216,24 @@ describe("Phase 6 Leash PostgreSQL schema", () => { `).rejects.toMatchObject({ code: "23514" }); }); + it("requires lowercase authorization nonces at the database boundary", async () => { + const { agentId } = await createOwnerAgent("lowercase-nonce"); + const cycleId = await createCycle(agentId); + + await expect( + insertReceipt(agentId, cycleId, { nonce: `0x${"AB".repeat(32)}` }), + ).rejects.toMatchObject({ code: "23514" }); + }); + + it.each(["failed", "blocked"])("requires a reason for %s receipts", async (status) => { + const { agentId } = await createOwnerAgent(`${status}-reason`); + const cycleId = await createCycle(agentId); + + await expect(insertReceipt(agentId, cycleId, { reason: null, status })).rejects.toMatchObject({ + code: "23514", + }); + }); + it("prevents receipts from borrowing another agent's cycle", async () => { const first = await createOwnerAgent("cycle-first"); const second = await createOwnerAgent("cycle-second"); diff --git a/apps/web/lib/db/leash-schema.ts b/apps/web/lib/db/leash-schema.ts index 2a555c2..bad1e24 100644 --- a/apps/web/lib/db/leash-schema.ts +++ b/apps/web/lib/db/leash-schema.ts @@ -1,298 +1,3 @@ -import { sql } from "drizzle-orm"; -import { - type AnyPgColumn, - check, - foreignKey, - index, - integer, - jsonb, - numeric, - pgEnum, - pgTable, - text, - timestamp, - unique, - uniqueIndex, - uuid, - varchar, -} from "drizzle-orm/pg-core"; - -import { users } from "./identity-schema"; - -export const agentStatus = pgEnum("agent_status", [ - "provisioned", - "paused", - "frozen", - "cancelled", - "nuked", -]); -export const capFrequency = pgEnum("cap_frequency", ["daily", "weekly", "monthly", "never"]); -export const capResetReason = pgEnum("cap_reset_reason", [ - "schedule", - "manual", - "frequency_change", -]); -export const receiptStatus = pgEnum("receipt_status", ["pending", "settled", "failed", "blocked"]); -export const leashNetwork = pgEnum("leash_network", ["eip155:8453", "eip155:42161"]); -export const leashAsset = pgEnum("leash_asset", ["USDC"]); -export const agentEventType = pgEnum("agent_event_type", ["connect", "sign", "block", "revoke"]); -export const agentEventSurface = pgEnum("agent_event_surface", [ - "agent", - "web", - "pwa", - "push_action", - "system", -]); - -export type ReceiptOrigin = { - clientName?: string; - toolName?: string; - transport: "http" | "mcp"; -}; - -export const agents = pgTable( - "agents", - { - id: uuid("id").defaultRandom().primaryKey(), - ownerId: uuid("owner_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - name: text("name").notNull(), - status: agentStatus("status").default("provisioned").notNull(), - signerSubject: text("signer_subject").notNull(), - agentAddress: varchar("agent_address", { length: 42 }), - clientName: text("client_name"), - clientVersion: text("client_version"), - transport: text("transport"), - connectionCount: integer("connection_count").default(0).notNull(), - firstSeenAt: timestamp("first_seen_at", { withTimezone: true }), - lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - }, - (table) => [ - index("agents_owner_id_idx").on(table.ownerId), - uniqueIndex("agents_signer_subject_unique").on(table.signerSubject), - uniqueIndex("agents_agent_address_unique") - .on(table.agentAddress) - .where(sql`${table.agentAddress} is not null`), - check("agents_name_check", sql`${table.name} ~ '[^[:space:]]'`), - check("agents_signer_subject_check", sql`${table.signerSubject} ~ '[^[:space:]]'`), - check( - "agents_address_check", - sql`${table.agentAddress} is null or (${table.agentAddress} ~ '^0x[0-9a-fA-F]{40}$' - and lower(${table.agentAddress}) <> '0x0000000000000000000000000000000000000000')`, - ), - check("agents_connection_count_check", sql`${table.connectionCount} >= 0`), - ], -); - -export const leashKeys = pgTable( - "leash_keys", - { - id: uuid("id").defaultRandom().primaryKey(), - agentId: uuid("agent_id") - .notNull() - .references(() => agents.id, { onDelete: "cascade" }), - hashedKey: varchar("hashed_key", { length: 64 }).notNull(), - prefix: text("prefix").notNull(), - last4: varchar("last4", { length: 4 }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - lastUsedAt: timestamp("last_used_at", { withTimezone: true }), - revokedAt: timestamp("revoked_at", { withTimezone: true }), - lastAuthFailureAt: timestamp("last_auth_failure_at", { withTimezone: true }), - lastAuthFailureCode: text("last_auth_failure_code"), - rotatedFromId: uuid("rotated_from_id").references((): AnyPgColumn => leashKeys.id), - }, - (table) => [ - uniqueIndex("leash_keys_hashed_key_unique").on(table.hashedKey), - uniqueIndex("leash_keys_one_active_per_agent") - .on(table.agentId) - .where(sql`${table.revokedAt} is null`), - check("leash_keys_hash_check", sql`${table.hashedKey} ~ '^[0-9a-f]{64}$'`), - check("leash_keys_prefix_check", sql`${table.prefix} = 'leash_sk_'`), - check("leash_keys_last4_check", sql`${table.last4} ~ '^[A-Za-z0-9_-]{4}$'`), - ], -); - -export const caps = pgTable( - "caps", - { - agentId: uuid("agent_id") - .primaryKey() - .references(() => agents.id, { onDelete: "cascade" }), - amountUsdCents: numeric("amount_usd_cents", { precision: 20, scale: 0 }), - frequency: capFrequency("frequency").notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), - }, - (table) => [ - check("caps_amount_check", sql`${table.amountUsdCents} is null or ${table.amountUsdCents} > 0`), - ], -); - -export const capCycles = pgTable( - "cap_cycles", - { - id: uuid("id").defaultRandom().primaryKey(), - agentId: uuid("agent_id") - .notNull() - .references(() => agents.id, { onDelete: "cascade" }), - startedAt: timestamp("started_at", { withTimezone: true }).notNull(), - endedAt: timestamp("ended_at", { withTimezone: true }), - resetReason: capResetReason("reset_reason"), - }, - (table) => [ - unique("cap_cycles_id_agent_unique").on(table.id, table.agentId), - uniqueIndex("cap_cycles_one_active_per_agent") - .on(table.agentId) - .where(sql`${table.endedAt} is null`), - index("cap_cycles_agent_started_idx").on(table.agentId, table.startedAt.desc()), - check( - "cap_cycles_end_check", - sql`(${table.endedAt} is null and ${table.resetReason} is null) - or (${table.endedAt} > ${table.startedAt} and ${table.resetReason} is not null)`, - ), - ], -); - -export const receipts = pgTable( - "receipts", - { - id: uuid("id").defaultRandom().primaryKey(), - agentId: uuid("agent_id") - .notNull() - .references(() => agents.id, { onDelete: "cascade" }), - cycleId: uuid("cycle_id").notNull(), - parentId: uuid("parent_id").references((): AnyPgColumn => receipts.id), - status: receiptStatus("status").default("pending").notNull(), - reason: text("reason"), - amountAtomic: numeric("amount_atomic").notNull(), - amountUsd: numeric("amount_usd", { precision: 38, scale: 6 }).notNull(), - asset: varchar("asset", { length: 42 }).notNull(), - network: leashNetwork("network").notNull(), - intendedNetwork: leashNetwork("intended_network"), - payTo: varchar("pay_to", { length: 42 }).notNull(), - authorizationNonce: varchar("authorization_nonce", { length: 66 }).notNull(), - requestFingerprint: varchar("request_fingerprint", { length: 64 }).notNull(), - authorizationValidBefore: timestamp("authorization_valid_before", { - withTimezone: true, - }).notNull(), - origin: jsonb("origin").$type(), - settlementResponse: jsonb("settlement_response").$type>(), - txHash: varchar("tx_hash", { length: 66 }), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - settledAt: timestamp("settled_at", { withTimezone: true }), - }, - (table) => [ - foreignKey({ - columns: [table.cycleId, table.agentId], - foreignColumns: [capCycles.id, capCycles.agentId], - name: "receipts_cycle_agent_fk", - }), - uniqueIndex("receipts_agent_nonce_unique").on(table.agentId, table.authorizationNonce), - uniqueIndex("receipts_agent_fingerprint_unique").on(table.agentId, table.requestFingerprint), - uniqueIndex("receipts_network_tx_hash_unique") - .on(table.network, table.txHash) - .where(sql`${table.txHash} is not null`), - index("receipts_cap_gate_idx").on(table.agentId, table.cycleId, table.status), - index("receipts_agent_created_idx").on(table.agentId, table.createdAt.desc()), - check( - "receipts_amount_atomic_check", - sql`${table.amountAtomic} > 0 and ${table.amountAtomic} = trunc(${table.amountAtomic})`, - ), - check("receipts_amount_usd_check", sql`${table.amountUsd} > 0`), - check( - "receipts_pay_to_check", - sql`${table.payTo} ~ '^0x[0-9a-fA-F]{40}$' - and lower(${table.payTo}) <> '0x0000000000000000000000000000000000000000'`, - ), - check( - "receipts_native_usdc_check", - sql`(${table.network} = 'eip155:8453' - and lower(${table.asset}) = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913') - or (${table.network} = 'eip155:42161' - and lower(${table.asset}) = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')`, - ), - check( - "receipts_authorization_check", - sql`${table.authorizationNonce} ~ '^0x[0-9a-fA-F]{64}$' - and ${table.requestFingerprint} ~ '^[0-9a-f]{64}$'`, - ), - check( - "receipts_origin_check", - sql`${table.origin} is null or (jsonb_typeof(${table.origin}) = 'object' - and ${table.origin} ? 'transport' - and ${table.origin}->>'transport' in ('mcp', 'http'))`, - ), - check( - "receipts_tx_hash_check", - sql`${table.txHash} is null or ${table.txHash} ~ '^0x[0-9a-fA-F]{64}$'`, - ), - check( - "receipts_state_check", - sql`(${table.status} = 'pending' and ${table.reason} is null - and ${table.intendedNetwork} is null and ${table.txHash} is null - and ${table.settlementResponse} is null and ${table.settledAt} is null) - or (${table.status} = 'settled' and ${table.reason} is null - and ${table.intendedNetwork} is null and ${table.txHash} is not null - and ${table.settlementResponse} is not null and ${table.settledAt} is not null) - or (${table.status} = 'failed' and ${table.reason} ~ '[^[:space:]]' - and ${table.intendedNetwork} is null and ${table.txHash} is null - and ${table.settlementResponse} is null and ${table.settledAt} is null) - or (${table.status} = 'blocked' and ${table.reason} ~ '[^[:space:]]' - and ${table.intendedNetwork} is not null and ${table.txHash} is null - and ${table.settlementResponse} is null and ${table.settledAt} is null)`, - ), - ], -); - -export const floats = pgTable( - "floats", - { - id: uuid("id").defaultRandom().primaryKey(), - agentId: uuid("agent_id") - .notNull() - .references(() => agents.id, { onDelete: "cascade" }), - network: leashNetwork("network").notNull(), - asset: leashAsset("asset").notNull(), - tokenAddress: varchar("token_address", { length: 42 }).notNull(), - balanceAtomic: numeric("balance_atomic").notNull(), - balanceUsd: numeric("balance_usd", { precision: 38, scale: 6 }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), - }, - (table) => [ - uniqueIndex("floats_agent_network_unique").on(table.agentId, table.network), - check( - "floats_balance_atomic_check", - sql`${table.balanceAtomic} >= 0 and ${table.balanceAtomic} = trunc(${table.balanceAtomic})`, - ), - check("floats_balance_usd_check", sql`${table.balanceUsd} >= 0`), - check( - "floats_native_usdc_check", - sql`(${table.network} = 'eip155:8453' - and lower(${table.tokenAddress}) = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913') - or (${table.network} = 'eip155:42161' - and lower(${table.tokenAddress}) = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')`, - ), - ], -); - -export const agentEvents = pgTable( - "agent_events", - { - id: uuid("id").defaultRandom().primaryKey(), - agentId: uuid("agent_id") - .notNull() - .references(() => agents.id, { onDelete: "cascade" }), - type: agentEventType("type").notNull(), - actorSurface: agentEventSurface("actor_surface").notNull(), - metadata: jsonb("metadata") - .$type>() - .default(sql`'{}'::jsonb`) - .notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - }, - (table) => [ - index("agent_events_agent_created_idx").on(table.agentId, table.createdAt.desc()), - check("agent_events_metadata_check", sql`jsonb_typeof(${table.metadata}) = 'object'`), - ], -); +export * from "./leash-control-schema"; +export * from "./leash-ledger-schema"; +export * from "./leash-receipt-schema"; diff --git a/apps/web/lib/leash/settlement-evidence.integration.test.ts b/apps/web/lib/leash/settlement-evidence.integration.test.ts index 50f2ba2..479dc6b 100644 --- a/apps/web/lib/leash/settlement-evidence.integration.test.ts +++ b/apps/web/lib/leash/settlement-evidence.integration.test.ts @@ -161,6 +161,32 @@ describe("settlement observation and on-chain proof", () => { expect(rpcMethods.at(-1)).toBe("eth_getTransactionReceipt"); }); + it("keeps a proven match when a later unrelated USDC log is present", async () => { + receipt = validReceipt(); + receipt.logs.push( + rpcLog( + encodeEventTopics({ + abi: settlementEvents, + args: { from: facilitator, to: payTo }, + eventName: "Transfer", + }), + encodeAbiParameters([{ type: "uint256" }], [BigInt(1)]), + "0x2", + ), + ); + + await expect( + verifySettlementOnchain(parseSettlementObservation(observedResult()), { + agentAddress, + amountAtomic: "25000", + authorizationNonce: nonce, + network: "eip155:8453", + payTo, + rpcUrl, + }), + ).resolves.toBe(true); + }); + it("keeps a shaped resource claim unproven without its matching nonce-use log", async () => { const withoutAuthorization = validReceipt(); withoutAuthorization.logs = withoutAuthorization.logs.slice(0, 1); diff --git a/apps/web/lib/leash/settlement-evidence.ts b/apps/web/lib/leash/settlement-evidence.ts index ea689dc..d49f5a9 100644 --- a/apps/web/lib/leash/settlement-evidence.ts +++ b/apps/web/lib/leash/settlement-evidence.ts @@ -157,13 +157,13 @@ export async function verifySettlementOnchain( if (getAddress(log.address) !== getAddress(config.token)) continue; const decoded = decodedLog(log); if (decoded?.eventName === "Transfer") { - transferred = + transferred ||= getAddress(decoded.args.from) === getAddress(expected.agentAddress) && getAddress(decoded.args.to) === getAddress(expected.payTo) && decoded.args.value === BigInt(expected.amountAtomic); } if (decoded?.eventName === "AuthorizationUsed") { - authorizationUsed = + authorizationUsed ||= getAddress(decoded.args.authorizer) === getAddress(expected.agentAddress) && decoded.args.nonce.toLowerCase() === expected.authorizationNonce.toLowerCase(); } diff --git a/apps/web/lib/leash/sign-gate.ts b/apps/web/lib/leash/sign-gate.ts new file mode 100644 index 0000000..b7e8502 --- /dev/null +++ b/apps/web/lib/leash/sign-gate.ts @@ -0,0 +1,32 @@ +import type { agents } from "../db/schema"; + +export type SignGateCode = + | "AGENT_PAUSED" + | "AGENT_FROZEN" + | "AGENT_CANCELLED" + | "AUTHORIZATION_EXPIRED" + | "CAP_CYCLE_CHANGED" + | "INVALID_LEASH_KEY" + | "LEASH_CAP_EXCEEDED" + | "LEASH_CAP_NOT_SET" + | "SIGNER_NOT_CONFIGURED" + | "SIGN_REQUEST_CONFLICT"; + +export class SignGateError extends Error { + constructor( + readonly code: SignGateCode, + readonly status: number, + ) { + super("The signing request cannot proceed."); + this.name = "SignGateError"; + } +} + +export function statusGateError(status: typeof agents.$inferSelect.status) { + if (status === "paused") return new SignGateError("AGENT_PAUSED", 423); + if (status === "frozen") return new SignGateError("AGENT_FROZEN", 423); + if (status === "cancelled" || status === "nuked") { + return new SignGateError("AGENT_CANCELLED", 423); + } + return null; +} diff --git a/apps/web/lib/leash/sign-preflight-store.integration.test.ts b/apps/web/lib/leash/sign-preflight-store.integration.test.ts new file mode 100644 index 0000000..2933585 --- /dev/null +++ b/apps/web/lib/leash/sign-preflight-store.integration.test.ts @@ -0,0 +1,182 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { createDatabase } from "../db/client"; +import { completePreSigningChecks, reserveSignRequest } from "./sign-store"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for sign preflight tests"); + +const connection = createDatabase(databaseUrl, 4); +const agentAddress = "0x2222222222222222222222222222222222222222"; +const payTo = "0x1111111111111111111111111111111111111111"; +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const nowSeconds = 1_784_271_300; + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provision(capCents = "200") { + const [owner] = await connection.client<{ id: string }[]>` + insert into users (email, magic_issuer) + values (${`${randomUUID()}@example.test`}, ${`did:ethr:${randomUUID()}`}) + returning id + `; + if (!owner) throw new Error("Expected owner"); + const [agent] = await connection.client<{ id: string }[]>` + insert into agents (owner_id, name, signer_subject, agent_address) + values (${owner.id}, 'Preflight test', ${`leash:${randomUUID()}`}, ${agentAddress}) + returning id + `; + if (!agent) throw new Error("Expected agent"); + const [key] = await connection.client<{ id: string }[]>` + insert into leash_keys (agent_id, hashed_key, prefix, last4) + values (${agent.id}, ${randomBytes(32).toString("hex")}, 'leash_sk_', 'a1B2') + returning id + `; + const [cycle] = await connection.client<{ id: string }[]>` + insert into cap_cycles (agent_id, started_at) + values (${agent.id}, now() - interval '1 minute') returning id + `; + if (!key || !cycle) throw new Error("Expected agent policy identity"); + await connection.client` + insert into caps (agent_id, amount_usd_cents, frequency) + values (${agent.id}, ${capCents}, 'daily') + `; + return { agentId: agent.id, cycleId: cycle.id, keyId: key.id }; +} + +function signBody(amount: string, validBefore = nowSeconds + 300) { + return { + amount, + asset: baseUsdc, + network: "eip155:8453", + origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, + payTo, + signerRequest: { + domain: { + chainId: 8453, + name: "USD Coin", + verifyingContract: baseUsdc, + version: "2", + }, + message: { + from: agentAddress, + nonce: `0x${randomBytes(32).toString("hex")}`, + to: payTo, + validAfter: "0", + validBefore: String(validBefore), + value: amount, + }, + primaryType: "TransferWithAuthorization", + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + }, + }; +} + +async function reserve( + identity: Awaited>, + amount: string, + validBefore?: number, +) { + const result = await reserveSignRequest(connection.db, { + ...identity, + body: signBody(amount, validBefore), + nowSeconds, + }); + if (result.kind !== "pending") throw new Error("Expected pending reservation"); + return result; +} + +async function rollCycle(identity: Awaited>) { + await connection.client` + update cap_cycles set ended_at = now(), reset_reason = 'manual' + where id = ${identity.cycleId} + `; + const [cycle] = await connection.client<{ id: string }[]>` + insert into cap_cycles (agent_id, started_at) + values (${identity.agentId}, now()) returning id + `; + if (!cycle) throw new Error("Expected replacement cycle"); + return { ...identity, cycleId: cycle.id }; +} + +function finalCheck( + identity: Awaited>, + receiptId: string, + liveBalanceAtomic = BigInt(10_000_000), + checkedAt = nowSeconds, +) { + return completePreSigningChecks(connection.db, { + ...identity, + liveBalanceAtomic, + nowSeconds: checkedAt, + receiptId, + signerAvailable: true, + }); +} + +describe("final hosted-signer policy decision", () => { + it("fails a reservation when the cap is lowered during the RPC gap", async () => { + const identity = await provision("100"); + const pending = await reserve(identity, "600000"); + await connection.client` + update caps set amount_usd_cents = 50 where agent_id = ${identity.agentId} + `; + + await expect(finalCheck(identity, pending.receiptId)).resolves.toMatchObject({ + code: "LEASH_CAP_EXCEEDED", + kind: "failed", + }); + }); + + it("fails an old-cycle reservation instead of signing it after a reset", async () => { + const identity = await provision(); + const pending = await reserve(identity, "250000"); + await rollCycle(identity); + + await expect(finalCheck(identity, pending.receiptId)).resolves.toMatchObject({ + code: "CAP_CYCLE_CHANGED", + kind: "failed", + }); + }); + + it("counts pending float reservations across cap-cycle boundaries", async () => { + const identity = await provision(); + await reserve(identity, "600000"); + const current = await rollCycle(identity); + const pending = await reserve(current, "600000"); + + await expect(finalCheck(current, pending.receiptId, BigInt(1_000_000))).resolves.toMatchObject({ + code: "FLOAT_EMPTY", + kind: "failed", + }); + }); + + it("rechecks authorization expiry immediately before signing", async () => { + const identity = await provision(); + const pending = await reserve(identity, "250000", nowSeconds + 1); + + await expect( + finalCheck(identity, pending.receiptId, undefined, nowSeconds + 2), + ).resolves.toMatchObject({ + code: "AUTHORIZATION_EXPIRED", + kind: "failed", + }); + }); +}); diff --git a/apps/web/lib/leash/sign-preflight-store.ts b/apps/web/lib/leash/sign-preflight-store.ts new file mode 100644 index 0000000..9acc644 --- /dev/null +++ b/apps/web/lib/leash/sign-preflight-store.ts @@ -0,0 +1,143 @@ +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; + +import type { Database } from "../db/client"; +import { agents, capCycles, caps, leashKeys, receipts } from "../db/schema"; +import { type SignGateCode, SignGateError, statusGateError } from "./sign-gate"; + +const ATOMIC_UNITS_PER_CENT = BigInt(10_000); +type PreflightFailure = SignGateCode | "FLOAT_EMPTY" | "FLOAT_CHECK_UNAVAILABLE"; + +function terminalReceipt(receipt: typeof receipts.$inferSelect) { + return { code: receipt.reason, kind: receipt.status, receiptId: receipt.id } as const; +} + +async function failLockedReceipt( + transaction: Parameters[0]>[0], + receipt: typeof receipts.$inferSelect, + failure: PreflightFailure, +) { + await transaction + .update(receipts) + .set({ reason: failure, status: "failed" }) + .where(and(eq(receipts.id, receipt.id), eq(receipts.status, "pending"))); + return { code: failure, kind: "failed" as const, receiptId: receipt.id }; +} + +export async function completePreSigningChecks( + db: Database, + options: { + agentId: string; + keyId: string; + liveBalanceAtomic: bigint; + nowSeconds?: number; + receiptId: string; + signerAvailable: boolean; + }, +) { + if (options.liveBalanceAtomic < BigInt(0)) throw new Error("Float balance cannot be negative"); + const checkedAt = new Date((options.nowSeconds ?? Math.floor(Date.now() / 1_000)) * 1_000); + return db.transaction(async (transaction) => { + const [agent] = await transaction + .select({ id: agents.id, status: agents.status }) + .from(agents) + .where(eq(agents.id, options.agentId)) + .for("update"); + const [key] = await transaction + .select({ id: leashKeys.id }) + .from(leashKeys) + .where( + and( + eq(leashKeys.id, options.keyId), + eq(leashKeys.agentId, options.agentId), + isNull(leashKeys.revokedAt), + ), + ); + const [receipt] = await transaction + .select() + .from(receipts) + .where(and(eq(receipts.id, options.receiptId), eq(receipts.agentId, options.agentId))) + .for("update"); + if (!agent || !key || !receipt) throw new SignGateError("INVALID_LEASH_KEY", 401); + if (receipt.status !== "pending") return terminalReceipt(receipt); + + let failure: PreflightFailure | undefined = statusGateError(agent.status)?.code; + if (!failure && receipt.authorizationValidBefore <= checkedAt) { + failure = "AUTHORIZATION_EXPIRED"; + } + + const [policy] = failure + ? [] + : await transaction + .select({ amountUsdCents: caps.amountUsdCents, cycleId: capCycles.id }) + .from(caps) + .innerJoin(capCycles, and(eq(capCycles.agentId, caps.agentId), isNull(capCycles.endedAt))) + .where(eq(caps.agentId, agent.id)) + .for("update"); + if (!failure && !policy?.amountUsdCents) failure = "LEASH_CAP_NOT_SET"; + if (!failure && policy?.cycleId !== receipt.cycleId) failure = "CAP_CYCLE_CHANGED"; + + if (!failure && policy?.amountUsdCents) { + const [usage] = await transaction + .select({ amountAtomic: sql`coalesce(sum(${receipts.amountAtomic}), 0)::text` }) + .from(receipts) + .where( + and( + eq(receipts.agentId, agent.id), + eq(receipts.cycleId, policy.cycleId), + inArray(receipts.status, ["pending", "settled"]), + ), + ); + if ( + BigInt(usage?.amountAtomic ?? "0") > + BigInt(policy.amountUsdCents) * ATOMIC_UNITS_PER_CENT + ) { + failure = "LEASH_CAP_EXCEEDED"; + } + } + + if (!failure) { + const [reserved] = await transaction + .select({ amountAtomic: sql`coalesce(sum(${receipts.amountAtomic}), 0)::text` }) + .from(receipts) + .where( + and( + eq(receipts.agentId, agent.id), + eq(receipts.network, receipt.network), + eq(receipts.status, "pending"), + ), + ); + if (BigInt(reserved?.amountAtomic ?? "0") > options.liveBalanceAtomic) { + failure = "FLOAT_EMPTY"; + } else if (!options.signerAvailable) { + failure = "SIGNER_NOT_CONFIGURED"; + } + } + if (failure) return failLockedReceipt(transaction, receipt, failure); + return { kind: "ready" as const, receiptId: receipt.id }; + }); +} + +export async function failSignRequestBeforeSigning( + db: Database, + options: { + agentId: string; + reason: "FLOAT_CHECK_UNAVAILABLE"; + receiptId: string; + }, +) { + return db.transaction(async (transaction) => { + await transaction + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.id, options.agentId)) + .for("update"); + const [receipt] = await transaction + .select() + .from(receipts) + .where(and(eq(receipts.id, options.receiptId), eq(receipts.agentId, options.agentId))) + .for("update"); + if (!receipt) throw new SignGateError("INVALID_LEASH_KEY", 401); + if (receipt.status !== "pending") return terminalReceipt(receipt); + return failLockedReceipt(transaction, receipt, options.reason); + }); +} diff --git a/apps/web/lib/leash/sign-request.test.ts b/apps/web/lib/leash/sign-request.test.ts index 07064a7..511945f 100644 --- a/apps/web/lib/leash/sign-request.test.ts +++ b/apps/web/lib/leash/sign-request.test.ts @@ -56,6 +56,55 @@ describe("remote EIP-3009 sign request validation", () => { }); }); + it("canonicalizes nonce casing before fingerprinting and returning signer data", () => { + const mixedCase = validRequest(); + mixedCase.signerRequest.message.nonce = `0x${"aB".repeat(32)}`; + const lowercase = validRequest(); + lowercase.signerRequest.message.nonce = mixedCase.signerRequest.message.nonce.toLowerCase(); + + const first = parseSignRequest(mixedCase, { agentAddress, nowSeconds: 1_784_271_300 }); + const second = parseSignRequest(lowercase, { agentAddress, nowSeconds: 1_784_271_300 }); + + expect(first.authorizationNonce).toBe(`0x${"ab".repeat(32)}`); + expect(first.signerRequest.message.nonce).toBe(`0x${"ab".repeat(32)}`); + expect(first.requestFingerprint).toBe(second.requestFingerprint); + }); + + it("redacts HTTP credentials, queries, and fragments from persisted origin telemetry", () => { + const request = validRequest(); + request.origin = { + clientName: "leash-fetch", + toolName: + "post https://receipt-user:receipt-password@example.test/v1/pay?api_key=receipt-secret#fragment-secret", + transport: "http", + }; + + const parsed = parseSignRequest(request, { agentAddress, nowSeconds: 1_784_271_300 }); + + expect(parsed.origin).toEqual({ + clientName: "leash-fetch", + toolName: "POST https://example.test/v1/pay", + transport: "http", + }); + expect(JSON.stringify(parsed.origin)).not.toMatch( + /receipt-user|receipt-password|receipt-secret|fragment-secret/, + ); + }); + + it.each([ + "POST not-an-absolute-url?api_key=receipt-secret", + "GET javascript:alert('receipt-secret')", + "SECRET https://example.test/protected", + ])("replaces malformed HTTP telemetry with a generic safe label: %s", (toolName) => { + const request = validRequest(); + request.origin = { clientName: "leash-fetch", toolName, transport: "http" }; + + const parsed = parseSignRequest(request, { agentAddress, nowSeconds: 1_784_271_300 }); + + expect(parsed.origin?.toolName).toBe("HTTP request"); + expect(JSON.stringify(parsed.origin)).not.toContain(toolName); + }); + it.each([ [ "Permit2", diff --git a/apps/web/lib/leash/sign-request.ts b/apps/web/lib/leash/sign-request.ts index 0135203..feee5e0 100644 --- a/apps/web/lib/leash/sign-request.ts +++ b/apps/web/lib/leash/sign-request.ts @@ -7,6 +7,18 @@ const ARBITRUM_NETWORK = "eip155:42161"; const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; const ARBITRUM_USDC = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"; const MAX_AUTHORIZATION_LIFETIME_SECONDS = 600; +const HTTP_METHODS = new Set([ + "CONNECT", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "TRACE", +]); +const SAFE_HTTP_LABEL = "HTTP request"; const AUTHORIZATION_TYPES = [ { name: "from", type: "address" }, { name: "to", type: "address" }, @@ -65,6 +77,23 @@ function supportedNetwork(value: unknown) { throw new InvalidSignRequestError(); } +function safeHttpToolName(value: unknown) { + if (typeof value !== "string" || value.length > 500) return SAFE_HTTP_LABEL; + const match = /^([A-Za-z]+) (\S+)$/.exec(value); + if (!match) return SAFE_HTTP_LABEL; + const method = match[1]?.toUpperCase(); + const rawUrl = match[2]; + if (!method || !rawUrl || !HTTP_METHODS.has(method)) return SAFE_HTTP_LABEL; + try { + const url = new URL(rawUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") return SAFE_HTTP_LABEL; + const safe = `${method} ${url.origin}${url.pathname}`; + return safe.length <= 500 ? safe : SAFE_HTTP_LABEL; + } catch { + return SAFE_HTTP_LABEL; + } +} + function origin(value: unknown) { if (value === undefined) return null; const parsed = exactRecord(value, ["clientName", "toolName", "transport"]); @@ -72,17 +101,28 @@ function origin(value: unknown) { typeof parsed.clientName !== "string" || parsed.clientName.length < 1 || parsed.clientName.length > 200 || + (parsed.transport !== "mcp" && parsed.transport !== "http") + ) { + throw new InvalidSignRequestError(); + } + if (parsed.transport === "http") { + return { + clientName: parsed.clientName, + toolName: safeHttpToolName(parsed.toolName), + transport: "http" as const, + }; + } + if ( typeof parsed.toolName !== "string" || parsed.toolName.length < 1 || - parsed.toolName.length > 500 || - (parsed.transport !== "mcp" && parsed.transport !== "http") + parsed.toolName.length > 500 ) { throw new InvalidSignRequestError(); } return { clientName: parsed.clientName, toolName: parsed.toolName, - transport: parsed.transport as "http" | "mcp", + transport: "mcp" as const, }; } @@ -142,7 +182,7 @@ export function parseSignRequest( "value", ]); const from = address(message.from); - const authorizationNonce = message.nonce; + const rawAuthorizationNonce = message.nonce; const validAfter = unsigned(message.validAfter); const validBefore = unsigned(message.validBefore); const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1_000); @@ -151,13 +191,14 @@ export function parseSignRequest( address(message.to) !== payTo || unsigned(message.value) !== amountAtomic || validAfter !== "0" || - typeof authorizationNonce !== "string" || - !/^0x[0-9a-fA-F]{64}$/.test(authorizationNonce) || + typeof rawAuthorizationNonce !== "string" || + !/^0x[0-9a-fA-F]{64}$/.test(rawAuthorizationNonce) || BigInt(validBefore) <= BigInt(nowSeconds) || BigInt(validBefore) > BigInt(nowSeconds + MAX_AUTHORIZATION_LIFETIME_SECONDS) ) { throw new InvalidSignRequestError(); } + const authorizationNonce = rawAuthorizationNonce.toLowerCase(); const fingerprint = [ network.network, @@ -178,6 +219,11 @@ export function parseSignRequest( origin: origin(body.origin), payTo, requestFingerprint: createHash("sha256").update(fingerprint.join("\0")).digest("hex"), - signerRequest: { domain, message, primaryType: signerRequest.primaryType, types }, + signerRequest: { + domain, + message: { ...message, nonce: authorizationNonce }, + primaryType: signerRequest.primaryType, + types, + }, }; } diff --git a/apps/web/lib/leash/sign-store.integration.test.ts b/apps/web/lib/leash/sign-store.integration.test.ts index d51ced9..3a4cc4a 100644 --- a/apps/web/lib/leash/sign-store.integration.test.ts +++ b/apps/web/lib/leash/sign-store.integration.test.ts @@ -190,6 +190,71 @@ describe("atomic hosted-signer reservation gate", () => { expect(count?.count).toBe("1"); }); + it("treats case variants of one EIP-3009 nonce as the same reservation", async () => { + const identity = await provision(); + const mixedCaseNonce = "aB".repeat(32); + const first = await reserveSignRequest(connection.db, { + ...identity, + body: signBody("250000", mixedCaseNonce), + nowSeconds, + }); + const second = await reserveSignRequest(connection.db, { + ...identity, + body: signBody("250000", mixedCaseNonce.toLowerCase()), + nowSeconds, + }); + + expect(second).toMatchObject({ kind: "pending", receiptId: first.receiptId, replayed: true }); + const rows = await connection.client<{ authorization_nonce: string }[]>` + select authorization_nonce from receipts where agent_id = ${identity.agentId} + `; + expect(rows).toEqual([{ authorization_nonce: `0x${"ab".repeat(32)}` }]); + }); + + it("replays a migrated legacy receipt by semantics without rewriting its audit fingerprint", async () => { + const identity = await provision(); + const mixedCaseNonce = "aB".repeat(32); + const body = signBody("250000", mixedCaseNonce); + const first = await reserveSignRequest(connection.db, { ...identity, body, nowSeconds }); + const legacyFingerprint = "f".repeat(64); + await connection.client` + update receipts set request_fingerprint = ${legacyFingerprint} where id = ${first.receiptId} + `; + + const replay = await reserveSignRequest(connection.db, { + ...identity, + body: signBody("250000", mixedCaseNonce.toLowerCase()), + nowSeconds, + }); + + expect(replay).toMatchObject({ kind: "pending", receiptId: first.receiptId, replayed: true }); + const [stored] = await connection.client<{ request_fingerprint: string }[]>` + select request_fingerprint from receipts where id = ${first.receiptId} + `; + expect(stored?.request_fingerprint).toBe(legacyFingerprint); + }); + + it("rejects a reused nonce when the stored payment semantics differ", async () => { + const identity = await provision(); + const nonce = randomBytes(32).toString("hex"); + await reserveSignRequest(connection.db, { + ...identity, + body: signBody("250000", nonce), + nowSeconds, + }); + + await expect( + reserveSignRequest(connection.db, { + ...identity, + body: signBody("250001", nonce), + nowSeconds, + }), + ).rejects.toMatchObject({ + code: "SIGN_REQUEST_CONFLICT", + status: 409, + } satisfies Partial); + }); + it("fails safely before signing when pending floats overcommit or the signer is blocked", async () => { const identity = await provision({ capCents: "200" }); const first = await reserveSignRequest(connection.db, { @@ -208,6 +273,7 @@ describe("atomic hosted-signer reservation gate", () => { completePreSigningChecks(connection.db, { ...identity, liveBalanceAtomic: BigInt(1_000_000), + nowSeconds, receiptId: second.receiptId, signerAvailable: true, }), @@ -216,6 +282,7 @@ describe("atomic hosted-signer reservation gate", () => { completePreSigningChecks(connection.db, { ...identity, liveBalanceAtomic: BigInt(1_000_000), + nowSeconds, receiptId: first.receiptId, signerAvailable: false, }), diff --git a/apps/web/lib/leash/sign-store.ts b/apps/web/lib/leash/sign-store.ts index ce34016..73d4e9b 100644 --- a/apps/web/lib/leash/sign-store.ts +++ b/apps/web/lib/leash/sign-store.ts @@ -2,37 +2,16 @@ import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import type { Database } from "../db/client"; import { agentEvents, agents, capCycles, caps, leashKeys, receipts } from "../db/schema"; +import { SignGateError, statusGateError } from "./sign-gate"; import { InvalidSignRequestError, parseSignRequest } from "./sign-request"; -const ATOMIC_UNITS_PER_CENT = BigInt(10_000); - -type GateCode = - | "AGENT_PAUSED" - | "AGENT_FROZEN" - | "AGENT_CANCELLED" - | "INVALID_LEASH_KEY" - | "LEASH_CAP_NOT_SET" - | "SIGNER_NOT_CONFIGURED" - | "SIGN_REQUEST_CONFLICT"; - -export class SignGateError extends Error { - constructor( - readonly code: GateCode, - readonly status: number, - ) { - super("The signing request cannot proceed."); - this.name = "SignGateError"; - } -} +export { SignGateError } from "./sign-gate"; +export { + completePreSigningChecks, + failSignRequestBeforeSigning, +} from "./sign-preflight-store"; -function statusError(status: typeof agents.$inferSelect.status) { - if (status === "paused") return new SignGateError("AGENT_PAUSED", 423); - if (status === "frozen") return new SignGateError("AGENT_FROZEN", 423); - if (status === "cancelled" || status === "nuked") { - return new SignGateError("AGENT_CANCELLED", 423); - } - return null; -} +const ATOMIC_UNITS_PER_CENT = BigInt(10_000); function amountUsd(amountAtomic: string) { const value = BigInt(amountAtomic); @@ -65,6 +44,19 @@ function pendingResult( }; } +function matchesRequestSemantics( + existing: typeof receipts.$inferSelect, + parsed: ReturnType, +) { + return ( + existing.network === parsed.network && + existing.asset.toLowerCase() === parsed.asset.toLowerCase() && + existing.payTo.toLowerCase() === parsed.payTo.toLowerCase() && + BigInt(existing.amountAtomic) === BigInt(parsed.amountAtomic) && + existing.authorizationValidBefore.getTime() === parsed.authorizationValidBefore.getTime() + ); +} + export async function reserveSignRequest( db: Database, options: { @@ -82,7 +74,7 @@ export async function reserveSignRequest( .for("update"); if (!agent) throw new SignGateError("INVALID_LEASH_KEY", 401); - const blockedStatus = statusError(agent.status); + const blockedStatus = statusGateError(agent.status); if (blockedStatus) throw blockedStatus; if (!agent.address) throw new SignGateError("SIGNER_NOT_CONFIGURED", 503); @@ -112,7 +104,7 @@ export async function reserveSignRequest( ), ); if (existing) { - if (existing.requestFingerprint !== parsed.requestFingerprint) { + if (!matchesRequestSemantics(existing, parsed)) { throw new SignGateError("SIGN_REQUEST_CONFLICT", 409); } if (existing.status === "pending") { @@ -188,71 +180,3 @@ export async function reserveSignRequest( return pendingResult(created.id, parsed, agent.address, false); }); } - -export async function completePreSigningChecks( - db: Database, - options: { - agentId: string; - keyId: string; - liveBalanceAtomic: bigint; - receiptId: string; - signerAvailable: boolean; - }, -) { - if (options.liveBalanceAtomic < BigInt(0)) throw new Error("Float balance cannot be negative"); - return db.transaction(async (transaction) => { - const [agent] = await transaction - .select({ id: agents.id, status: agents.status }) - .from(agents) - .where(eq(agents.id, options.agentId)) - .for("update"); - const [key] = await transaction - .select({ id: leashKeys.id }) - .from(leashKeys) - .where( - and( - eq(leashKeys.id, options.keyId), - eq(leashKeys.agentId, options.agentId), - isNull(leashKeys.revokedAt), - ), - ); - const [receipt] = await transaction - .select() - .from(receipts) - .where(and(eq(receipts.id, options.receiptId), eq(receipts.agentId, options.agentId))) - .for("update"); - if (!agent || !key || !receipt) throw new SignGateError("INVALID_LEASH_KEY", 401); - if (receipt.status !== "pending") { - return { code: receipt.reason, kind: receipt.status, receiptId: receipt.id } as const; - } - - const currentStatusError = statusError(agent.status); - let failure: GateCode | "FLOAT_EMPTY" | undefined = currentStatusError?.code; - if (!failure) { - const [reserved] = await transaction - .select({ amountAtomic: sql`coalesce(sum(${receipts.amountAtomic}), 0)::text` }) - .from(receipts) - .where( - and( - eq(receipts.agentId, agent.id), - eq(receipts.cycleId, receipt.cycleId), - eq(receipts.network, receipt.network), - eq(receipts.status, "pending"), - ), - ); - if (BigInt(reserved?.amountAtomic ?? "0") > options.liveBalanceAtomic) { - failure = "FLOAT_EMPTY"; - } else if (!options.signerAvailable) { - failure = "SIGNER_NOT_CONFIGURED"; - } - } - if (failure) { - await transaction - .update(receipts) - .set({ reason: failure, status: "failed" }) - .where(and(eq(receipts.id, receipt.id), eq(receipts.status, "pending"))); - return { code: failure, kind: "failed" as const, receiptId: receipt.id }; - } - return { kind: "ready" as const, receiptId: receipt.id }; - }); -} diff --git a/apps/web/package.json b/apps/web/package.json index 970c947..bc963e4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,6 +31,7 @@ "web-push": "^3.6.7" }, "devDependencies": { + "@tab/agent": "workspace:*", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e814747..7c416cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: specifier: ^3.6.7 version: 3.6.7 devDependencies: + '@tab/agent': + specifier: workspace:* + version: link:../agent '@types/node': specifier: ^22.0.0 version: 22.20.1 diff --git a/turbo.json b/turbo.json index 8132953..8c475ba 100644 --- a/turbo.json +++ b/turbo.json @@ -20,6 +20,7 @@ }, "test": { "cache": false, + "dependsOn": ["^build"], "outputs": ["coverage/**"], "passThroughEnv": ["DATABASE_URL", "PAYMENT_INTENT_SIGNING_SECRET", "SESSION_SECRET"] }, From 855ad38bcf085297f3c9ba9adfa68616fdeff9af Mon Sep 17 00:00:00 2001 From: Blockchain-Oracle Date: Fri, 17 Jul 2026 10:35:35 +0200 Subject: [PATCH 4/6] docs: record Phase 6 verification --- .../2026-07-17-phase-6-agent-mcp-proxy.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 .thoughts/verification/2026-07-17-phase-6-agent-mcp-proxy.md diff --git a/.thoughts/verification/2026-07-17-phase-6-agent-mcp-proxy.md b/.thoughts/verification/2026-07-17-phase-6-agent-mcp-proxy.md new file mode 100644 index 0000000..3a443e7 --- /dev/null +++ b/.thoughts/verification/2026-07-17-phase-6-agent-mcp-proxy.md @@ -0,0 +1,119 @@ +# Verification Audit: Phase 6 Agent MCP Proxy + +## Verdict + +**PASS — the Phase 6 MCP proxy, x402 payment wire, hosted-signer policy boundary, receipt +ledger, CLI/package, and local checkpoint pass at the approved B-03/B-04 blocked boundary.** + +Real Magic TEE signing and a funded mainnet agent payment are not included in this verdict. The +production route returns `SIGNER_NOT_CONFIGURED`, terminalizes the reservation honestly, and never +returns a fabricated signature or transaction hash. + +## Artifacts Checked + +- Phase 5 baseline: `975aa4b` +- Phase 6 implementation commits: `eab8053`, `bd59fce`, and `8d05657` +- Domain wiki and its x402, MCP, Magic, Particle, and hackathon source map +- `.thoughts/decisions/DECISIONS.md` +- Product spec and Story 3, "Agent pays an x402 resource within cap" +- Phase 6 plan goal, work list, acceptance coverage, and stop condition +- Prototype reintegration matrix F-1..F-15 and no-shipping-mock table +- Project quality profile and pinned installed package contracts +- Agent CLI/package, web signer routes, PostgreSQL schema/migrations, cross-app tests, built output, + and repository diff + +## Requirement Traceability + +| Requirement | Result | Evidence | +| --- | --- | --- | +| F-1 installable `leash-mcp` entry | Pass at workspace boundary; publication blocked | The package builds a stdio binary, parses strict environment/CLI configuration, bootstraps `/api/agent/connect`, and supports optional `--upstream`. Built-bin stdio and Streamable HTTP smoke tests and package dry-run pass. The package remains private until B-08; no unpublished name is presented as available from npm. | +| F-2 MCP pass-through proxy | Pass | The real MCP SDK server/client pair forwards list/call requests to a real Streamable HTTP upstream. The proxy starts without a signer, and free tools continue to work. | +| F-3 `paid_fetch` and fetch wrapper | Pass | The standalone MCP tool and exported wrapper use the real x402 client/scheme path, preserve request bodies, and retry the protected HTTP request through the remote signer contract. Real loopback HTTP integration tests pass. | +| F-4 dual-surface detection | Pass | Tests cover MCP tool-result payment-required data, SEP-1036 JSON-RPC `-32042`, x402 v2 response headers, and the v1 HTTP 402 body path using the installed x402 package contracts. | +| F-5 remote-signer wire | Pass at blocked production boundary | Strict EIP-3009 parsing, exact native-USDC domain/message validation, Base/Arbitrum registration, and signature recovery are tested with real viem private-key signatures. The production web route does not sign until B-03. | +| F-6 server-side cap gate | Pass | PostgreSQL reservations count settled plus pending spend. The final locked pre-signing check reloads the current cap and active cycle, rechecks expiry, and prevents concurrent reservations from crossing the cap. Blocked attempts persist as `blocked` with `LEASH_CAP_EXCEEDED`. | +| F-7 status and key gates | Pass | Paused, frozen, cancelled, and nuked agents fail before signing; the final locked check repeats status and active-key validation so revocation/status changes cannot slip through an earlier read. | +| F-8 CAIP-2 float routing | Pass | Only Base `eip155:8453` and Arbitrum `eip155:42161` native USDC are accepted. Real viem RPC tests prove `balanceOf`; the final gate counts all pending reservations across cycles on the selected float. RPC failure terminalizes the row as `failed/FLOAT_CHECK_UNAVAILABLE`. Polygon is rejected. | +| F-9 Magic TEE signing | Correctly blocked | B-03 remains open. The route returns HTTP 503 `SIGNER_NOT_CONFIGURED`, stores a failed reason, and emits no signature or fake hash. | +| F-10 result capture and receipt finalization | Pass | Agent callback data is stored only as an observation. Settlement occurs only after the server independently reads the transaction/receipt and verifies the direct native-USDC call, exact Transfer event, AuthorizationUsed event, payer, payee, amount, nonce, network, and success. HTTP 202/429/5xx retain correlation for bounded retry; only a trusted HTTP 200 `{status:"settled", verified:true}` clears it. | +| F-12 origin telemetry | Pass | MCP origin records real client/tool/transport values. HTTP telemetry strips credentials, query, and fragment on both agent and server boundaries; malformed or secret-bearing URLs are never echoed. | +| F-13 connect telemetry | Pass | `/api/agent/connect` authenticates the real Leash key and persists first/last seen, cycle count, raw client info, and the honest `Unknown client` fallback. CLI bootstrap executes before upstream initialization, so absence of downstream client info is represented honestly. | +| F-14 Leash key lifecycle | Pass | Keys are generated from cryptographic randomness, hashed at rest, shown once, rotated by revoking the prior key, and tenant/agent scoped. | +| F-15 flagship payable target | Correctly blocked | B-10 remains open. The universal tool and upstream accept explicit URLs; no fake or hardcoded payable resource was added. | +| Receipt schema and upgrade | Pass | PostgreSQL enforces canonical states, lowercase authorization nonces, unique replay evidence, owned cycles, supported networks, and nonblank terminal reasons. Migration `0016` safely normalizes legacy mixed-case nonces, aborts before mutation on case-fold collisions, preserves audit fingerprints, and truthfully marks admitted null terminal reasons as `LEGACY_REASON_MISSING`. Real 0015-shaped PostgreSQL upgrade tests pass. | +| AC-LEASH-1 | Pass for interception/wire; execution blocked | Proxy interception, dual detection, CAIP-2 routing, HTTP wrapper, persistence, and verified result processing are real. Magic signing and mainnet payment execution remain B-03/B-04 blocked as required by the Phase 6 stop condition. | +| AC-LEASH-2 | Pass for the Phase 6 signer boundary | Cap/status/key/float/expiry checks are server-side and concurrency-aware. The Tier 3 owner notification is a Phase 7 control-plane concern, not fabricated in Phase 6. | + +## Real Integration Evidence + +- A built `leash-mcp` process completed stdio and Streamable HTTP MCP smoke paths against real SDK + transports; free tools work with the honest null signer. +- Real x402 HTTP integration exercises the installed x402 client/scheme and signs EIP-3009 data + with an ephemeral viem account. Production code never receives that test key. +- A cross-application test drives the actual `LeashRemoteSigner` reporter over loopback HTTP into + the actual Next.js result route, real PostgreSQL, and a viem JSON-RPC server. It proves 202 keeps + correlation and independently verified 200 settlement clears it. +- Real PostgreSQL tests cover reservation serialization, current-cycle/cap/expiry rechecks, + cross-cycle float reservations, key/status changes, canonical replay, receipt constraints, and + the 0015-to-0016 data migration. +- Real viem RPC tests cover live USDC `balanceOf` reads and the transaction/receipt/log evidence + used to settle a pending receipt. +- The no-signer path is exercised end to end: no Magic configuration produces + `SIGNER_NOT_CONFIGURED`, a terminal failed receipt, and no fake signature or transaction hash. + +## Quality Gates + +- `pnpm install --frozen-lockfile --offline`: passed; lockfile was current. +- `pnpm lint`: passed, 446 files. +- `pnpm check:showcase`: passed with 0 fabricated-showcase hits. +- `pnpm typecheck`: passed, 6 Turbo tasks. +- `pnpm test`: passed, 6 Turbo tasks: + - agent: 12 files, 57 tests + - web: 112 files, 477 tests + - SDK: passed + - mobile: pass-with-no-tests +- `pnpm build`: passed, 4 Turbo tasks; the agent binary built and Next.js compiled/generated 36 + routes/pages including `/api/agent/connect`, `/api/agent/sign`, and `/api/agent/pay/result`. +- `pnpm --filter @tab/web db:check`: passed (`Everything's fine`). +- `git diff --check` and staged diff check: passed. +- Source hard cap: no TypeScript/TSX file under `apps/` or `packages/` exceeds 300 lines; the maximum + is exactly 300 lines. +- Independent follow-up correctness/security review: READY with no remaining blockers after all + prior HOLD findings and the 0015-to-0016 migration gap were resolved. +- Pull-request CI: pending publication; update this evidence after the hosted workflow passes. + +## Deviations From Plan + +- The plan says the client result response finalizes `pending→settled` or `pending→failed`. + Client data is not authoritative enough for a money ledger. The implementation records it as an + observation and settles only from server-fetched on-chain proof. Ambiguous client/network failure + remains pending and retries instead of being mislabeled failed. +- The plan describes the Magic signing call behind an OIDC environment guard. Because B-03 has not + established the real Express contract, Phase 6 stops one boundary earlier and always returns + `SIGNER_NOT_CONFIGURED` after every policy check. No speculative Magic request shape ships. +- F-1 names publication behavior, but B-08 is still open. The built package and pack contents are + verified locally while npm installation remains explicitly blocked. +- B-10 has not named a judged payable target. The implementation therefore exposes universal, + caller-supplied upstream/fetch URLs rather than inventing `LEASH_DEMO_TARGET_URL` content. +- Migration `0016` retains legacy request fingerprints as exact audit evidence and performs replay + matching from canonical stored payment semantics. This avoids rewriting historical evidence + while preventing case-only nonce replay conflicts. + +These are targeted plan/wiki deltas to propose; the authoritative documents were not silently +edited as a coding side effect. + +## Open Blocks and Follow-ups + +1. B-03: establish the Magic Express custom-issuer/TEE signing contract and provision the first + real agent wallet. Before enabling it, add a signing claim/lease so concurrent identical replays + cannot terminalize the same receipt between final preflight and signature release. +2. B-04: run the funded mainnet x402 payment and capture facilitator plus independent chain proof. +3. Add an expired-pending reconciler before B-03: use chain time and native USDC + `authorizationState`; never release a reservation merely because a client reported failure. +4. B-08: reserve and publish the agent package names before showing npm installation as live. +5. B-10: select and verify the flagship payable x402 target; do not substitute a fabricated + resource. +6. Before public traffic, add signer-route rate limiting/body-stream limits and pin trusted RPC + chain identity/confirmation policy. +7. Update the result-contract wording in the plan/wiki to make server-side on-chain proof, retry, + and honest pending ambiguity authoritative. From 0da32409e01658b497b48b7371470e96f311aec5 Mon Sep 17 00:00:00 2001 From: Blockchain-Oracle Date: Fri, 17 Jul 2026 10:45:37 +0200 Subject: [PATCH 5/6] fix(agent): declare CLI test runtime --- apps/agent/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/apps/agent/package.json b/apps/agent/package.json index 147888f..4ae130c 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -40,6 +40,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "tsx": "4.23.1", "typescript": "^5.8", "vitest": "^4.1.10" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c416cd..10640fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.20.1 + tsx: + specifier: 4.23.1 + version: 4.23.1 typescript: specifier: ^5.8 version: 5.9.3 From 804a0d6df677ff505d0a1bbec73a27e2235d8330 Mon Sep 17 00:00:00 2001 From: Blockchain-Oracle Date: Fri, 17 Jul 2026 10:50:22 +0200 Subject: [PATCH 6/6] docs: record hosted Phase 6 CI --- .../verification/2026-07-17-phase-6-agent-mcp-proxy.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.thoughts/verification/2026-07-17-phase-6-agent-mcp-proxy.md b/.thoughts/verification/2026-07-17-phase-6-agent-mcp-proxy.md index 3a443e7..b9a2d56 100644 --- a/.thoughts/verification/2026-07-17-phase-6-agent-mcp-proxy.md +++ b/.thoughts/verification/2026-07-17-phase-6-agent-mcp-proxy.md @@ -12,7 +12,8 @@ returns a fabricated signature or transaction hash. ## Artifacts Checked - Phase 5 baseline: `975aa4b` -- Phase 6 implementation commits: `eab8053`, `bd59fce`, and `8d05657` +- Phase 6 implementation commits: `eab8053`, `bd59fce`, and `8d05657`; clean-runner + runtime correction: `0da3240` - Domain wiki and its x402, MCP, Magic, Particle, and hackathon source map - `.thoughts/decisions/DECISIONS.md` - Product spec and Story 3, "Agent pays an x402 resource within cap" @@ -80,7 +81,9 @@ returns a fabricated signature or transaction hash. is exactly 300 lines. - Independent follow-up correctness/security review: READY with no remaining blockers after all prior HOLD findings and the 0015-to-0016 migration gap were resolved. -- Pull-request CI: pending publication; update this evidence after the hosted workflow passes. +- Pull-request GitHub Actions CI: passed on PR #3 at `0da3240` ([run 29567541704](https://github.com/Blockchain-Oracle/tab/actions/runs/29567541704); `Lint → Typecheck → Test → Build`). + The first clean-runner run exposed an undeclared `tsx` CLI-test runtime; `0da3240` declared it + directly and the rerun passed. ## Deviations From Plan