From 485e0a761c6ddf5e320198525a51716dfb81c8f3 Mon Sep 17 00:00:00 2001 From: Joel Orzet Date: Wed, 15 Apr 2026 14:17:56 -0300 Subject: [PATCH 01/41] fix: disable auto-translation on workflow editor to prevent React reconciliation crashes --- app/workflows/[workflowId]/layout.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/workflows/[workflowId]/layout.tsx b/app/workflows/[workflowId]/layout.tsx index 7cfbf2ee0..f2c1ea103 100644 --- a/app/workflows/[workflowId]/layout.tsx +++ b/app/workflows/[workflowId]/layout.tsx @@ -74,5 +74,13 @@ export async function generateMetadata({ } export default function WorkflowLayout({ children }: WorkflowLayoutProps) { - return children; + // Opt out of in-browser auto-translation (Google Translate, Edge, etc.) on + // the editor. Translators replace text nodes with wrappers, which + // breaks React's reconciler and throws NotFoundError on insertBefore / + // removeChild. Marketing / hub pages remain translatable. + return ( +
+ {children} +
+ ); } From e5a3fef7b7ea7724e96eee90446f16953c8a0b12 Mon Sep 17 00:00:00 2001 From: Joel Orzet Date: Mon, 20 Apr 2026 19:47:49 -0300 Subject: [PATCH 02/41] fix: guard wallet RPC calls against undefined result and retry transients Consolidate native and ERC20 balance fetchers onto a shared rpcCall helper with exponential backoff for HTTP 429, 5xx, network errors, and malformed gateway responses with missing result fields. The missing-result case was surfacing as BigInt(undefined) on the analytics page. --- lib/wallet/fetch-balances.ts | 359 +++++++++++++++++++---------------- 1 file changed, 192 insertions(+), 167 deletions(-) diff --git a/lib/wallet/fetch-balances.ts b/lib/wallet/fetch-balances.ts index 46fac8206..efb14dafb 100644 --- a/lib/wallet/fetch-balances.ts +++ b/lib/wallet/fetch-balances.ts @@ -87,6 +87,138 @@ function buildExplorerAddressUrl( return `${chain.explorerUrl}${path.replace("{address}", address)}`; } +/** + * Encode an ERC20 `balanceOf(address)` call payload. + */ +function encodeBalanceOfCallData(address: string): string { + const balanceOfSelector = "0x70a08231"; + const stripped = address.startsWith("0x") ? address.slice(2) : address; + const padded = stripped.toLowerCase().padStart(64, "0"); + return `${balanceOfSelector}${padded}`; +} + +/** + * Parse a hex wei string into BigInt, treating empty `"0x"` as zero. + * Caller must ensure hex is a non-empty string (rpcCall guarantees this). + */ +function hexWeiToBigInt(hex: string): bigint { + return hex === "0x" ? BIGINT_ZERO : BigInt(hex); +} + +type JsonRpcPayload = { + jsonrpc: "2.0"; + method: string; + params: unknown[]; + id: number; +}; + +/** + * RPC retry configuration. + * + * Two exponential-backoff schedules, picked by failure type: + * + * - `STANDARD`: network errors, HTTP 5xx, and malformed responses (missing + * `result` field). Short backoff because these usually clear quickly. + * - `RATE_LIMIT`: HTTP 429. Longer backoff because the server is actively + * throttling us; retrying too soon just extends the throttle. + * + * Schedule = `min(BASE_MS * 2^attempt, CAP_MS)`. + * + * With MAX_RETRIES = 3: + * - STANDARD delays: 500ms, 1s, 2s (total ~3.5s across 4 attempts) + * - RATE_LIMIT delays: 1s, 2s, 4s (total ~7s across 4 attempts) + */ +const RPC_RETRY_CONFIG = { + MAX_RETRIES: 3, + STANDARD: { + BASE_MS: 500, + CAP_MS: 3000, + }, + RATE_LIMIT: { + BASE_MS: 1000, + CAP_MS: 5000, + }, +} as const; + +type RpcFailureKind = "standard" | "rate_limit"; + +function getRpcBackoffMs(attempt: number, kind: RpcFailureKind): number { + const schedule = + kind === "rate_limit" + ? RPC_RETRY_CONFIG.RATE_LIMIT + : RPC_RETRY_CONFIG.STANDARD; + return Math.min(schedule.BASE_MS * 2 ** attempt, schedule.CAP_MS); +} + +/** + * Execute a JSON-RPC POST with retry/backoff for transient failures. + * + * Retries: HTTP 429, HTTP 5xx, network errors, and missing `result` fields + * (malformed gateway responses — the root cause behind `BigInt(undefined)`). + * Does not retry HTTP 4xx (except 429) or RPC-reported errors — those are + * deterministic and would fail again. + * + * Returns the raw `result` string (guaranteed non-empty). Callers interpret + * `"0x"` per their context via {@link hexWeiToBigInt}. + */ +async function rpcCall( + rpcUrl: string, + payload: JsonRpcPayload +): Promise { + let lastError: Error = new Error("RPC call failed"); + let lastFailureKind: RpcFailureKind = "standard"; + + for (let attempt = 0; attempt <= RPC_RETRY_CONFIG.MAX_RETRIES; attempt++) { + if (attempt > 0) { + await delay(getRpcBackoffMs(attempt - 1, lastFailureKind)); + } + + let response: Response; + try { + response = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + lastFailureKind = "standard"; + continue; + } + + if (response.status === 429) { + lastError = new Error("HTTP 429: rate limited"); + lastFailureKind = "rate_limit"; + continue; + } + + if (response.status >= 500) { + lastError = new Error(`HTTP ${response.status}: ${response.statusText}`); + lastFailureKind = "standard"; + continue; + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + if (data.error) { + throw new Error(data.error.message || "RPC error"); + } + + if (data.result === undefined || data.result === null) { + lastError = new Error("RPC returned no result"); + lastFailureKind = "standard"; + continue; + } + + return data.result; + } + + throw lastError; +} + /** * Fetch native token balance for a single chain */ @@ -95,23 +227,14 @@ export async function fetchNativeBalance( chain: ChainData ): Promise { try { - const response = await fetch(chain.defaultPrimaryRpc, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_getBalance", - params: [address, "latest"], - id: 1, - }), + const resultHex = await rpcCall(chain.defaultPrimaryRpc, { + jsonrpc: "2.0", + method: "eth_getBalance", + params: [address, "latest"], + id: 1, }); - const result = await response.json(); - if (result.error) { - throw new Error(result.error.message); - } - - const balanceWei = BigInt(result.result); + const balanceWei = hexWeiToBigInt(resultHex); return { chainId: chain.chainId, @@ -154,49 +277,17 @@ export async function fetchTokenBalance( chain: ChainData ): Promise { try { - // ERC20 balanceOf function signature - const balanceOfSelector = "0x70a08231"; - - // Encode the balanceOf call data - const addressWithoutPrefix = address.startsWith("0x") - ? address.slice(2) - : address; - const paddedAddress = addressWithoutPrefix.toLowerCase().padStart(64, "0"); - const callData = `${balanceOfSelector}${paddedAddress}`; - - const response = await fetch(chain.defaultPrimaryRpc, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_call", - params: [{ to: token.tokenAddress, data: callData }, "latest"], - id: 1, - }), + const resultHex = await rpcCall(chain.defaultPrimaryRpc, { + jsonrpc: "2.0", + method: "eth_call", + params: [ + { to: token.tokenAddress, data: encodeBalanceOfCallData(address) }, + "latest", + ], + id: 1, }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const result = await response.json(); - if (result.error) { - throw new Error(result.error.message || "RPC error"); - } - - if (!result.result || result.result === "0x") { - return { - tokenId: token.id, - chainId: token.chainId, - tokenAddress: token.tokenAddress, - symbol: token.symbol, - name: token.name, - balance: "0.000000", - loading: false, - }; - } - - const balanceWei = BigInt(result.result); + const balanceWei = hexWeiToBigInt(resultHex); return { tokenId: token.id, @@ -272,123 +363,57 @@ export function fetchAllTokenBalances( /** * Fetch balance for a single supported token with retry logic */ -export function fetchSupportedTokenBalance( +export async function fetchSupportedTokenBalance( address: string, token: SupportedToken, - chain: ChainData, - retries = 3 + chain: ChainData ): Promise { - const makeRequest = async ( - attempt: number - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Retry logic with exponential backoff requires this complexity - ): Promise => { - try { - // ERC20 balanceOf function signature - const balanceOfSelector = "0x70a08231"; - - // Encode the balanceOf call data - const addressWithoutPrefix = address.startsWith("0x") - ? address.slice(2) - : address; - const paddedAddress = addressWithoutPrefix - .toLowerCase() - .padStart(64, "0"); - const callData = `${balanceOfSelector}${paddedAddress}`; - - const response = await fetch(chain.defaultPrimaryRpc, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_call", - params: [{ to: token.tokenAddress, data: callData }, "latest"], - id: 1, - }), - }); - - // Handle rate limiting with retry - if (response.status === 429 && attempt < retries) { - const backoffMs = Math.min(1000 * 2 ** attempt, 5000); - await new Promise((resolve) => setTimeout(resolve, backoffMs)); - return makeRequest(attempt + 1); - } - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const result = await response.json(); - if (result.error) { - throw new Error(result.error.message || "RPC error"); - } - - const tokenExplorerUrl = buildExplorerAddressUrl( - chain, - token.tokenAddress - ); - - if (!result.result || result.result === "0x") { - return { - chainId: token.chainId, - tokenAddress: token.tokenAddress, - symbol: token.symbol, - name: token.name, - logoUrl: token.logoUrl, - balance: "0.000000", - loading: false, - explorerUrl: tokenExplorerUrl, - }; - } + try { + const resultHex = await rpcCall(chain.defaultPrimaryRpc, { + jsonrpc: "2.0", + method: "eth_call", + params: [ + { to: token.tokenAddress, data: encodeBalanceOfCallData(address) }, + "latest", + ], + id: 1, + }); - const balanceWei = BigInt(result.result); + const balanceWei = hexWeiToBigInt(resultHex); - return { - chainId: token.chainId, - tokenAddress: token.tokenAddress, - symbol: token.symbol, - name: token.name, - logoUrl: token.logoUrl, - balance: formatWeiToBalance(balanceWei, token.decimals), - loading: false, - explorerUrl: tokenExplorerUrl, - }; - } catch (error) { - // Retry on network errors - if ( - attempt < retries && - error instanceof Error && - !error.message.includes("HTTP 4") - ) { - const backoffMs = Math.min(500 * 2 ** attempt, 3000); - await new Promise((resolve) => setTimeout(resolve, backoffMs)); - return makeRequest(attempt + 1); + return { + chainId: token.chainId, + tokenAddress: token.tokenAddress, + symbol: token.symbol, + name: token.name, + logoUrl: token.logoUrl, + balance: formatWeiToBalance(balanceWei, token.decimals), + loading: false, + explorerUrl: buildExplorerAddressUrl(chain, token.tokenAddress), + }; + } catch (error) { + logUserError( + ErrorCategory.NETWORK_RPC, + `Failed to fetch balance for ${token.symbol}:`, + error, + { + chain_id: token.chainId.toString(), + token_symbol: token.symbol, + token_address: token.tokenAddress, } - - logUserError( - ErrorCategory.NETWORK_RPC, - `Failed to fetch balance for ${token.symbol}:`, - error, - { - chain_id: token.chainId.toString(), - token_symbol: token.symbol, - token_address: token.tokenAddress, - } - ); - return { - chainId: token.chainId, - tokenAddress: token.tokenAddress, - symbol: token.symbol, - name: token.name, - logoUrl: token.logoUrl, - balance: "0", - loading: false, - error: error instanceof Error ? error.message : "Failed to fetch", - explorerUrl: buildExplorerAddressUrl(chain, token.tokenAddress), - }; - } - }; - - return makeRequest(0); + ); + return { + chainId: token.chainId, + tokenAddress: token.tokenAddress, + symbol: token.symbol, + name: token.name, + logoUrl: token.logoUrl, + balance: "0", + loading: false, + error: error instanceof Error ? error.message : "Failed to fetch", + explorerUrl: buildExplorerAddressUrl(chain, token.tokenAddress), + }; + } } /** From 6f77e7ad64f648172d94d26b95f93ca995984725 Mon Sep 17 00:00:00 2001 From: Joel Orzet Date: Mon, 20 Apr 2026 19:57:09 -0300 Subject: [PATCH 03/41] refactor: extract wallet RPC client with jitter, breadcrumbs, and address validation Split rpcCall and helpers out of fetch-balances.ts into lib/wallet/rpc.ts so the retry logic is unit-testable in isolation. Add randomized jitter (up to 30%) to the backoff to avoid lockstep retries, and clamp the absolute maximum delay to 5s regardless of schedule. Validate EVM addresses before encoding balanceOf call data so an oversized input cannot silently produce wrong call data via padStart. Emit a Sentry breadcrumb on every retry attempt so the retry history is attached to any captured exception. Add lib/wallet/rpc.test.ts covering encodeBalanceOfCallData validation, hexWeiToBigInt edge cases, getRpcBackoffMs jitter and cap behavior, and all rpcCall retry/throw branches. --- lib/wallet/fetch-balances.ts | 133 +--------------- lib/wallet/rpc.test.ts | 290 +++++++++++++++++++++++++++++++++++ lib/wallet/rpc.ts | 183 ++++++++++++++++++++++ 3 files changed, 474 insertions(+), 132 deletions(-) create mode 100644 lib/wallet/rpc.test.ts create mode 100644 lib/wallet/rpc.ts diff --git a/lib/wallet/fetch-balances.ts b/lib/wallet/fetch-balances.ts index efb14dafb..422775ebe 100644 --- a/lib/wallet/fetch-balances.ts +++ b/lib/wallet/fetch-balances.ts @@ -3,6 +3,7 @@ */ import { ErrorCategory, logUserError } from "@/lib/logging"; +import { encodeBalanceOfCallData, hexWeiToBigInt, rpcCall } from "./rpc"; import type { ChainBalance, ChainData, @@ -87,138 +88,6 @@ function buildExplorerAddressUrl( return `${chain.explorerUrl}${path.replace("{address}", address)}`; } -/** - * Encode an ERC20 `balanceOf(address)` call payload. - */ -function encodeBalanceOfCallData(address: string): string { - const balanceOfSelector = "0x70a08231"; - const stripped = address.startsWith("0x") ? address.slice(2) : address; - const padded = stripped.toLowerCase().padStart(64, "0"); - return `${balanceOfSelector}${padded}`; -} - -/** - * Parse a hex wei string into BigInt, treating empty `"0x"` as zero. - * Caller must ensure hex is a non-empty string (rpcCall guarantees this). - */ -function hexWeiToBigInt(hex: string): bigint { - return hex === "0x" ? BIGINT_ZERO : BigInt(hex); -} - -type JsonRpcPayload = { - jsonrpc: "2.0"; - method: string; - params: unknown[]; - id: number; -}; - -/** - * RPC retry configuration. - * - * Two exponential-backoff schedules, picked by failure type: - * - * - `STANDARD`: network errors, HTTP 5xx, and malformed responses (missing - * `result` field). Short backoff because these usually clear quickly. - * - `RATE_LIMIT`: HTTP 429. Longer backoff because the server is actively - * throttling us; retrying too soon just extends the throttle. - * - * Schedule = `min(BASE_MS * 2^attempt, CAP_MS)`. - * - * With MAX_RETRIES = 3: - * - STANDARD delays: 500ms, 1s, 2s (total ~3.5s across 4 attempts) - * - RATE_LIMIT delays: 1s, 2s, 4s (total ~7s across 4 attempts) - */ -const RPC_RETRY_CONFIG = { - MAX_RETRIES: 3, - STANDARD: { - BASE_MS: 500, - CAP_MS: 3000, - }, - RATE_LIMIT: { - BASE_MS: 1000, - CAP_MS: 5000, - }, -} as const; - -type RpcFailureKind = "standard" | "rate_limit"; - -function getRpcBackoffMs(attempt: number, kind: RpcFailureKind): number { - const schedule = - kind === "rate_limit" - ? RPC_RETRY_CONFIG.RATE_LIMIT - : RPC_RETRY_CONFIG.STANDARD; - return Math.min(schedule.BASE_MS * 2 ** attempt, schedule.CAP_MS); -} - -/** - * Execute a JSON-RPC POST with retry/backoff for transient failures. - * - * Retries: HTTP 429, HTTP 5xx, network errors, and missing `result` fields - * (malformed gateway responses — the root cause behind `BigInt(undefined)`). - * Does not retry HTTP 4xx (except 429) or RPC-reported errors — those are - * deterministic and would fail again. - * - * Returns the raw `result` string (guaranteed non-empty). Callers interpret - * `"0x"` per their context via {@link hexWeiToBigInt}. - */ -async function rpcCall( - rpcUrl: string, - payload: JsonRpcPayload -): Promise { - let lastError: Error = new Error("RPC call failed"); - let lastFailureKind: RpcFailureKind = "standard"; - - for (let attempt = 0; attempt <= RPC_RETRY_CONFIG.MAX_RETRIES; attempt++) { - if (attempt > 0) { - await delay(getRpcBackoffMs(attempt - 1, lastFailureKind)); - } - - let response: Response; - try { - response = await fetch(rpcUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - lastFailureKind = "standard"; - continue; - } - - if (response.status === 429) { - lastError = new Error("HTTP 429: rate limited"); - lastFailureKind = "rate_limit"; - continue; - } - - if (response.status >= 500) { - lastError = new Error(`HTTP ${response.status}: ${response.statusText}`); - lastFailureKind = "standard"; - continue; - } - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - if (data.error) { - throw new Error(data.error.message || "RPC error"); - } - - if (data.result === undefined || data.result === null) { - lastError = new Error("RPC returned no result"); - lastFailureKind = "standard"; - continue; - } - - return data.result; - } - - throw lastError; -} - /** * Fetch native token balance for a single chain */ diff --git a/lib/wallet/rpc.test.ts b/lib/wallet/rpc.test.ts new file mode 100644 index 000000000..88eddf114 --- /dev/null +++ b/lib/wallet/rpc.test.ts @@ -0,0 +1,290 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const addBreadcrumbMock = vi.fn(); +vi.mock("@sentry/nextjs", () => ({ + addBreadcrumb: (...args: unknown[]) => addBreadcrumbMock(...args), +})); + +import { + encodeBalanceOfCallData, + getRpcBackoffMs, + hexWeiToBigInt, + type JsonRpcPayload, + RPC_RETRY_CONFIG, + rpcCall, +} from "./rpc"; + +const VALID_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; +const TEST_RPC_URL = "https://rpc.example.test"; +const TEST_PAYLOAD: JsonRpcPayload = { + jsonrpc: "2.0", + method: "eth_getBalance", + params: [VALID_ADDRESS, "latest"], + id: 1, +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function plainResponse(status: number, statusText = ""): Response { + return new Response(null, { status, statusText }); +} + +describe("encodeBalanceOfCallData", () => { + it("encodes a 0x-prefixed address", () => { + const data = encodeBalanceOfCallData(VALID_ADDRESS); + expect(data).toBe( + "0x70a08231000000000000000000000000" + + "1234567890abcdef1234567890abcdef12345678" + ); + }); + + it("encodes an unprefixed address", () => { + const data = encodeBalanceOfCallData(VALID_ADDRESS.slice(2)); + expect(data).toBe( + "0x70a08231000000000000000000000000" + + "1234567890abcdef1234567890abcdef12345678" + ); + }); + + it("lowercases mixed-case input", () => { + const data = encodeBalanceOfCallData( + "0x1234567890ABCDEF1234567890abcdef12345678" + ); + expect(data).toContain("1234567890abcdef1234567890abcdef12345678"); + }); + + it("throws on too-short input", () => { + expect(() => encodeBalanceOfCallData("0x1234")).toThrow( + /Invalid EVM address/ + ); + }); + + it("throws on too-long input", () => { + expect(() => + encodeBalanceOfCallData(`${VALID_ADDRESS}deadbeef`) + ).toThrow(/Invalid EVM address/); + }); + + it("throws on non-hex characters", () => { + expect(() => + encodeBalanceOfCallData("0xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz") + ).toThrow(/Invalid EVM address/); + }); +}); + +describe("hexWeiToBigInt", () => { + it('treats "0x" as zero', () => { + expect(hexWeiToBigInt("0x")).toBe(BigInt(0)); + }); + + it('parses "0x0" as zero', () => { + expect(hexWeiToBigInt("0x0")).toBe(BigInt(0)); + }); + + it("parses a non-zero hex value", () => { + expect(hexWeiToBigInt("0x1bc16d674ec80000")).toBe( + BigInt("2000000000000000000") + ); + }); +}); + +describe("getRpcBackoffMs", () => { + beforeEach(() => { + vi.spyOn(Math, "random").mockReturnValue(0); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns the base delay for standard attempt 0 with no jitter", () => { + expect(getRpcBackoffMs(0, "standard")).toBe( + RPC_RETRY_CONFIG.STANDARD.BASE_MS + ); + }); + + it("doubles the base delay for standard attempt 1", () => { + expect(getRpcBackoffMs(1, "standard")).toBe( + RPC_RETRY_CONFIG.STANDARD.BASE_MS * 2 + ); + }); + + it("caps standard backoff at CAP_MS", () => { + expect(getRpcBackoffMs(20, "standard")).toBeLessThanOrEqual( + RPC_RETRY_CONFIG.STANDARD.CAP_MS + ); + }); + + it("uses longer base for rate_limit", () => { + expect(getRpcBackoffMs(0, "rate_limit")).toBe( + RPC_RETRY_CONFIG.RATE_LIMIT.BASE_MS + ); + }); + + it("never exceeds ABSOLUTE_MAX_BACKOFF_MS even with maximum jitter", () => { + vi.spyOn(Math, "random").mockReturnValue(0.999999); + for (let attempt = 0; attempt < 10; attempt++) { + expect(getRpcBackoffMs(attempt, "standard")).toBeLessThanOrEqual( + RPC_RETRY_CONFIG.ABSOLUTE_MAX_BACKOFF_MS + ); + expect(getRpcBackoffMs(attempt, "rate_limit")).toBeLessThanOrEqual( + RPC_RETRY_CONFIG.ABSOLUTE_MAX_BACKOFF_MS + ); + } + }); + + it("adds jitter proportional to the base delay", () => { + vi.spyOn(Math, "random").mockReturnValue(0.5); + const base = RPC_RETRY_CONFIG.STANDARD.BASE_MS; + const expectedJitter = 0.5 * base * RPC_RETRY_CONFIG.JITTER_FACTOR; + expect(getRpcBackoffMs(0, "standard")).toBeCloseTo(base + expectedJitter); + }); +}); + +describe("rpcCall", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + vi.useFakeTimers(); + addBreadcrumbMock.mockClear(); + fetchMock.mockReset(); + vi.spyOn(Math, "random").mockReturnValue(0); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + async function runWithTimers(promise: Promise): Promise { + const settled = promise.then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }) + ); + await vi.runAllTimersAsync(); + const outcome = await settled; + if (outcome.ok) { + return outcome.value; + } + throw outcome.error; + } + + it("returns the result on first success", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ jsonrpc: "2.0", id: 1, result: "0x1234" }) + ); + + const result = await runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)); + + expect(result).toBe("0x1234"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(addBreadcrumbMock).not.toHaveBeenCalled(); + }); + + it("retries on 429 then succeeds", async () => { + fetchMock + .mockResolvedValueOnce(plainResponse(429, "Too Many Requests")) + .mockResolvedValueOnce( + jsonResponse({ jsonrpc: "2.0", id: 1, result: "0xdead" }) + ); + + const result = await runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)); + + expect(result).toBe("0xdead"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(addBreadcrumbMock).toHaveBeenCalledTimes(1); + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + category: "rpc.retry", + data: expect.objectContaining({ kind: "rate_limit", attempt: 1 }), + }) + ); + }); + + it("retries on 5xx then succeeds", async () => { + fetchMock + .mockResolvedValueOnce(plainResponse(502, "Bad Gateway")) + .mockResolvedValueOnce( + jsonResponse({ jsonrpc: "2.0", id: 1, result: "0x1" }) + ); + + const result = await runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)); + + expect(result).toBe("0x1"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ kind: "standard" }), + }) + ); + }); + + it("retries on network error then succeeds", async () => { + fetchMock + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValueOnce( + jsonResponse({ jsonrpc: "2.0", id: 1, result: "0x2" }) + ); + + const result = await runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)); + + expect(result).toBe("0x2"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("retries when result is missing then succeeds", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ jsonrpc: "2.0", id: 1 })) + .mockResolvedValueOnce( + jsonResponse({ jsonrpc: "2.0", id: 1, result: "0x3" }) + ); + + const result = await runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)); + + expect(result).toBe("0x3"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("throws immediately on RPC-reported error without retrying", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + jsonrpc: "2.0", + id: 1, + error: { code: -32_000, message: "execution reverted" }, + }) + ); + + await expect( + runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)) + ).rejects.toThrow(/execution reverted/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws immediately on non-429 4xx without retrying", async () => { + fetchMock.mockResolvedValueOnce(plainResponse(404, "Not Found")); + + await expect( + runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)) + ).rejects.toThrow(/HTTP 404/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws the last error after exhausting retries", async () => { + fetchMock.mockResolvedValue(plainResponse(429, "Too Many Requests")); + + await expect( + runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)) + ).rejects.toThrow(/HTTP 429/); + expect(fetchMock).toHaveBeenCalledTimes(RPC_RETRY_CONFIG.MAX_RETRIES + 1); + expect(addBreadcrumbMock).toHaveBeenCalledTimes( + RPC_RETRY_CONFIG.MAX_RETRIES + ); + }); +}); diff --git a/lib/wallet/rpc.ts b/lib/wallet/rpc.ts new file mode 100644 index 000000000..11c7c5939 --- /dev/null +++ b/lib/wallet/rpc.ts @@ -0,0 +1,183 @@ +/** + * Shared JSON-RPC client for wallet balance fetches. + * + * Consolidates retry/backoff semantics and payload encoding used by the + * native and ERC20 balance fetchers. Split into its own module so the + * retry logic can be unit-tested without pulling the balance-formatting + * machinery. + */ + +import { addBreadcrumb } from "@sentry/nextjs"; + +const BIGINT_ZERO = BigInt(0); +const EVM_ADDRESS_REGEX = /^(0x)?[0-9a-fA-F]{40}$/; +const ERC20_BALANCE_OF_SELECTOR = "0x70a08231"; +const ERC20_ADDRESS_PADDING = 64; + +export type RpcFailureKind = "standard" | "rate_limit"; + +export type JsonRpcPayload = { + jsonrpc: "2.0"; + method: string; + params: unknown[]; + id: number; +}; + +/** + * RPC retry configuration. + * + * Two exponential-backoff schedules with jitter, picked by failure type: + * + * - `STANDARD`: network errors, HTTP 5xx, and malformed responses (missing + * `result` field). Short backoff because these usually clear quickly. + * - `RATE_LIMIT`: HTTP 429. Longer backoff because the server is actively + * throttling us; retrying too soon just extends the throttle. + * + * Each delay = `min((BASE_MS * 2^attempt) + jitter, ABSOLUTE_MAX_BACKOFF_MS)` + * where `jitter = random() * base * JITTER_FACTOR`. + * + * With MAX_RETRIES = 3 and JITTER_FACTOR = 0.3, each individual delay is + * capped at ABSOLUTE_MAX_BACKOFF_MS = 5s regardless of schedule. + */ +export const RPC_RETRY_CONFIG = { + MAX_RETRIES: 3, + JITTER_FACTOR: 0.3, + ABSOLUTE_MAX_BACKOFF_MS: 5000, + STANDARD: { + BASE_MS: 500, + CAP_MS: 3000, + }, + RATE_LIMIT: { + BASE_MS: 1000, + CAP_MS: 5000, + }, +} as const; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Compute the backoff delay for a given retry attempt and failure kind. + * Adds randomized jitter to avoid lockstep retries across concurrent callers. + * Guaranteed to return at most `RPC_RETRY_CONFIG.ABSOLUTE_MAX_BACKOFF_MS`. + */ +export function getRpcBackoffMs( + attempt: number, + kind: RpcFailureKind +): number { + const schedule = + kind === "rate_limit" + ? RPC_RETRY_CONFIG.RATE_LIMIT + : RPC_RETRY_CONFIG.STANDARD; + const base = Math.min(schedule.BASE_MS * 2 ** attempt, schedule.CAP_MS); + const jitter = Math.random() * base * RPC_RETRY_CONFIG.JITTER_FACTOR; + return Math.min(base + jitter, RPC_RETRY_CONFIG.ABSOLUTE_MAX_BACKOFF_MS); +} + +/** + * Encode an ERC20 `balanceOf(address)` call payload. + * Validates the address is a well-formed 20-byte hex string to prevent + * silent mis-encoding from over-long input slipping past `padStart`. + */ +export function encodeBalanceOfCallData(address: string): string { + if (!EVM_ADDRESS_REGEX.test(address)) { + throw new Error(`Invalid EVM address: ${address}`); + } + const stripped = address.startsWith("0x") ? address.slice(2) : address; + const padded = stripped.toLowerCase().padStart(ERC20_ADDRESS_PADDING, "0"); + return `${ERC20_BALANCE_OF_SELECTOR}${padded}`; +} + +/** + * Parse a hex wei string into BigInt, treating empty `"0x"` as zero. + * `rpcCall` guarantees the input is a non-empty string. + */ +export function hexWeiToBigInt(hex: string): bigint { + return hex === "0x" ? BIGINT_ZERO : BigInt(hex); +} + +/** + * Execute a JSON-RPC POST with retry/backoff for transient failures. + * + * Retries: HTTP 429, HTTP 5xx, network errors, and missing `result` fields + * (malformed gateway responses — the root cause behind `BigInt(undefined)`). + * Does not retry HTTP 4xx (except 429) or RPC-reported errors — those are + * deterministic and would fail again. + * + * Each retry adds a Sentry breadcrumb so the retry history is attached to + * any error eventually captured on the same scope. + * + * Returns the raw `result` string (guaranteed non-empty). Callers interpret + * `"0x"` per their context via {@link hexWeiToBigInt}. + */ +export async function rpcCall( + rpcUrl: string, + payload: JsonRpcPayload +): Promise { + let lastError: Error = new Error("RPC call failed"); + let lastFailureKind: RpcFailureKind = "standard"; + + for (let attempt = 0; attempt <= RPC_RETRY_CONFIG.MAX_RETRIES; attempt++) { + if (attempt > 0) { + const backoffMs = getRpcBackoffMs(attempt - 1, lastFailureKind); + addBreadcrumb({ + category: "rpc.retry", + level: "info", + message: `Retrying RPC after ${lastFailureKind} failure: ${lastError.message}`, + data: { + url: rpcUrl, + method: payload.method, + attempt, + backoffMs: Math.round(backoffMs), + kind: lastFailureKind, + }, + }); + await delay(backoffMs); + } + + let response: Response; + try { + response = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + lastFailureKind = "standard"; + continue; + } + + if (response.status === 429) { + lastError = new Error("HTTP 429: rate limited"); + lastFailureKind = "rate_limit"; + continue; + } + + if (response.status >= 500) { + lastError = new Error(`HTTP ${response.status}: ${response.statusText}`); + lastFailureKind = "standard"; + continue; + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + if (data.error) { + throw new Error(data.error.message || "RPC error"); + } + + if (data.result === undefined || data.result === null) { + lastError = new Error("RPC returned no result"); + lastFailureKind = "standard"; + continue; + } + + return data.result; + } + + throw lastError; +} From cc0f88ec744a2c076dc83aa83391344984dfa6d0 Mon Sep 17 00:00:00 2001 From: Joel Orzet Date: Mon, 20 Apr 2026 20:49:29 -0300 Subject: [PATCH 04/41] feat: fail over wallet balance fetches to secondary RPC when primary fails Wire the chain-level default_fallback_rpc through ChainData so the wallet balance fetchers can fail over when the primary RPC is throttled or down. Add rpcCallWithFailover that walks the URL list in order with a reduced per-URL retry budget so the handoff is fast. Emit rpc.failover breadcrumbs for every hop. The user now sees an error only when every configured RPC is exhausted, not when the primary alone gets a 429. --- lib/wallet/fetch-balances.ts | 22 +++++-- lib/wallet/rpc.test.ts | 121 +++++++++++++++++++++++++++++++++++ lib/wallet/rpc.ts | 60 +++++++++++++++-- lib/wallet/types.ts | 1 + 4 files changed, 196 insertions(+), 8 deletions(-) diff --git a/lib/wallet/fetch-balances.ts b/lib/wallet/fetch-balances.ts index 422775ebe..8c9fbf342 100644 --- a/lib/wallet/fetch-balances.ts +++ b/lib/wallet/fetch-balances.ts @@ -3,7 +3,11 @@ */ import { ErrorCategory, logUserError } from "@/lib/logging"; -import { encodeBalanceOfCallData, hexWeiToBigInt, rpcCall } from "./rpc"; +import { + encodeBalanceOfCallData, + hexWeiToBigInt, + rpcCallWithFailover, +} from "./rpc"; import type { ChainBalance, ChainData, @@ -88,6 +92,16 @@ function buildExplorerAddressUrl( return `${chain.explorerUrl}${path.replace("{address}", address)}`; } +/** + * Collect the ordered list of RPC URLs to attempt for a chain: primary + * first, fallback second when configured. + */ +function getChainRpcUrls(chain: ChainData): string[] { + return chain.defaultFallbackRpc + ? [chain.defaultPrimaryRpc, chain.defaultFallbackRpc] + : [chain.defaultPrimaryRpc]; +} + /** * Fetch native token balance for a single chain */ @@ -96,7 +110,7 @@ export async function fetchNativeBalance( chain: ChainData ): Promise { try { - const resultHex = await rpcCall(chain.defaultPrimaryRpc, { + const resultHex = await rpcCallWithFailover(getChainRpcUrls(chain), { jsonrpc: "2.0", method: "eth_getBalance", params: [address, "latest"], @@ -146,7 +160,7 @@ export async function fetchTokenBalance( chain: ChainData ): Promise { try { - const resultHex = await rpcCall(chain.defaultPrimaryRpc, { + const resultHex = await rpcCallWithFailover(getChainRpcUrls(chain), { jsonrpc: "2.0", method: "eth_call", params: [ @@ -238,7 +252,7 @@ export async function fetchSupportedTokenBalance( chain: ChainData ): Promise { try { - const resultHex = await rpcCall(chain.defaultPrimaryRpc, { + const resultHex = await rpcCallWithFailover(getChainRpcUrls(chain), { jsonrpc: "2.0", method: "eth_call", params: [ diff --git a/lib/wallet/rpc.test.ts b/lib/wallet/rpc.test.ts index 88eddf114..8f5e8ffe5 100644 --- a/lib/wallet/rpc.test.ts +++ b/lib/wallet/rpc.test.ts @@ -12,6 +12,7 @@ import { type JsonRpcPayload, RPC_RETRY_CONFIG, rpcCall, + rpcCallWithFailover, } from "./rpc"; const VALID_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; @@ -287,4 +288,124 @@ describe("rpcCall", () => { RPC_RETRY_CONFIG.MAX_RETRIES ); }); + + it("honors the custom maxRetries argument", async () => { + fetchMock.mockResolvedValue(plainResponse(429, "Too Many Requests")); + + await expect( + runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD, 1)) + ).rejects.toThrow(/HTTP 429/); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe("rpcCallWithFailover", () => { + const fetchMock = vi.fn(); + const PRIMARY_URL = "https://primary.rpc.test"; + const FALLBACK_URL = "https://fallback.rpc.test"; + + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + vi.useFakeTimers(); + addBreadcrumbMock.mockClear(); + fetchMock.mockReset(); + vi.spyOn(Math, "random").mockReturnValue(0); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + async function runWithTimers(promise: Promise): Promise { + const settled = promise.then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }) + ); + await vi.runAllTimersAsync(); + const outcome = await settled; + if (outcome.ok) { + return outcome.value; + } + throw outcome.error; + } + + it("returns the primary result when primary succeeds", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ jsonrpc: "2.0", id: 1, result: "0xprimary" }) + ); + + const result = await runWithTimers( + rpcCallWithFailover([PRIMARY_URL, FALLBACK_URL], TEST_PAYLOAD) + ); + + expect(result).toBe("0xprimary"); + expect(fetchMock).toHaveBeenCalledWith( + PRIMARY_URL, + expect.anything() + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("fails over to the fallback URL when primary is exhausted", async () => { + fetchMock.mockImplementation((url: string) => { + if (url === PRIMARY_URL) { + return Promise.resolve(plainResponse(429, "Too Many Requests")); + } + return Promise.resolve( + jsonResponse({ jsonrpc: "2.0", id: 1, result: "0xfallback" }) + ); + }); + + const result = await runWithTimers( + rpcCallWithFailover([PRIMARY_URL, FALLBACK_URL], TEST_PAYLOAD) + ); + + expect(result).toBe("0xfallback"); + // Primary uses the reduced retry budget (1 retry => 2 attempts). + const primaryAttempts = fetchMock.mock.calls.filter( + ([url]) => url === PRIMARY_URL + ).length; + expect(primaryAttempts).toBe( + RPC_RETRY_CONFIG.RETRIES_PER_URL_WITH_FAILOVER + 1 + ); + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + category: "rpc.failover", + data: expect.objectContaining({ + failedUrl: PRIMARY_URL, + nextUrl: FALLBACK_URL, + }), + }) + ); + }); + + it("throws the last error when every URL is exhausted", async () => { + fetchMock.mockResolvedValue(plainResponse(429, "Too Many Requests")); + + await expect( + runWithTimers( + rpcCallWithFailover([PRIMARY_URL, FALLBACK_URL], TEST_PAYLOAD) + ) + ).rejects.toThrow(/HTTP 429/); + }); + + it("does not emit a failover breadcrumb when there is only one URL", async () => { + fetchMock.mockResolvedValue(plainResponse(429, "Too Many Requests")); + + await expect( + runWithTimers(rpcCallWithFailover([PRIMARY_URL], TEST_PAYLOAD)) + ).rejects.toThrow(/HTTP 429/); + const failoverBreadcrumbs = addBreadcrumbMock.mock.calls.filter( + (args) => (args[0] as { category?: string }).category === "rpc.failover" + ); + expect(failoverBreadcrumbs).toHaveLength(0); + }); + + it("rejects an empty URL list", async () => { + await expect( + runWithTimers(rpcCallWithFailover([], TEST_PAYLOAD)) + ).rejects.toThrow(/at least one URL/); + }); }); diff --git a/lib/wallet/rpc.ts b/lib/wallet/rpc.ts index 11c7c5939..42957ab57 100644 --- a/lib/wallet/rpc.ts +++ b/lib/wallet/rpc.ts @@ -36,11 +36,13 @@ export type JsonRpcPayload = { * Each delay = `min((BASE_MS * 2^attempt) + jitter, ABSOLUTE_MAX_BACKOFF_MS)` * where `jitter = random() * base * JITTER_FACTOR`. * - * With MAX_RETRIES = 3 and JITTER_FACTOR = 0.3, each individual delay is - * capped at ABSOLUTE_MAX_BACKOFF_MS = 5s regardless of schedule. + * `RETRIES_PER_URL_WITH_FAILOVER` applies when `rpcCallWithFailover` has a + * fallback URL available — retry fewer times per URL so we hand off to the + * fallback sooner when the primary is throttled or flaky. */ export const RPC_RETRY_CONFIG = { MAX_RETRIES: 3, + RETRIES_PER_URL_WITH_FAILOVER: 1, JITTER_FACTOR: 0.3, ABSOLUTE_MAX_BACKOFF_MS: 5000, STANDARD: { @@ -113,12 +115,13 @@ export function hexWeiToBigInt(hex: string): bigint { */ export async function rpcCall( rpcUrl: string, - payload: JsonRpcPayload + payload: JsonRpcPayload, + maxRetries: number = RPC_RETRY_CONFIG.MAX_RETRIES ): Promise { let lastError: Error = new Error("RPC call failed"); let lastFailureKind: RpcFailureKind = "standard"; - for (let attempt = 0; attempt <= RPC_RETRY_CONFIG.MAX_RETRIES; attempt++) { + for (let attempt = 0; attempt <= maxRetries; attempt++) { if (attempt > 0) { const backoffMs = getRpcBackoffMs(attempt - 1, lastFailureKind); addBreadcrumb({ @@ -181,3 +184,52 @@ export async function rpcCall( throw lastError; } + +/** + * Execute a JSON-RPC call across a primary URL with optional fallbacks. + * + * When more than one URL is provided, each URL uses the reduced + * `RETRIES_PER_URL_WITH_FAILOVER` budget so a throttled primary hands off to + * the fallback quickly instead of burning the full retry schedule first. + * + * Throws the last error after all URLs are exhausted. A Sentry breadcrumb is + * emitted for every failover hop. + */ +export async function rpcCallWithFailover( + rpcUrls: ReadonlyArray, + payload: JsonRpcPayload +): Promise { + if (rpcUrls.length === 0) { + throw new Error("rpcCallWithFailover requires at least one URL"); + } + + const maxRetries = + rpcUrls.length > 1 + ? RPC_RETRY_CONFIG.RETRIES_PER_URL_WITH_FAILOVER + : RPC_RETRY_CONFIG.MAX_RETRIES; + + let lastError: Error = new Error("RPC call failed"); + + for (const [i, url] of rpcUrls.entries()) { + try { + return await rpcCall(url, payload, maxRetries); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + const nextUrl = rpcUrls[i + 1]; + if (nextUrl) { + addBreadcrumb({ + category: "rpc.failover", + level: "info", + message: `RPC primary failed, failing over: ${lastError.message}`, + data: { + method: payload.method, + failedUrl: url, + nextUrl, + }, + }); + } + } + } + + throw lastError; +} diff --git a/lib/wallet/types.ts b/lib/wallet/types.ts index ba82eec57..44fd03993 100644 --- a/lib/wallet/types.ts +++ b/lib/wallet/types.ts @@ -16,6 +16,7 @@ export type ChainData = { symbol: string; chainType: string; defaultPrimaryRpc: string; + defaultFallbackRpc: string | null; explorerUrl: string | null; explorerAddressPath: string | null; isTestnet: boolean; From f35ac72747386843d184a5c46aa84e49a44d9620 Mon Sep 17 00:00:00 2001 From: Joel Orzet Date: Mon, 20 Apr 2026 20:51:08 -0300 Subject: [PATCH 05/41] chore: move wallet rpc test to tests/unit to match project convention --- lib/wallet/rpc.test.ts => tests/unit/wallet-rpc.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename lib/wallet/rpc.test.ts => tests/unit/wallet-rpc.test.ts (99%) diff --git a/lib/wallet/rpc.test.ts b/tests/unit/wallet-rpc.test.ts similarity index 99% rename from lib/wallet/rpc.test.ts rename to tests/unit/wallet-rpc.test.ts index 8f5e8ffe5..7b921a5cc 100644 --- a/lib/wallet/rpc.test.ts +++ b/tests/unit/wallet-rpc.test.ts @@ -13,7 +13,7 @@ import { RPC_RETRY_CONFIG, rpcCall, rpcCallWithFailover, -} from "./rpc"; +} from "@/lib/wallet/rpc"; const VALID_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; const TEST_RPC_URL = "https://rpc.example.test"; From a99e9a46518ddebec255b2d274e466184ba330fc Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 21 Apr 2026 10:52:06 +1000 Subject: [PATCH 06/41] fix: KEEP-287 prevent duplicate edges between same nodes Two nodes could be connected multiple times via the same source/target handle combination. Allow multiple connections between a node pair only when they use different handles (e.g. Condition true/false to the same target); reject exact duplicates. - Extract hasDuplicateEdge to lib/workflow/edge-helpers.ts - Reject duplicates in isValidConnection (drag-time) and onConnect (after sourceHandle auto-assignment) --- components/workflow/workflow-canvas.tsx | 30 +++++- lib/workflow/edge-helpers.ts | 29 ++++++ tests/unit/edge-helpers.test.ts | 119 ++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 lib/workflow/edge-helpers.ts create mode 100644 tests/unit/edge-helpers.test.ts diff --git a/components/workflow/workflow-canvas.tsx b/components/workflow/workflow-canvas.tsx index 44ed25682..abf49ea95 100644 --- a/components/workflow/workflow-canvas.tsx +++ b/components/workflow/workflow-canvas.tsx @@ -48,6 +48,7 @@ import { type WorkflowNode, type WorkflowNodeType, } from "@/lib/workflow-store"; +import { hasDuplicateEdge } from "@/lib/workflow/edge-helpers"; import { Edge } from "../ai-elements/edge"; import { Panel } from "../ai-elements/panel"; import { ActionNode } from "./nodes/action-node"; @@ -454,9 +455,25 @@ export function WorkflowCanvas() { return false; } + // Reject a duplicate of an existing edge (same source/target and handles). + // Different handles between the same node pair are still allowed. + if ( + hasDuplicateEdge(edges, { + source: connection.source, + target: connection.target, + sourceHandle, + targetHandle: + "targetHandle" in connection + ? (connection.targetHandle as string | null | undefined) + : undefined, + }) + ) { + return false; + } + return true; }, - [nodes] + [edges, nodes] ); const onConnect: OnConnect = useCallback( @@ -493,6 +510,17 @@ export function WorkflowCanvas() { } } + if ( + hasDuplicateEdge(currentEdges, { + source: connection.source, + target: connection.target, + sourceHandle, + targetHandle: connection.targetHandle, + }) + ) { + return currentEdges; + } + const newEdge = { id: nanoid(), ...connection, diff --git a/lib/workflow/edge-helpers.ts b/lib/workflow/edge-helpers.ts new file mode 100644 index 000000000..508b760c8 --- /dev/null +++ b/lib/workflow/edge-helpers.ts @@ -0,0 +1,29 @@ +import type { Edge as XYFlowEdge } from "@xyflow/react"; + +/** Normalize handle IDs so null/undefined/"" compare equal. */ +export function normalizeHandle(handle: string | null | undefined): string { + return handle ?? ""; +} + +/** True if an edge with the same source/sourceHandle -> target/targetHandle + * already exists. Allows multiple connections between the same pair when + * they use different handles (e.g. Condition true/false to the same target). */ +export function hasDuplicateEdge( + edges: readonly XYFlowEdge[], + candidate: { + source: string; + target: string; + sourceHandle?: string | null; + targetHandle?: string | null; + } +): boolean { + const sh = normalizeHandle(candidate.sourceHandle); + const th = normalizeHandle(candidate.targetHandle); + return edges.some( + (e) => + e.source === candidate.source && + e.target === candidate.target && + normalizeHandle(e.sourceHandle) === sh && + normalizeHandle(e.targetHandle) === th + ); +} diff --git a/tests/unit/edge-helpers.test.ts b/tests/unit/edge-helpers.test.ts new file mode 100644 index 000000000..4398fe623 --- /dev/null +++ b/tests/unit/edge-helpers.test.ts @@ -0,0 +1,119 @@ +import type { Edge as XYFlowEdge } from "@xyflow/react"; +import { describe, expect, it } from "vitest"; + +import { hasDuplicateEdge, normalizeHandle } from "@/lib/workflow/edge-helpers"; + +function edge( + id: string, + source: string, + target: string, + sourceHandle?: string | null, + targetHandle?: string | null +): XYFlowEdge { + return { id, source, target, sourceHandle, targetHandle }; +} + +describe("edge-helpers", () => { + describe("normalizeHandle", () => { + it("returns empty string for null", () => { + expect(normalizeHandle(null)).toBe(""); + }); + + it("returns empty string for undefined", () => { + expect(normalizeHandle(undefined)).toBe(""); + }); + + it("passes through a string value", () => { + expect(normalizeHandle("true")).toBe("true"); + }); + + it("preserves empty string", () => { + expect(normalizeHandle("")).toBe(""); + }); + }); + + describe("hasDuplicateEdge", () => { + it("returns false when no edges exist", () => { + expect( + hasDuplicateEdge([], { source: "a", target: "b" }) + ).toBe(false); + }); + + it("detects duplicate when both handles are null/undefined on both sides", () => { + const existing = [edge("e1", "a", "b")]; + expect( + hasDuplicateEdge(existing, { source: "a", target: "b" }) + ).toBe(true); + }); + + it("treats null, undefined, and empty string handles as equivalent", () => { + const existing = [edge("e1", "a", "b", null, null)]; + expect( + hasDuplicateEdge(existing, { + source: "a", + target: "b", + sourceHandle: "", + targetHandle: undefined, + }) + ).toBe(true); + }); + + it("allows different targets from the same source", () => { + const existing = [edge("e1", "a", "b")]; + expect( + hasDuplicateEdge(existing, { source: "a", target: "c" }) + ).toBe(false); + }); + + it("allows different sources to the same target", () => { + const existing = [edge("e1", "a", "c")]; + expect( + hasDuplicateEdge(existing, { source: "b", target: "c" }) + ).toBe(false); + }); + + it("allows same source->target on different source handles (Condition true/false)", () => { + const existing = [edge("e1", "cond", "target", "true")]; + expect( + hasDuplicateEdge(existing, { + source: "cond", + target: "target", + sourceHandle: "false", + }) + ).toBe(false); + }); + + it("rejects same source->target on the same source handle", () => { + const existing = [edge("e1", "cond", "target", "true")]; + expect( + hasDuplicateEdge(existing, { + source: "cond", + target: "target", + sourceHandle: "true", + }) + ).toBe(true); + }); + + it("allows same source->target on different target handles", () => { + const existing = [edge("e1", "a", "b", null, "in-1")]; + expect( + hasDuplicateEdge(existing, { + source: "a", + target: "b", + targetHandle: "in-2", + }) + ).toBe(false); + }); + + it("rejects when any prior edge in the list matches", () => { + const existing = [ + edge("e1", "x", "y"), + edge("e2", "a", "b"), + edge("e3", "m", "n"), + ]; + expect( + hasDuplicateEdge(existing, { source: "a", target: "b" }) + ).toBe(true); + }); + }); +}); From 3a17441dc4f6a85579e55ef2f8ee3d722e54673a Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 21 Apr 2026 11:04:25 +1000 Subject: [PATCH 07/41] fix: KEEP-289 gate pane context menu to workflow routes The pane right-click menu exposed Add Step on the landing page because onPaneContextMenu was wired unconditionally while the canvas is shared between / and /workflows/[id] via PersistentCanvas. Other add-node entry points (toolbar, AddStepButton, onConnectEnd) already gate on isWorkflowRoute or require a real node to be present. Extend the existing isGenerating guard with !isWorkflowRoute so the handler resolves to undefined on the landing page; the canvas already derives isWorkflowRoute from the pathname. --- components/workflow/workflow-canvas.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/workflow/workflow-canvas.tsx b/components/workflow/workflow-canvas.tsx index 44ed25682..b30ddb19d 100644 --- a/components/workflow/workflow-canvas.tsx +++ b/components/workflow/workflow-canvas.tsx @@ -755,7 +755,9 @@ export function WorkflowCanvas() { onNodeContextMenu={isGenerating ? undefined : onNodeContextMenu} onNodesChange={isGenerating ? undefined : onNodesChange} onPaneClick={onPaneClick} - onPaneContextMenu={isGenerating ? undefined : onPaneContextMenu} + onPaneContextMenu={ + isGenerating || !isWorkflowRoute ? undefined : onPaneContextMenu + } onSelectionChange={isGenerating ? undefined : onSelectionChange} > {isWorkflowRoute && ( From feb05cca6a9540d752cbb8c767837e0661c7594a Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 21 Apr 2026 11:26:21 +1000 Subject: [PATCH 08/41] fix: KEEP-287 dedupe AI-generated edges before apply/persist The AI prompt path bypassed isValidConnection/onConnect by calling setEdges directly with workflow data. A hallucinated duplicate would slip through into the canvas and the DB. - Add dedupeEdges helper (O(n) via Set keyed on source/handle/target). - Apply it to the streaming setEdges and to finalEdges before workflow.create/update. - Cover with 5 additional unit tests. --- components/ai-elements/prompt.tsx | 19 +++++++++------ lib/workflow/edge-helpers.ts | 29 +++++++++++++++++----- tests/unit/edge-helpers.test.ts | 40 ++++++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 14 deletions(-) diff --git a/components/ai-elements/prompt.tsx b/components/ai-elements/prompt.tsx index e8d630d1e..ec07fabb5 100644 --- a/components/ai-elements/prompt.tsx +++ b/components/ai-elements/prompt.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner"; import { Shimmer } from "@/components/ai-elements/shimmer"; import { Button } from "@/components/ui/button"; import { api } from "@/lib/api-client"; +import { dedupeEdges } from "@/lib/workflow/edge-helpers"; import { currentWorkflowIdAtom, currentWorkflowNameAtom, @@ -135,9 +136,10 @@ export function AIPrompt({ workflowId, onWorkflowCreated }: AIPromptProps) { ); } - // Update the canvas incrementally + // Update the canvas incrementally. Dedupe in case the AI emitted + // duplicate edges (same source/sourceHandle -> target/targetHandle). setNodes(partialData.nodes || []); - setEdges(validEdges); + setEdges(dedupeEdges(validEdges)); if (partialData.name) { setCurrentWorkflowName(partialData.name); } @@ -153,11 +155,14 @@ export function AIPrompt({ workflowId, onWorkflowCreated }: AIPromptProps) { console.log("[AI Prompt] Nodes:", workflowData.nodes?.length || 0); console.log("[AI Prompt] Edges:", workflowData.edges?.length || 0); - // Use edges from workflow data with animated type - const finalEdges = (workflowData.edges || []).map((edge) => ({ - ...edge, - type: "animated", - })); + // Use edges from workflow data with animated type; dedupe before + // persisting so AI hallucinations don't leak duplicates into the DB. + const finalEdges = dedupeEdges( + (workflowData.edges || []).map((edge) => ({ + ...edge, + type: "animated", + })) + ); // Validate: check for blank/incomplete nodes console.log("[AI Prompt] Validating nodes:", workflowData.nodes); diff --git a/lib/workflow/edge-helpers.ts b/lib/workflow/edge-helpers.ts index 508b760c8..09f019c42 100644 --- a/lib/workflow/edge-helpers.ts +++ b/lib/workflow/edge-helpers.ts @@ -5,17 +5,19 @@ export function normalizeHandle(handle: string | null | undefined): string { return handle ?? ""; } +type EdgeLike = { + source: string; + target: string; + sourceHandle?: string | null; + targetHandle?: string | null; +}; + /** True if an edge with the same source/sourceHandle -> target/targetHandle * already exists. Allows multiple connections between the same pair when * they use different handles (e.g. Condition true/false to the same target). */ export function hasDuplicateEdge( edges: readonly XYFlowEdge[], - candidate: { - source: string; - target: string; - sourceHandle?: string | null; - targetHandle?: string | null; - } + candidate: EdgeLike ): boolean { const sh = normalizeHandle(candidate.sourceHandle); const th = normalizeHandle(candidate.targetHandle); @@ -27,3 +29,18 @@ export function hasDuplicateEdge( normalizeHandle(e.targetHandle) === th ); } + +/** Return a new array with duplicate edges removed, preserving first occurrence. + * Duplicate is defined identically to {@link hasDuplicateEdge}. */ +export function dedupeEdges(edges: readonly E[]): E[] { + const seen = new Set(); + const result: E[] = []; + for (const edge of edges) { + const key = `${edge.source}\u0000${normalizeHandle(edge.sourceHandle)}\u0000${edge.target}\u0000${normalizeHandle(edge.targetHandle)}`; + if (!seen.has(key)) { + seen.add(key); + result.push(edge); + } + } + return result; +} diff --git a/tests/unit/edge-helpers.test.ts b/tests/unit/edge-helpers.test.ts index 4398fe623..2ead67a8e 100644 --- a/tests/unit/edge-helpers.test.ts +++ b/tests/unit/edge-helpers.test.ts @@ -1,7 +1,11 @@ import type { Edge as XYFlowEdge } from "@xyflow/react"; import { describe, expect, it } from "vitest"; -import { hasDuplicateEdge, normalizeHandle } from "@/lib/workflow/edge-helpers"; +import { + dedupeEdges, + hasDuplicateEdge, + normalizeHandle, +} from "@/lib/workflow/edge-helpers"; function edge( id: string, @@ -116,4 +120,38 @@ describe("edge-helpers", () => { ).toBe(true); }); }); + + describe("dedupeEdges", () => { + it("returns an empty array for empty input", () => { + expect(dedupeEdges([])).toEqual([]); + }); + + it("returns the same edges when all are unique", () => { + const input = [ + edge("e1", "a", "b"), + edge("e2", "b", "c"), + edge("e3", "a", "c"), + ]; + expect(dedupeEdges(input)).toEqual(input); + }); + + it("drops later duplicates and preserves first occurrence order", () => { + const first = edge("e1", "a", "b"); + const second = edge("e2", "b", "c"); + const dup = edge("e3", "a", "b"); + expect(dedupeEdges([first, second, dup])).toEqual([first, second]); + }); + + it("treats null/undefined/empty-string handles as equivalent when deduping", () => { + const first = edge("e1", "a", "b", null, null); + const dup = edge("e2", "a", "b", "", undefined); + expect(dedupeEdges([first, dup])).toEqual([first]); + }); + + it("keeps edges that differ only by sourceHandle", () => { + const trueEdge = edge("e1", "cond", "t", "true"); + const falseEdge = edge("e2", "cond", "t", "false"); + expect(dedupeEdges([trueEdge, falseEdge])).toEqual([trueEdge, falseEdge]); + }); + }); }); From 76b55cf55c6420fce8ebde683b63f984cd53c508 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Tue, 21 Apr 2026 11:35:06 +1000 Subject: [PATCH 09/41] feat(nav): reorganize left nav and user menu - Move Billing from left nav to user menu dropdown (gated on isOwner + isBillingEnabled; routes to /billing) - Move Address Book from user menu to left nav (replaces Billing slot); opens overlay on click - Add Report an issue button to left nav bottom section (below Documentation); opens FeedbackOverlay - New user menu order: Wallet, Settings, Connections, API Keys, Billing, Projects and Tags - Combine separate Projects and Tags overlays into one tabbed ProjectsAndTagsOverlay (mirrors SettingsOverlay pattern; supports initialTab prop) - NavItem ACTION_ITEM_IDS allowlist so href=null items that open overlays are not marked "Coming Soon" --- components/navigation-sidebar.tsx | 69 +++-- .../overlays/projects-and-tags-overlay.tsx | 260 ++++++++++++++++++ components/workflows/user-menu.tsx | 50 ++-- 3 files changed, 335 insertions(+), 44 deletions(-) create mode 100644 components/overlays/projects-and-tags-overlay.tsx diff --git a/components/navigation-sidebar.tsx b/components/navigation-sidebar.tsx index a90282b07..6863c7013 100644 --- a/components/navigation-sidebar.tsx +++ b/components/navigation-sidebar.tsx @@ -2,11 +2,12 @@ import { BarChart3, + Bookmark, Check, ChevronLeft, ChevronRight, - CreditCard, DollarSign, + Github, Globe, Info, List, @@ -17,6 +18,9 @@ import { import { useParams, usePathname, useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { DiscordIcon } from "@/components/icons/discord-icon"; +import { AddressBookOverlay } from "@/components/overlays/address-book-overlay"; +import { FeedbackOverlay } from "@/components/overlays/feedback-overlay"; +import { useOverlay } from "@/components/overlays/overlay-provider"; import { Tooltip, TooltipContent, @@ -26,8 +30,6 @@ import { useIsMobile } from "@/hooks/use-mobile"; import type { Project, SavedWorkflow, Tag } from "@/lib/api-client"; import { api } from "@/lib/api-client"; import { authClient, useSession } from "@/lib/auth-client"; -import { isBillingEnabled } from "@/lib/billing/feature-flag"; -import { useActiveMember } from "@/lib/hooks/use-organization"; import type { NavPanelStates } from "@/lib/hooks/use-persisted-nav-state"; import { usePersistedNavState } from "@/lib/hooks/use-persisted-nav-state"; import { isAnonymousUser } from "@/lib/is-anonymous"; @@ -404,6 +406,11 @@ function SidebarHeader({ ); } +const ACTION_ITEM_IDS: ReadonlySet = new Set([ + "workflows", + "address-book", +]); + function NavItem({ item, active, @@ -415,7 +422,7 @@ function NavItem({ showLabels: boolean; onClick: () => void; }): React.ReactNode { - const disabled = item.href === null && item.id !== "workflows"; + const disabled = item.href === null && !ACTION_ITEM_IDS.has(item.id); const layoutClass = showLabels ? "gap-3 px-2" : "justify-center"; if (disabled) { @@ -492,18 +499,17 @@ const NAV_ITEMS = [ href: "/earnings" as string | null, }, { - id: "billing", - icon: CreditCard, - label: "Billing", - href: "/billing" as string | null, + id: "address-book", + icon: Bookmark, + label: "Address Book", + href: null as string | null, }, ]; -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: large component with many panel interactions, further extraction would hurt readability export function NavigationSidebar(): React.ReactNode { const isMobile = useIsMobile(); const { data: session } = useSession(); - const { isOwner } = useActiveMember(); + const { open: openOverlay } = useOverlay(); const router = useRouter(); const pathname = usePathname(); const params = useParams(); @@ -570,7 +576,6 @@ export function NavigationSidebar(): React.ReactNode { const isHubPage = pathname === "/hub"; const isAnalyticsPage = pathname === "/analytics"; const isEarningsPage = pathname === "/earnings"; - const isBillingPage = pathname === "/billing"; const expanded = navState.state.sidebar; const setExpanded = navState.setSidebar; @@ -698,9 +703,6 @@ export function NavigationSidebar(): React.ReactNode { if (id === "earnings") { return isEarningsPage; } - if (id === "billing") { - return isBillingPage; - } return false; } @@ -755,6 +757,11 @@ export function NavigationSidebar(): React.ReactNode { } return; } + if (id === "address-book") { + navState.closeAll(); + openOverlay(AddressBookOverlay); + return; + } navState.closeAll(); if (href) { router.push(href); @@ -800,8 +807,8 @@ export function NavigationSidebar(): React.ReactNode { if (item.id === "earnings") { return !isAnonymous; } - if (item.id === "billing") { - return isOwner && isBillingEnabled(); + if (item.id === "address-book") { + return !isAnonymous; } return true; }); @@ -886,6 +893,36 @@ export function NavigationSidebar(): React.ReactNode { ); })} + {(() => { + const reportButton = ( + + ); + + if (showLabels) { + return reportButton; + } + + return ( + + {reportButton} + Report an issue + + ); + })()} {/* Resize handle */} diff --git a/components/overlays/projects-and-tags-overlay.tsx b/components/overlays/projects-and-tags-overlay.tsx new file mode 100644 index 000000000..29f51ef3a --- /dev/null +++ b/components/overlays/projects-and-tags-overlay.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { FolderOpen, Plus, Tag as TagIcon, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { ConfirmOverlay } from "@/components/overlays/confirm-overlay"; +import { Overlay } from "@/components/overlays/overlay"; +import { useOverlay } from "@/components/overlays/overlay-provider"; +import { ProjectFormDialog } from "@/components/projects/project-form-dialog"; +import { TagFormDialog } from "@/components/tags/tag-form-dialog"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { api, type Project, type Tag as TagType } from "@/lib/api-client"; + +type ProjectsAndTagsOverlayProps = { + overlayId: string; + initialTab?: "projects" | "tags"; +}; + +export function ProjectsAndTagsOverlay({ + overlayId, + initialTab = "projects", +}: ProjectsAndTagsOverlayProps): React.ReactElement { + const { open: openOverlay } = useOverlay(); + const [projects, setProjects] = useState([]); + const [tags, setTags] = useState([]); + const [loadingProjects, setLoadingProjects] = useState(true); + const [loadingTags, setLoadingTags] = useState(true); + const [showProjectDialog, setShowProjectDialog] = useState(false); + const [showTagDialog, setShowTagDialog] = useState(false); + + const loadProjects = useCallback(async (): Promise => { + try { + const result = await api.project.getAll(); + setProjects(result); + } catch { + toast.error("Failed to load projects"); + } finally { + setLoadingProjects(false); + } + }, []); + + const loadTags = useCallback(async (): Promise => { + try { + const result = await api.tag.getAll(); + setTags(result); + } catch { + toast.error("Failed to load tags"); + } finally { + setLoadingTags(false); + } + }, []); + + useEffect(() => { + loadProjects().catch(() => undefined); + loadTags().catch(() => undefined); + }, [loadProjects, loadTags]); + + const handleDeleteProject = (project: Project): void => { + openOverlay(ConfirmOverlay, { + title: "Delete Project", + message: `Are you sure you want to delete "${project.name}"? This cannot be undone.`, + confirmLabel: "Delete", + confirmVariant: "destructive" as const, + destructive: true, + onConfirm: async () => { + try { + await api.project.delete(project.id); + setProjects((prev) => prev.filter((p) => p.id !== project.id)); + toast.success(`Project "${project.name}" deleted`); + } catch { + toast.error("Failed to delete project"); + } + }, + }); + }; + + const handleDeleteTag = (tag: TagType): void => { + openOverlay(ConfirmOverlay, { + title: "Delete Tag", + message: `Are you sure you want to delete "${tag.name}"? This cannot be undone.`, + confirmLabel: "Delete", + confirmVariant: "destructive" as const, + destructive: true, + onConfirm: async () => { + try { + await api.tag.delete(tag.id); + setTags((prev) => prev.filter((t) => t.id !== tag.id)); + toast.success(`Tag "${tag.name}" deleted`); + } catch { + toast.error("Failed to delete tag"); + } + }, + }); + }; + + const handleProjectCreated = (project: Project): void => { + setProjects((prev) => [...prev, project]); + }; + + const handleTagCreated = (tag: TagType): void => { + setTags((prev) => [...prev, tag]); + }; + + return ( + <> + + + + Projects + Tags + + + +
+ +
+ + {loadingProjects && ( +
+ +
+ )} + {!loadingProjects && projects.length === 0 && ( +
+ +

No projects yet

+

+ Create a project to organize your workflows. +

+
+ )} + {!loadingProjects && projects.length > 0 && ( +
+ {projects.map((project) => ( +
+
+ +
+

{project.name}

+ {project.description && ( +

+ {project.description} +

+ )} +
+
+
+ + {project.workflowCount}{" "} + {project.workflowCount === 1 + ? "workflow" + : "workflows"} + + {project.workflowCount === 0 && ( + + )} +
+
+ ))} +
+ )} +
+ + +
+ +
+ + {loadingTags && ( +
+ +
+ )} + {!loadingTags && tags.length === 0 && ( +
+ +

No tags yet

+

+ Create a tag to categorize your workflows. +

+
+ )} + {!loadingTags && tags.length > 0 && ( +
+ {tags.map((tag) => ( +
+
+ +

{tag.name}

+
+
+ + {tag.workflowCount}{" "} + {tag.workflowCount === 1 ? "workflow" : "workflows"} + + {tag.workflowCount === 0 && ( + + )} +
+
+ ))} +
+ )} +
+
+
+ + + + ); +} diff --git a/components/workflows/user-menu.tsx b/components/workflows/user-menu.tsx index 921e8f1af..dc0a2a610 100644 --- a/components/workflows/user-menu.tsx +++ b/components/workflows/user-menu.tsx @@ -1,31 +1,27 @@ "use client"; import { - Bookmark, - FolderOpen, - Github, + CreditCard, + FolderTree, Key, LogOut, Plug, Settings, - Tag, Users, Wallet, } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useState } from "react"; import { AuthDialog, isSingleProviderSignInInitiated, } from "@/components/auth/dialog"; import { ManageOrgsModal } from "@/components/organization/manage-orgs-modal"; -import { AddressBookOverlay } from "@/components/overlays/address-book-overlay"; import { ApiKeysOverlay } from "@/components/overlays/api-keys-overlay"; -import { FeedbackOverlay } from "@/components/overlays/feedback-overlay"; import { IntegrationsOverlay } from "@/components/overlays/integrations-overlay"; import { useOverlay } from "@/components/overlays/overlay-provider"; -import { ProjectsOverlay } from "@/components/overlays/projects-overlay"; +import { ProjectsAndTagsOverlay } from "@/components/overlays/projects-and-tags-overlay"; import { SettingsOverlay } from "@/components/overlays/settings-overlay"; -import { TagsOverlay } from "@/components/overlays/tags-overlay"; import { WalletOverlay } from "@/components/overlays/wallet-overlay"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; @@ -38,13 +34,17 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { signOut, useSession } from "@/lib/auth-client"; -import { useOrganization } from "@/lib/hooks/use-organization"; +import { isBillingEnabled } from "@/lib/billing/feature-flag"; +import { useActiveMember, useOrganization } from "@/lib/hooks/use-organization"; -export const UserMenu = () => { +export const UserMenu = (): React.ReactElement => { const { data: session, isPending } = useSession(); const { open: openOverlay } = useOverlay(); const [orgModalOpen, setOrgModalOpen] = useState(false); const { organization } = useOrganization(); + const { isOwner } = useActiveMember(); + const router = useRouter(); + const showBilling = isOwner && isBillingEnabled(); const handleLogout = async () => { await signOut(); @@ -144,9 +144,9 @@ export const UserMenu = () => { - openOverlay(FeedbackOverlay)}> - - Report an issue + openOverlay(WalletOverlay)}> + + Wallet openOverlay(SettingsOverlay)}> @@ -160,21 +160,15 @@ export const UserMenu = () => { API Keys - openOverlay(WalletOverlay)}> - - Wallet - - openOverlay(AddressBookOverlay)}> - - Address Book - - openOverlay(ProjectsOverlay)}> - - Projects - - openOverlay(TagsOverlay)}> - - Tags + {showBilling && ( + router.push("/billing")}> + + Billing + + )} + openOverlay(ProjectsAndTagsOverlay)}> + + Projects and Tags From fa65323444ed6f855301f2db064946ab32d3dbeb Mon Sep 17 00:00:00 2001 From: Simon KP Date: Tue, 21 Apr 2026 11:35:43 +1000 Subject: [PATCH 10/41] feat(billing): landing-style redesign, billing details card, status polish UI redesign (/billing) - PricingTable mirrors landing /pricing: pill Monthly/Annual toggle with inline "Save 20%" badge; centered 4-col grid at xl; HeroMetrics stat panel per card; custom TierSelect dropdown with green highlight for selected tier - Outline current plan with a thin keeperhub-green-dark border + "CURRENT" badge instead of the old POPULAR highlight - Enterprise card consistent with others ("Custom" price, "Talk to us" CTA, mailto:human@keeperhub.com); no gradient background or special border - Shared ComparisonTable below the grid: "Compare all features" toggle reveals a 10-row striped matrix with Enterprise column accented in green - CTA label normalized to "Change plan" for all paid plan changes - Remove subheadings under price; remove redundant executions pill on Current Plan card; move renewal message (e.g. "Your plan ends on ...") inline with plan name + status pill - Execution usage bar and gas credits bar restyled on keeperhub-green tokens with subtle /15 track; overage tail in yellow - FAQ link footer at bottom of /billing routes to https://keeperhub.com/pricing Billing Details card - New BillingDetails card rendered next to BillingHistory (lg:grid-cols-[2fr_1fr]) - Fetches GET /api/billing/billing-details; shows card brand + last4 + expiry and invoice email; empty state when no card on file - Edit pencil inline next to "Billing Details" title; opens Stripe portal Auth gate - billing-page.tsx mirrors analytics page pattern: useSession + local AuthGate, early-return when anonymous (no more pricing table exposure to logged-out users) Data plumbing - Add BillingDetails type and getBillingDetails(customerId) to BillingProvider - Stripe implementation cascades: customer.invoice_settings.default_payment_method -> subscription.default_payment_method -> first customer payment method (Stripe Checkout attaches card to subscription, not customer, by default) - Add BILLING_DETAILS to BILLING_API constants - New GET /api/billing/billing-details route (owner-auth) - /billing bumps refreshKey 2s after ?checkout=success so BillingDetails remounts once Stripe has attached the payment method - BillingHistory view/PDF links recolored keeperhub green - Confirm plan change dialog copy: remove leading "--" before the prorated billing note Tests - billing-handle-event.test.ts mock provider stubs getBillingDetails --- app/api/billing/billing-details/route.ts | 44 +++ components/billing/billing-details.tsx | 155 +++++++++ components/billing/billing-history.tsx | 4 +- components/billing/billing-page.tsx | 88 +++++- components/billing/billing-status.tsx | 44 ++- .../billing/confirm-plan-change-dialog.tsx | 2 +- components/billing/pricing-table/index.tsx | 203 +++++++++--- .../billing/pricing-table/plan-card-parts.tsx | 299 ++++++++++-------- .../billing/pricing-table/plan-card.tsx | 118 +++---- components/billing/pricing-table/utils.ts | 7 +- lib/billing/constants.ts | 1 + lib/billing/provider.ts | 12 + lib/billing/providers/stripe.ts | 62 ++++ tests/unit/billing-handle-event.test.ts | 3 + 14 files changed, 753 insertions(+), 289 deletions(-) create mode 100644 app/api/billing/billing-details/route.ts create mode 100644 components/billing/billing-details.tsx diff --git a/app/api/billing/billing-details/route.ts b/app/api/billing/billing-details/route.ts new file mode 100644 index 000000000..5341cc2c0 --- /dev/null +++ b/app/api/billing/billing-details/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { isBillingEnabled } from "@/lib/billing/feature-flag"; +import { getOrgSubscription } from "@/lib/billing/plans-server"; +import { getBillingProvider } from "@/lib/billing/providers"; +import { requireOrgOwner } from "@/lib/billing/require-org-owner"; +import { ErrorCategory, logSystemError } from "@/lib/logging"; + +export async function GET(): Promise { + if (!isBillingEnabled()) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + try { + const authResult = await requireOrgOwner(); + if ("error" in authResult) { + return authResult.error; + } + const { orgId: activeOrgId } = authResult; + + const sub = await getOrgSubscription(activeOrgId); + if (!sub?.providerCustomerId) { + return NextResponse.json({ + paymentMethod: null, + billingEmail: null, + }); + } + + const provider = getBillingProvider(); + const details = await provider.getBillingDetails(sub.providerCustomerId); + + return NextResponse.json(details); + } catch (error) { + logSystemError( + ErrorCategory.EXTERNAL_SERVICE, + "[Billing] Billing details error", + error, + { endpoint: "/api/billing/billing-details", operation: "get" } + ); + return NextResponse.json( + { error: "Failed to load billing details" }, + { status: 500 } + ); + } +} diff --git a/components/billing/billing-details.tsx b/components/billing/billing-details.tsx new file mode 100644 index 000000000..25a7a3b0b --- /dev/null +++ b/components/billing/billing-details.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { Loader2, Pencil } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { BILLING_API } from "@/lib/billing/constants"; +import { useOrganization } from "@/lib/hooks/use-organization"; + +type PaymentMethod = { + brand: string; + last4: string; + expMonth: number; + expYear: number; +}; + +type BillingDetailsResponse = { + paymentMethod: PaymentMethod | null; + billingEmail: string | null; +}; + +function formatBrand(brand: string): string { + const map: Record = { + visa: "Visa", + mastercard: "Mastercard", + amex: "American Express", + discover: "Discover", + jcb: "JCB", + diners: "Diners Club", + unionpay: "UnionPay", + }; + return map[brand] ?? brand.charAt(0).toUpperCase() + brand.slice(1); +} + +export function BillingDetails(): React.ReactElement { + const { organization } = useOrganization(); + const orgId = organization?.id; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [portalLoading, setPortalLoading] = useState(false); + + const fetchDetails = useCallback(async (): Promise => { + setLoading(true); + try { + const response = await fetch(BILLING_API.BILLING_DETAILS); + if (response.ok) { + const json = (await response.json()) as BillingDetailsResponse; + setData(json); + } else { + setData({ paymentMethod: null, billingEmail: null }); + } + } catch { + setData({ paymentMethod: null, billingEmail: null }); + } finally { + setLoading(false); + } + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: orgId drives re-fetch on org switch + useEffect(() => { + fetchDetails().catch(() => undefined); + }, [fetchDetails, orgId]); + + async function openPortal(): Promise { + setPortalLoading(true); + try { + const response = await fetch(BILLING_API.PORTAL, { method: "POST" }); + const json = (await response.json()) as { url?: string; error?: string }; + if (response.ok && json.url) { + window.location.href = json.url; + return; + } + toast.error(json.error ?? "Could not open billing portal"); + } catch { + toast.error("Could not open billing portal"); + } finally { + setPortalLoading(false); + } + } + + const paymentMethod = data?.paymentMethod ?? null; + const billingEmail = data?.billingEmail ?? null; + const hasPaymentMethod = paymentMethod !== null; + + return ( + + + + Billing Details + {hasPaymentMethod && ( + + )} + + + + {loading && ( +
+ + Loading... +
+ )} + + {!(loading || hasPaymentMethod) && ( +

+ No card on file. Subscribe to a paid plan to add a payment method. +

+ )} + + {!loading && hasPaymentMethod && ( +
+

+ + {formatBrand(paymentMethod.brand)} ending in + {" "} + + •••• {paymentMethod.last4} + +

+

+ Expires {String(paymentMethod.expMonth).padStart(2, "0")}/ + {String(paymentMethod.expYear).slice(-2)} +

+
+ )} + + {!loading && ( +
+

+ Invoice Email:{" "} + {billingEmail ? ( + {billingEmail} + ) : ( + + Not on file + + )} +

+
+ )} +
+
+ ); +} diff --git a/components/billing/billing-history.tsx b/components/billing/billing-history.tsx index 885b81372..e8707a6a6 100644 --- a/components/billing/billing-history.tsx +++ b/components/billing/billing-history.tsx @@ -216,7 +216,7 @@ export function BillingHistory(): React.ReactElement { {invoice.invoiceUrl && ( +
+
+
+ {isAuthRequired ? ( + + ) : ( + + )} +
+
+

+ {isAuthRequired + ? "Sign in to view billing" + : "Organization required"} +

+

+ {isAuthRequired + ? "Sign in to your account to manage your subscription and view billing history." + : "Create or join an organization to manage billing."} +

+
+ {!isAuthRequired && ( + + )} +
+
+ + ); +} + export function BillingPage(): React.ReactElement { const searchParams = useSearchParams(); + const { data: session, isPending: sessionPending } = useSession(); const { organization } = useOrganization(); const orgId = organization?.id; const [currentPlan, setCurrentPlan] = useState("free"); @@ -39,13 +90,18 @@ export function BillingPage(): React.ReactElement { >(undefined); const [refreshKey, setRefreshKey] = useState(0); const [planLoaded, setPlanLoaded] = useState(false); + const isAnonymous = !session?.user || session.user.isAnonymous; useEffect(() => { const checkout = searchParams.get("checkout"); if (checkout === "success") { toast.success("Subscription activated successfully!"); window.history.replaceState({}, "", window.location.pathname); - } else if (checkout === "canceled") { + // Re-fetch after Stripe finishes attaching the payment method (~2s delay) + const timer = setTimeout(() => setRefreshKey((k) => k + 1), 2000); + return () => clearTimeout(timer); + } + if (checkout === "canceled") { toast.info("Checkout was canceled."); window.history.replaceState({}, "", window.location.pathname); } @@ -88,6 +144,20 @@ export function BillingPage(): React.ReactElement { setRefreshKey((k) => k + 1); } + if (sessionPending) { + return ( +
diff --git a/components/billing/billing-status.tsx b/components/billing/billing-status.tsx index f0fce614d..f9b8e6cbd 100644 --- a/components/billing/billing-status.tsx +++ b/components/billing/billing-status.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { BILLING_ALERTS, BILLING_API } from "@/lib/billing/constants"; -import { PLANS, type PlanName, type TierKey } from "@/lib/billing/plans"; +import { PLANS, type PlanName } from "@/lib/billing/plans"; type OverageCharge = { periodStart: string; @@ -371,13 +371,13 @@ function ExecutionUsageBar({ const overageRate = PLANS[plan].overage.ratePerThousand; function resolveBarColor(): string { - if (isOverLimit) { - return hasOverage ? "bg-muted-foreground" : "bg-destructive"; + if (isOverLimit && !hasOverage) { + return "bg-destructive"; } - if (isNearLimit) { + if (isNearLimit && !isOverLimit) { return "bg-yellow-500"; } - return "bg-keeperhub-green"; + return "bg-keeperhub-green-dark"; } const barColor = resolveBarColor(); @@ -393,7 +393,7 @@ function ExecutionUsageBar({ {!(isUnlimited || isOverLimit) && ( -
+
-
-
+
+
-
+
)} @@ -456,7 +456,7 @@ function GasCreditsBar({ if (isNearLimit) { return "bg-yellow-500"; } - return "bg-keeperhub-green"; + return "bg-keeperhub-green-dark"; } const barColor = resolveBarColor(); @@ -469,7 +469,7 @@ function GasCreditsBar({ {(gasCredits.totalCents / 100).toFixed(2)}
-
+
t.key === tier) : null; const statusVariant = STATUS_VARIANT[sub?.status ?? "active"] ?? "outline"; const renewalMessage = getRenewalMessage( @@ -607,14 +605,14 @@ function BillingStatusContent({ )} -
+
{planDef.name} - {activeTier && ( - - {activeTier.executions.toLocaleString()} executions - - )} {sub?.status ?? "active"} + {renewalMessage && ( +

+ {renewalMessage.text} +

+ )}
{usage && ( @@ -628,12 +626,6 @@ function BillingStatusContent({ {gasCredits && } - - {renewalMessage && ( -

- {renewalMessage.text} -

- )} ); } diff --git a/components/billing/confirm-plan-change-dialog.tsx b/components/billing/confirm-plan-change-dialog.tsx index 93f900131..ac238b5d8 100644 --- a/components/billing/confirm-plan-change-dialog.tsx +++ b/components/billing/confirm-plan-change-dialog.tsx @@ -567,7 +567,7 @@ export function ConfirmPlanChangeDialog({ )} {" "} - -- changes take effect immediately with prorated billing. + Changes take effect immediately with prorated billing.

diff --git a/components/billing/pricing-table/index.tsx b/components/billing/pricing-table/index.tsx index 455bcea40..8561f5d48 100644 --- a/components/billing/pricing-table/index.tsx +++ b/components/billing/pricing-table/index.tsx @@ -1,12 +1,158 @@ "use client"; +import { ChevronDown } from "lucide-react"; import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; import type { BillingInterval } from "@/lib/billing/plans"; import { PLANS } from "@/lib/billing/plans"; import { cn } from "@/lib/utils"; import { PlanCard } from "./plan-card"; import type { PricingTableProps } from "./types"; +const COMPARISON_ROWS = [ + { + label: "Workflows", + free: "Unlimited", + pro: "Unlimited", + business: "Unlimited", + enterprise: "Unlimited", + }, + { + label: "Chains", + free: "All EVM", + pro: "All EVM", + business: "All EVM", + enterprise: "Custom", + }, + { + label: "Triggers", + free: "Standard", + pro: "Advanced", + business: "Advanced + Custom", + enterprise: "Custom", + }, + { + label: "API", + free: "Rate-limited", + pro: "Full", + business: "Full", + enterprise: "Full", + }, + { + label: "Logs", + free: "7 days", + pro: "30 days", + business: "90 days", + enterprise: "Custom", + }, + { + label: "Support", + free: "Community", + pro: "Email", + business: "Dedicated", + enterprise: "Dedicated (1h)", + }, + { + label: "SLA", + free: "\u2014", + pro: "\u2014", + business: "99.9%", + enterprise: "99.999%", + }, + { + label: "Builder", + free: "Visual + AI", + pro: "Visual + AI", + business: "Visual + AI", + enterprise: "Visual + AI", + }, + { + label: "MCP Server", + free: "Included", + pro: "Included", + business: "Included", + enterprise: "Included", + }, + { + label: "Ops team", + free: "\u2014", + pro: "\u2014", + business: "\u2014", + enterprise: "Dedicated", + }, +] as const; + +function ComparisonTable(): React.ReactElement { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + + {isOpen && ( +
+ + + + + + + + + + + + {COMPARISON_ROWS.map((row, i) => ( + + + + + + + + ))} + +
+ Feature + + Free + + Pro + + Business + + Enterprise +
+ {row.label} + + {row.free} + {row.pro}{row.business} + {row.enterprise} +
+
+ )} +
+ ); +} + export function PricingTable({ currentPlan = "free", currentTier, @@ -18,14 +164,13 @@ export function PricingTable({ return (
- {/* Interval toggle */} -
-
+
+
+ + Save 20% +
- {/* Plan cards */} -
+
- {/* Overage callout */} -
-

- When users reach their execution limit -

-
-
-

Pay per execution (default)

-

- On paid tiers, overages billed at end of cycle -

-
-
-

Bump executions

-

- Select a higher tier from the dropdown -

-
-
-

Upgrade their plan

-

- Move to a higher plan for more features -

-
-
-

- On paid tiers, overages are billed at end of cycle. Unpaid overage - invoices may result in reduced execution limits. Free tier: hard cap, - must upgrade. -

-
+ + +

+ Paid tiers bill overage at the end of the cycle. Free tier caps at its + limit with no overage. +

); } diff --git a/components/billing/pricing-table/plan-card-parts.tsx b/components/billing/pricing-table/plan-card-parts.tsx index 77454303b..52c9ac944 100644 --- a/components/billing/pricing-table/plan-card-parts.tsx +++ b/components/billing/pricing-table/plan-card-parts.tsx @@ -1,152 +1,180 @@ +"use client"; + +import { ChevronDown } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { CardFooter } from "@/components/ui/card"; -import { SUPPORT_LABELS } from "@/lib/billing/constants"; import type { BillingInterval, PLANS, PlanName } from "@/lib/billing/plans"; import { cn } from "@/lib/utils"; import type { PlanTierItem } from "./types"; -import { formatPrice, getButtonLabel, getExecutionsDisplay } from "./utils"; - -export function FeatureRow({ - label, - value, - highlight = false, -}: { - label: string; - value: string; - highlight?: boolean; -}): React.ReactElement { - return ( -
- {label} - - {value} - -
- ); -} +import { formatPrice, getButtonLabel, getTierPrice } from "./utils"; export function PlanCardBadge({ isActive, - isPopular, }: { isActive: boolean; - isPopular: boolean; }): React.ReactElement | null { - if (isActive) { - return ( -
- - ACTIVE - -
- ); - } - if (!isPopular) { + if (!isActive) { return null; } return ( -
- - POPULAR +
+ + CURRENT
); } -export function PlanCardFeatures({ - plan, - planName, - activeTier, - gasCreditCentsCap, +export function PlanHeader({ + name, + price, + isEnterprise, }: { - plan: (typeof PLANS)[PlanName]; - planName: PlanName; - activeTier: PlanTierItem | undefined; - gasCreditCentsCap?: number; + name: string; + price: number | null; + isEnterprise: boolean; }): React.ReactElement { - const isEnterprise = planName === "enterprise"; - const executionsDisplay = getExecutionsDisplay(planName, activeTier); - - const capCents = gasCreditCentsCap ?? plan.features.gasCreditsCents; - const gasCredits = isEnterprise - ? `$${(capCents / 100).toFixed(0)}+/mo` - : `$${(capCents / 100).toFixed(0)}/mo`; - - const logRetention = - plan.features.logRetentionDays >= 365 - ? "1 year" - : `${plan.features.logRetentionDays} days`; - return ( -
- {executionsDisplay && ( - - )} - - -
+
+

{name}

+
+ {isEnterprise || price === null ? ( + + Custom + + ) : ( + <> + + {formatPrice(price)} + + /mo + + )} +
+
+ ); +} - - - - - - - {plan.features.sla && ( - - )} +export function HeroMetrics({ + executions, + gas, +}: { + executions: string; + gas: string; +}): React.ReactElement { + return ( +
+
+

+ Executions /mo +

+

{executions}

+
+
+

+ Gas credits /mo +

+

{gas}

+
); } -export function PriceDisplay({ - price, - annualTotal, +export function TierSelect({ + options, + value, + onChange, interval, }: { - price: number | null; - annualTotal: number | null; + options: PlanTierItem[]; + value: string; + onChange: (key: string) => void; interval: BillingInterval; }): React.ReactElement { - if (price === null) { - return ( -
- - {interval === "yearly" ? "$1,999+" : "$2,499+"} - - /mo -
- ); - } + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!isOpen) { + return; + } + function handleClickOutside(event: MouseEvent): void { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return (): void => + document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen]); + + const selected = options.find((opt) => opt.key === value) ?? options[0]; + return ( -
- - {formatPrice(price)} - - /mo - {annualTotal !== null && ( -

- {formatPrice(annualTotal)}/year billed annually -

+
+ + + {isOpen && ( +
+ {options.map((opt) => { + const isSelected = opt.key === value; + return ( + + ); + })} +
)}
); @@ -157,7 +185,6 @@ export function PlanCardFooter({ plan, isCurrent, loading, - isPopular, currentPlan, hasSubscription, onSubscribe, @@ -166,7 +193,6 @@ export function PlanCardFooter({ plan: (typeof PLANS)[PlanName]; isCurrent: boolean; loading: boolean; - isPopular: boolean; currentPlan?: PlanName; hasSubscription: boolean; onSubscribe: () => void; @@ -174,33 +200,30 @@ export function PlanCardFooter({ const isFree = planName === "free"; const isEnterprise = planName === "enterprise"; + let overageLabel: string | null = null; + if (plan.overage.enabled) { + overageLabel = `$${plan.overage.ratePerThousand}/1K additional executions`; + } else if (isFree) { + overageLabel = "No overage. Hard cap at limit."; + } else if (isEnterprise) { + overageLabel = "Custom overage terms"; + } + return ( - -
- {isFree && ( - No overage - )} - {plan.overage.enabled && ( - - ${plan.overage.ratePerThousand}/1K additional executions - - )} - {isEnterprise && ( - Custom pricing - )} -
+ + {overageLabel && ( + + {overageLabel} + + )} ); } diff --git a/components/billing/pricing-table/plan-card.tsx b/components/billing/pricing-table/plan-card.tsx index 6df2d22a7..50b138030 100644 --- a/components/billing/pricing-table/plan-card.tsx +++ b/components/billing/pricing-table/plan-card.tsx @@ -2,15 +2,7 @@ import { useState } from "react"; import { toast } from "sonner"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { CONTACT_EMAIL } from "@/lib/billing/constants"; +import { Card, CardContent } from "@/components/ui/card"; import type { BillingInterval, PLANS, @@ -20,17 +12,16 @@ import type { import { cn } from "@/lib/utils"; import { ConfirmPlanChangeDialog } from "../confirm-plan-change-dialog"; import { + HeroMetrics, PlanCardBadge, - PlanCardFeatures, PlanCardFooter, - PriceDisplay, + PlanHeader, + TierSelect, } from "./plan-card-parts"; import type { GasCreditCapsMap } from "./types"; import { cancelSubscription, computeDisplayPrice, - formatPrice, - getTierPrice, isCurrentPlan, resolveExecutions, startCheckout, @@ -44,7 +35,6 @@ export function PlanCard({ currentTier, currentInterval, gasCreditCaps, - isPopular = false, onPlanUpdated, }: { plan: (typeof PLANS)[PlanName]; @@ -54,7 +44,6 @@ export function PlanCard({ currentTier?: TierKey | null; currentInterval?: BillingInterval | null; gasCreditCaps?: GasCreditCapsMap; - isPopular?: boolean; onPlanUpdated?: () => Promise; }): React.ReactElement { const [selectedTier, setSelectedTier] = useState( @@ -64,9 +53,10 @@ export function PlanCard({ ); const [loading, setLoading] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); - const [selectOpen, setSelectOpen] = useState(false); const hasSubscription = currentPlan !== undefined && currentPlan !== "free"; + const isEnterprise = planName === "enterprise"; + const isFree = planName === "free"; const isCurrent = isCurrentPlan( planName, @@ -80,15 +70,28 @@ export function PlanCard({ const activeTier = plan.tiers.find((t) => t.key === selectedTier); const price = computeDisplayPrice(planName, activeTier, interval); - const annualTotal = - activeTier && interval === "yearly" - ? activeTier.monthlyPriceAnnual * 12 - : null; + const capCents = gasCreditCaps?.[planName] ?? plan.features.gasCreditsCents; + const gasDisplay = isEnterprise + ? "Custom" + : `$${(capCents / 100).toFixed(0)}`; + + const executionsDisplay = (() => { + if (isEnterprise) { + return "Custom"; + } + if (isFree) { + return plan.features.maxExecutionsPerMonth.toLocaleString(); + } + if (activeTier) { + return activeTier.executions.toLocaleString(); + } + return "-"; + })(); async function executeCheckout(): Promise { setLoading(true); try { - if (planName === "free") { + if (isFree) { const result = await cancelSubscription(); if (!result.success) { return; @@ -122,35 +125,30 @@ export function PlanCard({ } function handleSubscribe(): void { - if (planName === "enterprise") { + if (isEnterprise) { window.open( - `mailto:${CONTACT_EMAIL}?subject=Enterprise%20Plan`, + "mailto:human@keeperhub.com?subject=Enterprise%20Plan", "_blank", "noopener" ); return; } - if (isCurrent) { return; } - - if (planName === "free" && hasSubscription) { + if (isFree && hasSubscription) { setConfirmOpen(true); return; } - - if (planName === "free") { + if (isFree) { return; } - if (hasSubscription) { setConfirmOpen(true); return; } - executeCheckout().catch(() => { - // error handled inside executeCheckout + // handled inside executeCheckout }); } @@ -180,63 +178,35 @@ export function PlanCard({ /> - - - - {plan.name} -

{plan.description}

-
+ - - + + + {plan.tiers.length > 0 && ( - + setSelectedTier(key as TierKey)} + options={plan.tiers} + value={selectedTier ?? plan.tiers[0].key} + /> )} - - ; + getBillingDetails(customerId: string): Promise; + verifyWebhook(body: string, signature: string): Promise; getSubscriptionDetails(subscriptionId: string): Promise; diff --git a/lib/billing/providers/stripe.ts b/lib/billing/providers/stripe.ts index a469a27e3..107d2f70a 100644 --- a/lib/billing/providers/stripe.ts +++ b/lib/billing/providers/stripe.ts @@ -1,6 +1,7 @@ import type Stripe from "stripe"; import { stripe } from "@/lib/stripe"; import type { + BillingDetails, BillingProvider, BillingWebhookEvent, CreateCheckoutParams, @@ -263,6 +264,67 @@ export class StripeBillingProvider implements BillingProvider { return { url: session.url }; } + async getBillingDetails(customerId: string): Promise { + const s = getStripe(); + const customer = await s.customers.retrieve(customerId, { + expand: ["invoice_settings.default_payment_method"], + }); + + if (customer.deleted) { + return { paymentMethod: null, billingEmail: null }; + } + + const defaultPaymentMethod = + customer.invoice_settings?.default_payment_method; + let card: Stripe.PaymentMethod.Card | null = + defaultPaymentMethod && + typeof defaultPaymentMethod === "object" && + defaultPaymentMethod.type === "card" + ? (defaultPaymentMethod.card ?? null) + : null; + + // Stripe Checkout stores the default payment method on the subscription, + // not on the customer. Fall back to the most recent subscription's default, + // then to any card attached to the customer. + if (!card) { + const subs = await s.subscriptions.list({ + customer: customerId, + status: "all", + limit: 1, + expand: ["data.default_payment_method"], + }); + const subDefault = subs.data[0]?.default_payment_method; + if ( + subDefault && + typeof subDefault === "object" && + subDefault.type === "card" + ) { + card = subDefault.card ?? null; + } + } + + if (!card) { + const methods = await s.paymentMethods.list({ + customer: customerId, + type: "card", + limit: 1, + }); + card = methods.data[0]?.card ?? null; + } + + return { + paymentMethod: card + ? { + brand: card.brand, + last4: card.last4, + expMonth: card.exp_month, + expYear: card.exp_year, + } + : null, + billingEmail: customer.email ?? null, + }; + } + // biome-ignore lint/suspicious/useAwait: must be async to satisfy BillingProvider interface contract async verifyWebhook( body: string, diff --git a/tests/unit/billing-handle-event.test.ts b/tests/unit/billing-handle-event.test.ts index 1d8af1e63..a0cce6037 100644 --- a/tests/unit/billing-handle-event.test.ts +++ b/tests/unit/billing-handle-event.test.ts @@ -66,6 +66,9 @@ function createMockProvider( createCustomer: vi.fn(), createCheckoutSession: vi.fn(), createPortalSession: vi.fn(), + getBillingDetails: vi + .fn() + .mockResolvedValue({ paymentMethod: null, billingEmail: null }), verifyWebhook: vi.fn(), getSubscriptionDetails: vi.fn().mockResolvedValue({ priceId: process.env.STRIPE_PRICE_PRO_25K_MONTHLY, From c26960bcc17067ff68e127f68fc9238bc3cb1d73 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 21 Apr 2026 11:54:50 +1000 Subject: [PATCH 11/41] docs: KEEP-289 explain why pane context menu is gated on route The gate is subtle (the canvas is shared between / and /workflows/[id] via PersistentCanvas). A comment at the call site prevents a future maintainer from stripping !isWorkflowRoute as "cleanup to match siblings". --- components/workflow/workflow-canvas.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/components/workflow/workflow-canvas.tsx b/components/workflow/workflow-canvas.tsx index b30ddb19d..db010203e 100644 --- a/components/workflow/workflow-canvas.tsx +++ b/components/workflow/workflow-canvas.tsx @@ -756,6 +756,7 @@ export function WorkflowCanvas() { onNodesChange={isGenerating ? undefined : onNodesChange} onPaneClick={onPaneClick} onPaneContextMenu={ + // Add Step is the pane menu's only action; gate by route since the canvas is shared with / isGenerating || !isWorkflowRoute ? undefined : onPaneContextMenu } onSelectionChange={isGenerating ? undefined : onSelectionChange} From 23e7c2cd5be033b82bc28ae759f91a1810b93c76 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Tue, 21 Apr 2026 11:59:38 +1000 Subject: [PATCH 12/41] feat(banner): add skinny dismissible announcement banner for paid plans - New AppBanner client component mounted in app/layout.tsx: fixed 36px strip at the top of the app, keeperhub-green tint with border, centered info icon + body + "See plans" link, close (X) at right edge - Dismissal is permanent-per-browser via localStorage key kh-billing-announce-v1 so the banner never reappears for a user who closes it (version suffix lets us introduce a new banner later without wiping other prefs) - Banner height is exposed via --app-banner-height CSS var on so fixed overlays shift down cleanly when visible and snap back on dismiss. Updated: - components/workflow/workflow-toolbar.tsx (persistent toolbar top) - components/navigation-sidebar.tsx (sidebar top-[60px] now includes banner) - components/flyout-panel.tsx (two fixed surfaces) - app/workflows/[workflowId]/page.tsx (side panel lg breakpoint) - components/billing/billing-page.tsx (pt-20 -> calc) - components/analytics/analytics-page.tsx (pt-20 -> calc) - components/earnings/earnings-page.tsx (pt-20 -> calc) - No hydration flash: component renders null until mounted to avoid SSR/client mismatch reading localStorage --- app/layout.tsx | 2 + app/workflows/[workflowId]/page.tsx | 2 +- components/analytics/analytics-page.tsx | 2 +- components/app-banner.tsx | 81 ++++++++++++++++++++++++ components/billing/billing-page.tsx | 2 +- components/earnings/earnings-page.tsx | 2 +- components/flyout-panel.tsx | 4 +- components/navigation-sidebar.tsx | 2 +- components/workflow/workflow-toolbar.tsx | 2 +- 9 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 components/app-banner.tsx diff --git a/app/layout.tsx b/app/layout.tsx index cf565f35e..c5793e869 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import { Analytics } from "@vercel/analytics/react"; import { SpeedInsights } from "@vercel/speed-insights/next"; import { Provider } from "jotai"; import { type ReactNode, Suspense } from "react"; +import { AppBanner } from "@/components/app-banner"; import { AuthProvider } from "@/components/auth/provider"; import { KeeperHubExtensionLoader } from "@/components/extension-loader"; import { GitHubStarsLoader } from "@/components/github-stars-loader"; @@ -76,6 +77,7 @@ const RootLayout = ({ children }: RootLayoutProps) => ( }> + {children} diff --git a/app/workflows/[workflowId]/page.tsx b/app/workflows/[workflowId]/page.tsx index 6bcdd66f2..0dafc4942 100644 --- a/app/workflows/[workflowId]/page.tsx +++ b/app/workflows/[workflowId]/page.tsx @@ -880,7 +880,7 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { {/* Right panel overlay (desktop only) - only show if trigger exists */} {!isMobile && hasTriggerNode && (
-
+
diff --git a/components/app-banner.tsx b/components/app-banner.tsx new file mode 100644 index 000000000..b9b1b200c --- /dev/null +++ b/components/app-banner.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { Info, X } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; + +const STORAGE_KEY = "kh-billing-announce-v1"; + +export function AppBanner(): React.ReactElement | null { + const [mounted, setMounted] = useState(false); + const [dismissed, setDismissed] = useState(true); + + useEffect(() => { + setMounted(true); + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + setDismissed(stored === "1"); + } catch { + setDismissed(false); + } + }, []); + + useEffect(() => { + if (!mounted) { + return; + } + if (dismissed) { + document.documentElement.style.removeProperty("--app-banner-height"); + } else { + document.documentElement.style.setProperty("--app-banner-height", "36px"); + } + return (): void => { + document.documentElement.style.removeProperty("--app-banner-height"); + }; + }, [mounted, dismissed]); + + function handleDismiss(): void { + try { + window.localStorage.setItem(STORAGE_KEY, "1"); + } catch { + // localStorage unavailable; dismissal only lasts this session + } + setDismissed(true); + } + + if (!mounted || dismissed) { + return null; + } + + return ( +
+

+

+ +
+ ); +} diff --git a/components/billing/billing-page.tsx b/components/billing/billing-page.tsx index 2770df75f..2e2a815f2 100644 --- a/components/billing/billing-page.tsx +++ b/components/billing/billing-page.tsx @@ -167,7 +167,7 @@ export function BillingPage(): React.ReactElement { data-testid="billing-page" >
-
+

Billing

diff --git a/components/earnings/earnings-page.tsx b/components/earnings/earnings-page.tsx index 29b0f52ef..0dfd08e92 100644 --- a/components/earnings/earnings-page.tsx +++ b/components/earnings/earnings-page.tsx @@ -122,7 +122,7 @@ export function EarningsPage(): ReactNode { return (

-
+
diff --git a/components/flyout-panel.tsx b/components/flyout-panel.tsx index 30afebfab..aa28ba4e4 100644 --- a/components/flyout-panel.tsx +++ b/components/flyout-panel.tsx @@ -46,7 +46,7 @@ export function FlyoutPanel({ )} - + {selectedNode.data.type !== "trigger" && ( + + )}
)} diff --git a/components/workflow/workflow-context-menu.tsx b/components/workflow/workflow-context-menu.tsx index e12b4129d..ede7dd64b 100644 --- a/components/workflow/workflow-context-menu.tsx +++ b/components/workflow/workflow-context-menu.tsx @@ -144,12 +144,6 @@ export function WorkflowContextMenu({ return null; } - // Check if the node is a trigger (can't be deleted) - const isTriggerNode = Boolean( - menuState.nodeId && - nodes.find((n) => n.id === menuState.nodeId)?.data.type === "trigger" - ); - const getNodeLabel = () => { if (!menuState.nodeId) { return "Step"; @@ -169,7 +163,6 @@ export function WorkflowContextMenu({ > {menuState.type === "node" && ( } label={`Delete ${getNodeLabel()}`} onClick={handleDeleteNode} @@ -239,6 +232,10 @@ export function useContextMenuHandlers( const onNodeContextMenu = useCallback( (event: React.MouseEvent, node: Node) => { event.preventDefault(); + const data = node.data as WorkflowNode["data"] | undefined; + if (data?.type === "trigger") { + return; + } setMenuState({ type: "node", position: { x: event.clientX, y: event.clientY }, From 78f05cc9d85ed2f3202ff5c0e85eebe119fe6017 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 21 Apr 2026 12:54:03 +1000 Subject: [PATCH 14/41] fix: KEEP-291 remount config panel subtree on node selection change Add key={selectedNode.id} on the per-node config wrapper so the entire subtree unmounts and remounts when the user selects a different node. Without this, leaf components (AbiFunctionArgsField.localArgValues, FieldGroup.isExpanded, etc.) retained useState from the previous node because React preserves state for components at the same tree position across prop changes. Result: field inputs from the old node persisted in the panel after clicking a new node. --- components/workflow/node-config-panel.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/components/workflow/node-config-panel.tsx b/components/workflow/node-config-panel.tsx index bd626702d..9d06dc1e2 100644 --- a/components/workflow/node-config-panel.tsx +++ b/components/workflow/node-config-panel.tsx @@ -1084,7 +1084,10 @@ export const PanelInner = () => { !selectedNode.data.config?.actionType && isOwner ) && ( -
+
{selectedNode.data.type === "trigger" && ( Date: Tue, 21 Apr 2026 12:58:13 +1000 Subject: [PATCH 15/41] docs: KEEP-291 explain why config panel subtree is keyed on selected node id --- components/workflow/node-config-panel.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/workflow/node-config-panel.tsx b/components/workflow/node-config-panel.tsx index 9d06dc1e2..9cbfc8d88 100644 --- a/components/workflow/node-config-panel.tsx +++ b/components/workflow/node-config-panel.tsx @@ -1084,6 +1084,9 @@ export const PanelInner = () => { !selectedNode.data.config?.actionType && isOwner ) && ( + // key forces this subtree to remount when the selected node + // changes, resetting local useState in leaf field components so + // the previous node's inputs don't leak into the new node's panel.
Date: Tue, 21 Apr 2026 13:24:37 +1000 Subject: [PATCH 16/41] fix: KEEP-288 guard onConnectEnd node creation with connectionState.isValid The DOM-based hit-test (event.target.closest('.react-flow__node'|'.react-flow__handle')) is unreliable on mouseup during an xyflow connection drag. event.target can be the connection-line overlay rather than the target handle, so a successful handle-to-handle drop fired both onConnect (correct edge) and onConnectEnd's fallback "create node on pane drop" branch (spurious node + spurious edge). Replace the DOM check with xyflow's own tri-state signal: FinalConnectionState.isValid. Only null (pointer never entered a handle's connection radius) represents a true pane drop. true means onConnect already handled it; false means an invalid handle drop. --- components/workflow/workflow-canvas.tsx | 27 +++++++------------------ 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/components/workflow/workflow-canvas.tsx b/components/workflow/workflow-canvas.tsx index 44ed25682..a05b057cc 100644 --- a/components/workflow/workflow-canvas.tsx +++ b/components/workflow/workflow-canvas.tsx @@ -2,6 +2,7 @@ import { ConnectionMode, + type FinalConnectionState, MiniMap, type Node, type NodeMouseHandler, @@ -562,30 +563,16 @@ export function WorkflowCanvas() { ); const onConnectEnd = useCallback( - (event: MouseEvent | TouchEvent) => { + (event: MouseEvent | TouchEvent, connectionState: FinalConnectionState) => { if (!connectingNodeId.current) { return; } - // Get client position first - const { clientX, clientY } = getClientPosition(event); - - // For touch events, use elementFromPoint to get the actual element at the touch position - // For mouse events, use event.target as before - const target = - "changedTouches" in event - ? document.elementFromPoint(clientX, clientY) - : (event.target as Element); - - if (!target) { - connectingNodeId.current = null; - return; - } - - const isNode = target.closest(".react-flow__node"); - const isHandle = target.closest(".react-flow__handle"); - - if (!(isNode || isHandle)) { + // isValid === null: pointer never entered a handle's connection radius (pane drop). + // true: valid connection -- onConnect already created the edge. + // false: over a handle that rejected the drop -- do nothing. + if (connectionState.isValid === null) { + const { clientX, clientY } = getClientPosition(event); const { adjustedX, adjustedY } = calculateMenuPosition( event, clientX, From 088623384ee3e72323d9128f41117349055654bc Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 21 Apr 2026 14:35:43 +1000 Subject: [PATCH 17/41] fix: KEEP-293 make PR CI build tolerant of missing ECR credentials for Dependabot --- .github/workflows/pr-checks.yml | 4 ++-- docker-bake.hcl | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 18255a73c..526478ef0 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -143,7 +143,7 @@ jobs: uses: docker/setup-buildx-action@v4 - name: Configure AWS credentials - if: steps.changes.outputs.relevant == 'true' + if: steps.changes.outputs.relevant == 'true' && github.actor != 'dependabot[bot]' uses: aws-actions/configure-aws-credentials@v6 with: aws-access-key-id: ${{ secrets.TO_AWS_ACCESS_KEY_ID }} @@ -151,7 +151,7 @@ jobs: aws-region: ${{ vars.TO_REGION }} - name: Login to AWS ECR - if: steps.changes.outputs.relevant == 'true' + if: steps.changes.outputs.relevant == 'true' && github.actor != 'dependabot[bot]' id: login-ecr uses: aws-actions/amazon-ecr-login@v2 diff --git a/docker-bake.hcl b/docker-bake.hcl index bda2b0a96..e3d571344 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -50,13 +50,13 @@ target "app" { NEXT_PUBLIC_BILLING_ENABLED = NEXT_PUBLIC_BILLING_ENABLED NEXT_PUBLIC_SENTRY_DSN = NEXT_PUBLIC_SENTRY_DSN } - tags = compact([ + tags = ECR_REGISTRY != "" ? compact([ "${ECR_REGISTRY}/${ECR_REPO}:app-${IMAGE_TAG}", "${ECR_REGISTRY}/${ECR_REPO}:app-latest", ENVIRONMENT_TAG != "" ? "${ECR_REGISTRY}/${ECR_REPO}:${ENVIRONMENT_TAG}" : "", - ]) - cache-from = ["type=registry,ref=${ECR_REGISTRY}/${ECR_REPO}:cache-app"] - cache-to = ["type=registry,ref=${ECR_REGISTRY}/${ECR_REPO}:cache-app,mode=max"] + ]) : [] + cache-from = ECR_REGISTRY != "" ? ["type=registry,ref=${ECR_REGISTRY}/${ECR_REPO}:cache-app"] : [] + cache-to = ECR_REGISTRY != "" ? ["type=registry,ref=${ECR_REGISTRY}/${ECR_REPO}:cache-app,mode=max"] : [] } target "sentry-upload" { From f064dc7d88477e467b8e1b5743faa7ad02ff4dad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 04:58:25 +0000 Subject: [PATCH 18/41] chore(deps): bump dompurify Bumps the npm_and_yarn group with 1 update in the /docs-site directory: [dompurify](https://github.com/cure53/DOMPurify). Updates `dompurify` from 3.3.3 to 3.4.0 - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.3.3...3.4.0) --- updated-dependencies: - dependency-name: dompurify dependency-version: 3.4.0 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- docs-site/pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs-site/pnpm-lock.yaml b/docs-site/pnpm-lock.yaml index 27cf45b54..7cc71ed25 100644 --- a/docs-site/pnpm-lock.yaml +++ b/docs-site/pnpm-lock.yaml @@ -981,8 +981,8 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - dompurify@3.3.3: - resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + dompurify@3.4.0: + resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} @@ -2895,7 +2895,7 @@ snapshots: dependencies: dequal: 2.0.3 - dompurify@3.3.3: + dompurify@3.4.0: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -3446,7 +3446,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.13 dayjs: 1.11.19 - dompurify: 3.3.3 + dompurify: 3.4.0 katex: 0.16.27 khroma: 2.1.0 lodash-es: 4.17.22 From 2c2550b0e1435c1239ebd7f88fa7a6478a016b08 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Tue, 21 Apr 2026 14:59:04 +1000 Subject: [PATCH 19/41] fix(bazaar): emit discoverable/category/tags + fix resource URL The CDP Bazaar (agentic.market) crawls paid x402 endpoints and looks for extensions.bazaar.discoverable:true plus category/tags before indexing the resource. Our prior work (KEEP-176) only emitted bazaar.schema for agentcash/x402scan/mppscan; CDP Bazaar requires the discovery fields separately. Also fixes the resource.url in the dual-402 response, which threaded request.url through and resolved to the internal pod bind (https://0.0.0.0:3000/...) in K8s. buildPaymentConfig already used NEXT_PUBLIC_APP_URL; buildDual402Response now does too. - lib/payments/router.ts: discoverable:true always, category/tags when present, resource.url from NEXT_PUBLIC_APP_URL - lib/x402/types.ts: project category, add tagName to CallRouteWorkflow - app/api/mcp/workflows/[slug]/call/route.ts: leftJoin tags for tagName - tests/unit/payment-router.test.ts: replace omit-extensions case, add category/tags case Closes KEEP-294. Related: KEEP-176, KEEP-264. --- app/api/mcp/workflows/[slug]/call/route.ts | 5 +-- lib/payments/router.ts | 40 ++++++++++++++++------ lib/x402/types.ts | 4 +++ tests/unit/payment-router.test.ts | 23 +++++++++++-- 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/app/api/mcp/workflows/[slug]/call/route.ts b/app/api/mcp/workflows/[slug]/call/route.ts index 4363ae6e4..316d7eb6e 100644 --- a/app/api/mcp/workflows/[slug]/call/route.ts +++ b/app/api/mcp/workflows/[slug]/call/route.ts @@ -4,7 +4,7 @@ import { start } from "workflow/api"; import { checkConcurrencyLimit } from "@/app/api/execute/_lib/concurrency-limit"; import { enforceExecutionLimit } from "@/lib/billing/execution-guard"; import { db } from "@/lib/db"; -import { workflowExecutions, workflows } from "@/lib/db/schema"; +import { tags, workflowExecutions, workflows } from "@/lib/db/schema"; import { ErrorCategory, logSystemError } from "@/lib/logging"; import { checkIpRateLimit, getClientIp } from "@/lib/mcp/rate-limit"; import { hashMppCredential } from "@/lib/mpp/server"; @@ -166,8 +166,9 @@ async function createAndStartExecution( async function lookupWorkflow(slug: string): Promise { const rows = await db - .select(CALL_ROUTE_COLUMNS) + .select({ ...CALL_ROUTE_COLUMNS, tagName: tags.name }) .from(workflows) + .leftJoin(tags, eq(workflows.tagId, tags.id)) .where(and(eq(workflows.listedSlug, slug), eq(workflows.isListed, true))) .limit(1); return rows[0] ?? null; diff --git a/lib/payments/router.ts b/lib/payments/router.ts index 4428410cb..424ab2a71 100644 --- a/lib/payments/router.ts +++ b/lib/payments/router.ts @@ -41,6 +41,8 @@ type Dual402Params = { workflowName: string; resourceUrl: string; inputSchema?: Record | null; + category?: string | null; + tagName?: string | null; }; type PaymentRequiredV2 = { @@ -81,6 +83,8 @@ function buildPaymentRequired(params: Dual402Params): PaymentRequiredV2 { workflowName, resourceUrl, inputSchema, + category, + tagName, } = params; const amountSmallestUnit = String( Math.round(Number(price) * 10 ** USDC_DECIMALS) @@ -106,18 +110,24 @@ function buildPaymentRequired(params: Dual402Params): PaymentRequiredV2 { ], }; + // CDP Bazaar discovery: `discoverable: true` opts the resource into the + // marketplace index. The schema subtree feeds agentcash / x402scan probers. + const bazaar: Record = { discoverable: true }; + if (category) { + bazaar.category = category; + } + if (tagName) { + bazaar.tags = [tagName]; + } if (inputSchema) { - payload.extensions = { - bazaar: { - schema: { - properties: { - input: { properties: { body: inputSchema } }, - output: { properties: { example: WORKFLOW_OUTPUT_EXAMPLE } }, - }, - }, + bazaar.schema = { + properties: { + input: { properties: { body: inputSchema } }, + output: { properties: { example: WORKFLOW_OUTPUT_EXAMPLE } }, }, }; } + payload.extensions = { bazaar }; return payload; } @@ -362,14 +372,24 @@ export function gatePayment( return handleMpp(request, workflow, creatorWalletAddress, createHandler); } - // No payment header -- return dual 402 challenge + // No payment header -- return dual 402 challenge. + // Resource URL must use the public hostname (not request.url, which can be + // the internal pod bind `0.0.0.0:3000` inside K8s) or the CDP Bazaar + // crawler and any other caller will fail to resolve the endpoint. + const publicHost = + process.env.NEXT_PUBLIC_APP_URL ?? "https://app.keeperhub.com"; + const resourceUrl = workflow.listedSlug + ? `${publicHost}/api/mcp/workflows/${workflow.listedSlug}/call` + : request.url; return Promise.resolve( buildDual402Response({ price: workflow.priceUsdcPerCall ?? "0", creatorWalletAddress, workflowName: workflow.name, - resourceUrl: request.url, + resourceUrl, inputSchema: workflow.inputSchema, + category: workflow.category, + tagName: workflow.tagName, }) as NextResponse ); } diff --git a/lib/x402/types.ts b/lib/x402/types.ts index 2734d7eb1..2aeb326a2 100644 --- a/lib/x402/types.ts +++ b/lib/x402/types.ts @@ -4,6 +4,7 @@ import { workflows } from "@/lib/db/schema"; * Exact columns the call route needs from the workflows table. * priceUsdcPerCall returns string | null from Drizzle (numeric column). * nodes and edges are needed for execution; userId for creating the execution record. + * category/tagId feed the x402 Bazaar extensions for marketplace discovery. */ export type CallRouteWorkflow = { id: string; @@ -19,6 +20,8 @@ export type CallRouteWorkflow = { nodes: unknown[]; edges: unknown[]; userId: string; + category: string | null; + tagName: string | null; }; /** @@ -40,4 +43,5 @@ export const CALL_ROUTE_COLUMNS = { nodes: workflows.nodes, edges: workflows.edges, userId: workflows.userId, + category: workflows.category, } as const; diff --git a/tests/unit/payment-router.test.ts b/tests/unit/payment-router.test.ts index 4b16badf6..4db61f874 100644 --- a/tests/unit/payment-router.test.ts +++ b/tests/unit/payment-router.test.ts @@ -252,7 +252,7 @@ describe("buildDual402Response", () => { ).toEqual({ executionId: "exec_abc123", status: "running" }); }); - it("omits extensions block when inputSchema is absent", async () => { + it("always emits extensions.bazaar.discoverable:true so CDP Bazaar indexes the resource", async () => { const response = buildDual402Response({ price: "0.01", creatorWalletAddress: "0xCreator", @@ -260,6 +260,25 @@ describe("buildDual402Response", () => { resourceUrl: "https://example.com/api/mcp/workflows/test/call", }); const body = await response.json(); - expect(body.extensions).toBeUndefined(); + expect(body.extensions.bazaar.discoverable).toBe(true); + // schema subtree is only populated when inputSchema is provided + expect(body.extensions.bazaar.schema).toBeUndefined(); + expect(body.extensions.bazaar.category).toBeUndefined(); + expect(body.extensions.bazaar.tags).toBeUndefined(); + }); + + it("emits extensions.bazaar.category and tags when provided", async () => { + const response = buildDual402Response({ + price: "0.01", + creatorWalletAddress: "0xCreator", + workflowName: "Test Workflow", + resourceUrl: "https://example.com/api/mcp/workflows/test/call", + category: "web3", + tagName: "defi", + }); + const body = await response.json(); + expect(body.extensions.bazaar.discoverable).toBe(true); + expect(body.extensions.bazaar.category).toBe("web3"); + expect(body.extensions.bazaar.tags).toEqual(["defi"]); }); }); From 4e220735dcb96b36569e11cdbd8d044b11f7536c Mon Sep 17 00:00:00 2001 From: Simon KP Date: Tue, 21 Apr 2026 15:27:29 +1000 Subject: [PATCH 20/41] chore(chain-id): migrate legacy Tempo chain ID 42420 to 4217 Canonical chain ID for Tempo mainnet is 4217 (already used throughout the app, payment router, seed chains, and lib/rpc/types.ts). Three stragglers still referenced the legacy 42420 at spec-comment-noted blind spots from KEEP-176: - keeperhub-events/event-tracker/lib/utils/chains.ts: dead constant (nothing in event-tracker imports AVAILABLE_CHAINS.TEMPO_MAINNET), update to 4217 for consistency. - .claude/agents/protocol-domain.md: stale doc-prompt line referencing the Tempo Blockscout explorer by legacy ID. Actual explorer config in scripts/seed/seed-chains.ts:523 is keyed by 4217. - lib/rpc/rpc-config.ts: remove the duplicate 42420 RPC entry. The canonical 4217 entry handles every in-app caller; nothing on our side resolves RPC by the legacy ID. Adds a CI grep guard in pr-checks.yml (lint job) so future changes can't reintroduce 42420 as a bare numeric literal in source. The guard excludes *.md, drizzle/meta/*, and the workflow file itself. Closes KEEP-261. --- .claude/agents/protocol-domain.md | 2 +- .github/workflows/pr-checks.yml | 17 +++++++++++++++++ .../event-tracker/lib/utils/chains.ts | 2 +- lib/rpc/rpc-config.ts | 8 +------- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/.claude/agents/protocol-domain.md b/.claude/agents/protocol-domain.md index 4dcafb3e5..ee9dfc300 100644 --- a/.claude/agents/protocol-domain.md +++ b/.claude/agents/protocol-domain.md @@ -614,7 +614,7 @@ EVM chains with Etherscan-compatible explorer configs (safe for seed workflows): - "11155111" -- Sepolia Testnet (Etherscan Sepolia) Non-EVM / Blockscout chains with explorer configs (NOT safe for protocol seed workflows -- different API format): -- "42420" -- Tempo (Blockscout) +- "4217" -- Tempo (Blockscout) - "42429" -- Tempo Testnet (Blockscout) - "101" -- Solana (Solscan) - "103" -- Solana Devnet (Solscan) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 526478ef0..557792b30 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -94,6 +94,23 @@ jobs: - name: Run check run: pnpm check + - name: Forbid legacy Tempo chain ID 42420 + # Canonical Tempo mainnet chain ID is 4217 (see lib/rpc/types.ts). + # 42420 is a retired legacy ID; re-introducing it silently routes to + # the wrong RPC entry or mismatches the chains table. See KEEP-261. + # Uses git grep on the checked-out tree -- no user input is interpolated. + run: | + MATCHES=$(git grep -nE '\b42420\b' -- \ + ':!*.md' \ + ':!drizzle/meta/' \ + ':!.github/workflows/pr-checks.yml' \ + || true) + if [ -n "$MATCHES" ]; then + echo "::error::Legacy Tempo chain ID 42420 found. Use 4217 instead (KEEP-261)." + echo "$MATCHES" + exit 1 + fi + typecheck: runs-on: ubuntu-latest steps: diff --git a/keeperhub-events/event-tracker/lib/utils/chains.ts b/keeperhub-events/event-tracker/lib/utils/chains.ts index 4ce461337..e19e41bee 100644 --- a/keeperhub-events/event-tracker/lib/utils/chains.ts +++ b/keeperhub-events/event-tracker/lib/utils/chains.ts @@ -5,7 +5,7 @@ export const AVAILABLE_CHAINS = { SOLANA_MAINNET: "101", SOLANA_DEVNET: "103", BASE_MAINNET: "8453", - TEMPO_MAINNET: "42420", + TEMPO_MAINNET: "4217", TEMPO_TESTNET: "42429", BASE_SEPOLIA: "84532", } as const; diff --git a/lib/rpc/rpc-config.ts b/lib/rpc/rpc-config.ts index 126cabb38..7157604ed 100644 --- a/lib/rpc/rpc-config.ts +++ b/lib/rpc/rpc-config.ts @@ -105,19 +105,13 @@ export const CHAIN_CONFIG: Record = { fallbackEnvKey: "CHAIN_TEMPO_TESTNET_FALLBACK_RPC", publicDefault: PUBLIC_RPCS.TEMPO_TESTNET, }, - // Tempo Mainnet (4217 is the canonical chain ID; 42420 kept for backwards compatibility) + // Tempo Mainnet 4217: { jsonKey: "tempo-mainnet", envKey: "CHAIN_TEMPO_MAINNET_PRIMARY_RPC", fallbackEnvKey: "CHAIN_TEMPO_MAINNET_FALLBACK_RPC", publicDefault: PUBLIC_RPCS.TEMPO_MAINNET, }, - 42420: { - jsonKey: "tempo-mainnet", - envKey: "CHAIN_TEMPO_MAINNET_PRIMARY_RPC", - fallbackEnvKey: "CHAIN_TEMPO_MAINNET_FALLBACK_RPC", - publicDefault: PUBLIC_RPCS.TEMPO_MAINNET, - }, // BNB Chain (BSC) Mainnet 56: { jsonKey: "bsc-mainnet", From 173801bc7d795de886f847ffa1fc3c2667bfddfd Mon Sep 17 00:00:00 2001 From: Simon KP Date: Tue, 21 Apr 2026 15:41:17 +1000 Subject: [PATCH 21/41] docs: add paid-workflows and agentcash-install guides for dual-chain revenue Two new pages close the creator-facing and caller-facing documentation gaps flagged in KEEP-259 after KEEP-176 shipped dual-protocol payments without explaining to either audience what to expect. - docs/workflows/paid-workflows.md -- creator-facing. Explains how x402 settles on Base USDC and MPP settles on Tempo USDC.e, so a creator with balances on both chains understands the split is a function of which agents paid them (not a bug). Lists discovery scanners, pricing guidance, and points at the mcp-test dogfood reference. - docs/ai-tools/agentcash-install.md -- caller-facing. Covers the one-liner `npx agentcash add https://app.keeperhub.com` that installs a KeeperHub skill into every supported AI agent's skill directory (17 at time of writing). Clarifies that agentcash handles per-call payment, no API key setup, and documents the two real meta-tools (search_workflows, call_workflow) that the skill exposes. Both pages wired into their section _meta.ts and index.md. This is the docs portion of KEEP-259. Wallet-overlay UI changes (per-chain breakdown, first-visit explainer, Earnings card tooltip) will ship in a follow-up PR on a separate branch. Related: KEEP-259, KEEP-176, KEEP-294. --- docs/ai-tools/_meta.ts | 1 + docs/ai-tools/agentcash-install.md | 53 +++++++++++++++++++++++++++ docs/ai-tools/index.md | 1 + docs/workflows/_meta.ts | 1 + docs/workflows/index.md | 1 + docs/workflows/paid-workflows.md | 59 ++++++++++++++++++++++++++++++ 6 files changed, 116 insertions(+) create mode 100644 docs/ai-tools/agentcash-install.md create mode 100644 docs/workflows/paid-workflows.md diff --git a/docs/ai-tools/_meta.ts b/docs/ai-tools/_meta.ts index f6b1674b0..cc9d0638b 100644 --- a/docs/ai-tools/_meta.ts +++ b/docs/ai-tools/_meta.ts @@ -2,4 +2,5 @@ export default { overview: "Overview", "claude-code-plugin": "Claude Code Plugin", "mcp-server": "MCP Server", + "agentcash-install": "Install the Skill", }; diff --git a/docs/ai-tools/agentcash-install.md b/docs/ai-tools/agentcash-install.md new file mode 100644 index 000000000..14b61aa1d --- /dev/null +++ b/docs/ai-tools/agentcash-install.md @@ -0,0 +1,53 @@ +--- +title: "Install the KeeperHub Skill" +description: "One-command install for Claude Code, Cursor, and 15 other AI agents using agentcash." +--- + +# Install the KeeperHub Skill + +Run KeeperHub workflows from any supported AI agent with a single command: + +```bash +npx agentcash add https://app.keeperhub.com +``` + +This walks KeeperHub's `/openapi.json`, generates a local `keeperhub` skill file, and symlinks it into every agent skill directory it finds on your machine. + +## Supported agents + +`agentcash add` auto-detects installed agents and installs the skill into each one. Currently supported: + +- Claude Code +- Cursor +- Cline +- Windsurf +- Continue +- Roo Code +- Kilo Code +- Goose +- Trae +- Junie +- Crush +- Kiro CLI +- Qwen Code +- OpenHands +- Gemini CLI +- Codex +- GitHub Copilot + +Once installed, your agent can tab-complete `/keeperhub` and route to KeeperHub's workflow catalog directly. No API key setup is required for this install path — per-call payments are handled by agentcash's wallet. + +## Paying for calls + +Paid workflows settle in USDC on Base (via x402) or USDC.e on Tempo (via MPP). The first time your agent calls a paid workflow, agentcash will prompt you to fund a wallet or approve a per-call spending limit. Most workflows cost under `$0.05` per call. + +If you already have an agentcash wallet, the balance applies automatically. + +## What the skill exposes + +After install, the agent has access to two meta-tools: + +- `search_workflows` -- find workflows by category, tag, or free text. Returns slug, description, inputSchema, and price for each match. +- `call_workflow` -- execute a listed workflow by slug. For read workflows the call executes and returns the result; for write workflows it returns unsigned calldata `{to, data, value}` for the caller to submit. Use `search_workflows` first to discover available workflows. + +This meta-tool pattern keeps the agent's tool list small no matter how many workflows are listed — the agent discovers available workflows at runtime instead of registering one tool per workflow. diff --git a/docs/ai-tools/index.md b/docs/ai-tools/index.md index d3181b8da..2cadfced9 100644 --- a/docs/ai-tools/index.md +++ b/docs/ai-tools/index.md @@ -10,3 +10,4 @@ AI-powered tools that help you build, configure, and manage blockchain automatio - [Overview](/ai-tools/overview) -- How AI tools integrate with KeeperHub - [Claude Code Plugin](/ai-tools/claude-code-plugin) -- Use Claude Code for workflow development - [MCP Server](/ai-tools/mcp-server) -- KeeperHub MCP server for AI-assisted automation +- [Install the Skill](/ai-tools/agentcash-install) -- One-command skill install for 17 AI agents via agentcash diff --git a/docs/workflows/_meta.ts b/docs/workflows/_meta.ts index a454e18c1..c8b2950c5 100644 --- a/docs/workflows/_meta.ts +++ b/docs/workflows/_meta.ts @@ -1,5 +1,6 @@ export default { introduction: "Introduction", creating: "Creating Workflows", + "paid-workflows": "Paid Workflows", examples: "Examples", }; diff --git a/docs/workflows/index.md b/docs/workflows/index.md index 61bbb7768..5e7190577 100644 --- a/docs/workflows/index.md +++ b/docs/workflows/index.md @@ -9,4 +9,5 @@ Workflows are the core of KeeperHub -- visual automations that connect triggers, - [Introduction](/workflows/introduction) -- What workflows are and how they work - [Creating Workflows](/workflows/creating) -- Step-by-step guide to building your first workflow +- [Paid Workflows](/workflows/paid-workflows) -- List workflows for AI agents and earn per-call revenue on Base or Tempo - [Examples](/workflows/examples) -- Real-world workflow examples and templates diff --git a/docs/workflows/paid-workflows.md b/docs/workflows/paid-workflows.md new file mode 100644 index 000000000..2515fed46 --- /dev/null +++ b/docs/workflows/paid-workflows.md @@ -0,0 +1,59 @@ +--- +title: "Paid Workflows" +description: "List workflows for AI agents to call on demand and earn revenue on Base or Tempo." +--- + +# Paid Workflows + +When you list a workflow as paid, AI agents can discover and call it via KeeperHub's MCP endpoint. Each call settles on-chain in USDC, with the creator wallet as the recipient. Revenue arrives on either Base or Tempo depending on which protocol the calling agent uses. + +## How payment works + +Agents can pay using one of two protocols, and both are always offered on every paid workflow call: + +| Protocol | Chain | Token | Used by | +|---|---|---|---| +| x402 | Base (chain ID 8453) | USDC (`0x8335...02913`) | Agentcash wallets with a Base balance, Coinbase CDP-backed agents | +| MPP | Tempo (chain ID 4217) | USDC.e (`0x20c0...8b50`) | Agentcash wallets with a Tempo balance, MPP-native clients | + +The calling agent chooses which protocol to use based on what its wallet holds. A workflow creator does not pick one — both chains are live on every listed workflow, and you receive funds on whichever chain the caller paid from. + +## Receiving revenue on two chains + +After a caller pays, the USDC (or USDC.e) lands directly in your organization's creator wallet. Because the two chains settle in different tokens, you will see two balances in your wallet overlay: + +- **Base USDC** — accumulates from x402 calls +- **Tempo USDC.e** — accumulates from MPP calls + +Both are fully redeemable stablecoins pegged to USD. The split is purely a function of which agents called your workflow. There is nothing to configure, and no balance is "incorrect" if one chain has zero activity. + +### Why Tempo? + +Tempo has faster finality and predictable gas costs, which matters for high-throughput agent traffic. Base has the broader ecosystem and more wallet support today. KeeperHub supports both because different agents run on different wallets — forcing a single chain would exclude either the Coinbase Agent Kit ecosystem (Base) or the MPP-native wallet ecosystem (Tempo). + +### Consolidating your balance + +If you prefer a single-chain balance, you can bridge between Base and Tempo through the relevant chain bridges. KeeperHub does not auto-bridge — creator funds stay in the wallet you registered. See [Wallet Management](/wallet-management) for details on accessing the wallet. + +## Listing a workflow + +1. Open a workflow you want to list +2. Click the **List** button in the workflow toolbar +3. Set a per-call price in USDC and (optionally) a category and tags +4. Save — the workflow is now callable by agents via `https://app.keeperhub.com/api/mcp/workflows//call` + +Listed workflows are discoverable by x402scan, mppscan, and agentcash through their OpenAPI / `PAYMENT-REQUIRED` probes. No registration form is required for these scanners. + +## Pricing guidance + +Most listed workflows price between `$0.001` and `$0.10` per call. Agents pay per-request, so a price that sounds negligible in isolation adds up at scale. Consider: + +- The workflow's runtime cost (gas, RPC, external API calls) +- How long the execution takes +- Whether the output is a one-shot answer or part of a chained agent session + +You can update the price at any time on existing listed workflows. Prior calls settle at the price active when the call was made. + +## Dogfood reference + +The `mcp-test` workflow listed at `https://app.keeperhub.com/api/mcp/workflows/mcp-test/call` is the reference implementation. It is priced at `$0.01` per call, accepts both x402 and MPP payments, and its `/openapi.json` entry is what the scanners ingest. From 20622b4ce1620fc9f6f3f5a848cf2c3fa48057f2 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 14 Apr 2026 20:57:59 +1000 Subject: [PATCH 22/41] feat: add Aave V4 protocol definition (Lido Spoke) Add ABI-driven protocol definition for Aave V4 Hub-and-Spoke lending, starting with the Lido Spoke on Ethereum mainnet. - protocols/abis/aave-v4.json: reduced ABI covering 8 ISpoke functions - protocols/aave-v4.ts: defineAbiProtocol with lidoSpoke contract - tests/unit/protocol-aave-v4.test.ts: 19 unit tests Actions mirror the V3 surface with V4 semantics: supply, withdraw, borrow, repay, set-collateral, get-user-supplied-assets, get-user-debt, and a new get-reserve-id utility needed to resolve the opaque uint256 reserveId V4 uses to identify reserves within a Spoke. Differences from V3 noted in the file: no referralCode on supply, no interestRateMode on borrow/repay, onBehalfOf added to setUsingAsCollateral. Write actions surface (shares, amount) return values as named outputs. Additional Spokes (EtherFi, Kelp, Ethena Correlated, Ethena Ecosystem, Lombard BTC) share the same ABI and can be added as further contract entries in follow-ups. Notes: - No Sepolia deployment exists yet; integration tests deferred. - getUserAccountData intentionally excluded this pass - it returns a Solidity struct and the template engine path for nested tuple field access needs verification first. --- lib/types/integration.ts | 5 +- protocols/aave-v3.ts | 2 +- protocols/aave-v4.ts | 185 +++++++++++++++++++++++++++ protocols/abis/aave-v4.json | 102 +++++++++++++++ protocols/index.ts | 5 +- tests/unit/protocol-aave-v4.test.ts | 188 ++++++++++++++++++++++++++++ 6 files changed, 483 insertions(+), 4 deletions(-) create mode 100644 protocols/aave-v4.ts create mode 100644 protocols/abis/aave-v4.json create mode 100644 tests/unit/protocol-aave-v4.test.ts diff --git a/lib/types/integration.ts b/lib/types/integration.ts index 5d558c13c..039fd1904 100755 --- a/lib/types/integration.ts +++ b/lib/types/integration.ts @@ -9,12 +9,13 @@ * 2. Add a system integration to SYSTEM_INTEGRATION_TYPES in discover-plugins.ts * 3. Run: pnpm discover-plugins * - * Generated types: aave, aerodrome, ai-gateway, ajna, chainlink, chronicle, clerk, code, compound, cowswap, curve, database, discord, ethena, lido, linear, math, morpho, pendle, protocol, resend, rocket-pool, safe, sendgrid, sky, slack, spark, telegram, uniswap, v0, web3, webflow, webhook, wrapped, yearn + * Generated types: aave-v3, aave-v4, aerodrome, ai-gateway, ajna, chainlink, chronicle, clerk, code, compound, cowswap, curve, database, discord, ethena, lido, linear, math, morpho, pendle, protocol, resend, rocket-pool, safe, sendgrid, sky, slack, spark, telegram, uniswap, v0, web3, webflow, webhook, wrapped, yearn */ // Integration type union - plugins + system integrations export type IntegrationType = - | "aave" + | "aave-v3" + | "aave-v4" | "aerodrome" | "ai-gateway" | "ajna" diff --git a/protocols/aave-v3.ts b/protocols/aave-v3.ts index fa71f5f06..686555a5d 100644 --- a/protocols/aave-v3.ts +++ b/protocols/aave-v3.ts @@ -2,7 +2,7 @@ import { defineProtocol } from "@/lib/protocol-registry"; export default defineProtocol({ name: "Aave V3", - slug: "aave", + slug: "aave-v3", description: "Aave V3 lending and borrowing protocol -- supply, borrow, repay, and monitor account health", website: "https://aave.com", diff --git a/protocols/aave-v4.ts b/protocols/aave-v4.ts new file mode 100644 index 000000000..11f1f3250 --- /dev/null +++ b/protocols/aave-v4.ts @@ -0,0 +1,185 @@ +import { defineAbiProtocol } from "@/lib/protocol-registry"; +import aaveV4Abi from "./abis/aave-v4.json"; + +// Aave V4 launched on Ethereum mainnet 2026-03-30 with a Hub-and-Spoke +// architecture. Users interact with Spokes (not Hubs) for supply/borrow. +// Each Spoke is tied to an ecosystem partner and has its own set of reserves +// identified by an opaque uint256 reserveId. Use `get-reserve-id` to resolve +// an asset into its reserveId before calling supply/withdraw/borrow/repay. +// +// This first cut exposes the Lido Spoke only - the most established of the +// six launch Spokes (Lido, EtherFi, Kelp, Ethena Correlated, Ethena +// Ecosystem, Lombard BTC). Additional Spokes can be added as contract +// entries sharing the same ABI. +// +// Integration tests are gated on the separate aave-v4-mainnet-onchain +// test file - no Sepolia V4 deployment exists at launch. + +export default defineAbiProtocol({ + name: "Aave V4", + slug: "aave-v4", + description: + "Aave V4 Hub-and-Spoke lending protocol - supply, borrow, repay and monitor positions via the Lido Spoke", + website: "https://aave.com", + icon: "/protocols/aave.png", + + contracts: { + lidoSpoke: { + label: "Aave V4 Lido Spoke", + abi: JSON.stringify(aaveV4Abi), + addresses: { + "1": "0xe1900480ac69f0B296841Cd01cC37546d92F35Cd", + }, + overrides: { + supply: { + slug: "supply", + label: "Supply Asset", + description: + "Supply an asset to the Aave V4 Lido Spoke to earn interest. Amount is in the underlying asset's smallest unit (wei for 18-decimal tokens).", + inputs: { + reserveId: { + label: "Reserve ID", + helpTip: + "Opaque uint256 identifier for a reserve within this Spoke. Use the Get Reserve ID action to resolve from (hub, assetId).", + }, + amount: { label: "Amount (wei)" }, + onBehalfOf: { label: "On Behalf Of Address" }, + }, + outputs: { + result0: { name: "suppliedShares", label: "Supplied Shares" }, + result1: { + name: "suppliedAmount", + label: "Supplied Amount (underlying)", + }, + }, + }, + withdraw: { + slug: "withdraw", + label: "Withdraw Asset", + description: "Withdraw a supplied asset from the Aave V4 Lido Spoke", + inputs: { + reserveId: { label: "Reserve ID" }, + amount: { label: "Amount (wei)" }, + onBehalfOf: { label: "Recipient Address" }, + }, + outputs: { + result0: { name: "withdrawnShares", label: "Withdrawn Shares" }, + result1: { + name: "withdrawnAmount", + label: "Withdrawn Amount (underlying)", + }, + }, + }, + borrow: { + slug: "borrow", + label: "Borrow Asset", + description: + "Borrow an asset from the Aave V4 Lido Spoke against supplied collateral. V4 uses a single rate model (no stable/variable mode).", + inputs: { + reserveId: { label: "Reserve ID" }, + amount: { label: "Amount (wei)" }, + onBehalfOf: { label: "On Behalf Of Address" }, + }, + outputs: { + result0: { name: "drawnShares", label: "Drawn Shares" }, + result1: { + name: "drawnAmount", + label: "Drawn Amount (underlying)", + }, + }, + }, + repay: { + slug: "repay", + label: "Repay Debt", + description: "Repay a borrowed asset to the Aave V4 Lido Spoke", + inputs: { + reserveId: { label: "Reserve ID" }, + amount: { label: "Amount (wei)" }, + onBehalfOf: { label: "On Behalf Of Address" }, + }, + outputs: { + result0: { + name: "drawnSharesBurned", + label: "Drawn Shares Burned", + }, + result1: { + name: "totalAmountRepaid", + label: "Total Amount Repaid (underlying)", + }, + }, + }, + setUsingAsCollateral: { + slug: "set-collateral", + label: "Set Asset as Collateral", + description: + "Enable or disable a supplied reserve as collateral in the Aave V4 Lido Spoke", + inputs: { + reserveId: { label: "Reserve ID" }, + usingAsCollateral: { + label: "Use as Collateral", + helpTip: + "Toggles the entire supplied balance of this reserve as collateral. There is no partial collateral in Aave V4.", + }, + onBehalfOf: { label: "On Behalf Of Address" }, + }, + }, + getReserveId: { + slug: "get-reserve-id", + label: "Get Reserve ID", + description: + "Resolve an asset to its reserveId within this Spoke, given the Hub address and the Hub's assetId for that asset", + inputs: { + hub: { label: "Hub Address" }, + assetId: { + label: "Hub Asset ID", + helpTip: + "Asset identifier within the Hub. Use the Hub's getAssetId(underlying) to resolve from an ERC-20 address.", + }, + }, + outputs: { + result: { + name: "reserveId", + label: "Reserve ID", + }, + }, + }, + getUserSuppliedAssets: { + slug: "get-user-supplied-assets", + label: "Get User Supplied Assets", + description: + "Get the amount of underlying asset supplied by a user for a given reserve", + inputs: { + reserveId: { label: "Reserve ID" }, + user: { label: "User Address" }, + }, + outputs: { + result: { + name: "suppliedAmount", + label: "Supplied Amount (underlying)", + }, + }, + }, + getUserDebt: { + slug: "get-user-debt", + label: "Get User Debt", + description: + "Get the debt of a user for a given reserve, split into drawn debt and premium debt. Total debt = drawn + premium.", + inputs: { + reserveId: { label: "Reserve ID" }, + user: { label: "User Address" }, + }, + outputs: { + result0: { + name: "drawnDebt", + label: "Drawn Debt (underlying)", + }, + result1: { + name: "premiumDebt", + label: "Premium Debt (underlying)", + }, + }, + }, + }, + }, + }, +}); diff --git a/protocols/abis/aave-v4.json b/protocols/abis/aave-v4.json new file mode 100644 index 000000000..d044f6e40 --- /dev/null +++ b/protocols/abis/aave-v4.json @@ -0,0 +1,102 @@ +[ + { + "type": "function", + "name": "supply", + "stateMutability": "nonpayable", + "inputs": [ + { "name": "reserveId", "type": "uint256" }, + { "name": "amount", "type": "uint256" }, + { "name": "onBehalfOf", "type": "address" } + ], + "outputs": [ + { "name": "", "type": "uint256" }, + { "name": "", "type": "uint256" } + ] + }, + { + "type": "function", + "name": "withdraw", + "stateMutability": "nonpayable", + "inputs": [ + { "name": "reserveId", "type": "uint256" }, + { "name": "amount", "type": "uint256" }, + { "name": "onBehalfOf", "type": "address" } + ], + "outputs": [ + { "name": "", "type": "uint256" }, + { "name": "", "type": "uint256" } + ] + }, + { + "type": "function", + "name": "borrow", + "stateMutability": "nonpayable", + "inputs": [ + { "name": "reserveId", "type": "uint256" }, + { "name": "amount", "type": "uint256" }, + { "name": "onBehalfOf", "type": "address" } + ], + "outputs": [ + { "name": "", "type": "uint256" }, + { "name": "", "type": "uint256" } + ] + }, + { + "type": "function", + "name": "repay", + "stateMutability": "nonpayable", + "inputs": [ + { "name": "reserveId", "type": "uint256" }, + { "name": "amount", "type": "uint256" }, + { "name": "onBehalfOf", "type": "address" } + ], + "outputs": [ + { "name": "", "type": "uint256" }, + { "name": "", "type": "uint256" } + ] + }, + { + "type": "function", + "name": "setUsingAsCollateral", + "stateMutability": "nonpayable", + "inputs": [ + { "name": "reserveId", "type": "uint256" }, + { "name": "usingAsCollateral", "type": "bool" }, + { "name": "onBehalfOf", "type": "address" } + ], + "outputs": [] + }, + { + "type": "function", + "name": "getReserveId", + "stateMutability": "view", + "inputs": [ + { "name": "hub", "type": "address" }, + { "name": "assetId", "type": "uint256" } + ], + "outputs": [{ "name": "", "type": "uint256" }] + }, + { + "type": "function", + "name": "getUserSuppliedAssets", + "stateMutability": "view", + "inputs": [ + { "name": "reserveId", "type": "uint256" }, + { "name": "user", "type": "address" } + ], + "outputs": [{ "name": "", "type": "uint256" }] + }, + { + "type": "function", + "name": "getUserDebt", + "stateMutability": "view", + "inputs": [ + { "name": "reserveId", "type": "uint256" }, + { "name": "user", "type": "address" } + ], + "outputs": [ + { "name": "", "type": "uint256" }, + { "name": "", "type": "uint256" } + ] + } +] diff --git a/protocols/index.ts b/protocols/index.ts index a037e8506..805700e18 100644 --- a/protocols/index.ts +++ b/protocols/index.ts @@ -8,13 +8,14 @@ * This ensures the protocol registry is populated when the Next.js * server starts (via the plugin import chain). * - * Registered protocols: aave, aerodrome, ajna, chainlink, chronicle, compound, cowswap, curve, ethena, lido, morpho, pendle, rocket-pool, safe, sky, spark, uniswap, wrapped, yearn + * Registered protocols: aave-v3, aave-v4, aerodrome, ajna, chainlink, chronicle, compound, cowswap, curve, ethena, lido, morpho, pendle, rocket-pool, safe, sky, spark, uniswap, wrapped, yearn */ import { protocolToPlugin, registerProtocol } from "@/lib/protocol-registry"; import { registerIntegration } from "@/plugins/registry"; import aaveDef from "./aave-v3"; +import aaveV4Def from "./aave-v4"; import aerodromeDef from "./aerodrome"; import ajnaDef from "./ajna"; import chainlinkDef from "./chainlink"; @@ -36,6 +37,8 @@ import yearnDef from "./yearn-v3"; registerProtocol(aaveDef); registerIntegration(protocolToPlugin(aaveDef)); +registerProtocol(aaveV4Def); +registerIntegration(protocolToPlugin(aaveV4Def)); registerProtocol(aerodromeDef); registerIntegration(protocolToPlugin(aerodromeDef)); registerProtocol(ajnaDef); diff --git a/tests/unit/protocol-aave-v4.test.ts b/tests/unit/protocol-aave-v4.test.ts new file mode 100644 index 000000000..369fc9648 --- /dev/null +++ b/tests/unit/protocol-aave-v4.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import { getProtocol, registerProtocol } from "@/lib/protocol-registry"; +import aaveV4Def from "@/protocols/aave-v4"; + +const KEBAB_CASE_REGEX = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; +const HEX_ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/; + +describe("Aave V4 Protocol Definition (ABI-driven)", () => { + it("imports without throwing", () => { + expect(aaveV4Def).toBeDefined(); + expect(aaveV4Def.name).toBe("Aave V4"); + expect(aaveV4Def.slug).toBe("aave-v4"); + }); + + it("protocol slug is valid kebab-case", () => { + expect(aaveV4Def.slug).toMatch(KEBAB_CASE_REGEX); + }); + + it("all action slugs are valid kebab-case", () => { + for (const action of aaveV4Def.actions) { + expect(action.slug).toMatch(KEBAB_CASE_REGEX); + } + }); + + it("every action references an existing contract", () => { + const contractKeys = new Set(Object.keys(aaveV4Def.contracts)); + for (const action of aaveV4Def.actions) { + expect( + contractKeys.has(action.contract), + `action "${action.slug}" references unknown contract "${action.contract}"` + ).toBe(true); + } + }); + + it("has no duplicate action slugs", () => { + const slugs = aaveV4Def.actions.map((a) => a.slug); + expect(slugs.length).toBe(new Set(slugs).size); + }); + + it("all read actions define outputs", () => { + const readActions = aaveV4Def.actions.filter((a) => a.type === "read"); + for (const action of readActions) { + expect( + action.outputs, + `read action "${action.slug}" must have outputs` + ).toBeDefined(); + expect(action.outputs?.length).toBeGreaterThan(0); + } + }); + + it("all contract addresses are valid hex format", () => { + for (const [key, contract] of Object.entries(aaveV4Def.contracts)) { + for (const [chain, address] of Object.entries(contract.addresses)) { + expect( + address, + `contract "${key}" chain "${chain}" address must be valid hex` + ).toMatch(HEX_ADDRESS_REGEX); + } + } + }); + + it("has 8 actions covering V3 parity + reserveId resolver", () => { + expect(aaveV4Def.actions).toHaveLength(8); + const slugs = aaveV4Def.actions.map((a) => a.slug); + expect(slugs).toEqual( + expect.arrayContaining([ + "supply", + "withdraw", + "borrow", + "repay", + "set-collateral", + "get-reserve-id", + "get-user-supplied-assets", + "get-user-debt", + ]) + ); + }); + + it("has 5 write actions and 3 read actions", () => { + const reads = aaveV4Def.actions.filter((a) => a.type === "read"); + const writes = aaveV4Def.actions.filter((a) => a.type === "write"); + expect(reads).toHaveLength(3); + expect(writes).toHaveLength(5); + }); + + it("has 1 contract (Lido Spoke only for this first cut)", () => { + expect(Object.keys(aaveV4Def.contracts)).toHaveLength(1); + expect(aaveV4Def.contracts.lidoSpoke).toBeDefined(); + }); + + it("Lido Spoke is available on Ethereum mainnet only (V4 launch state)", () => { + const chains = Object.keys(aaveV4Def.contracts.lidoSpoke.addresses); + expect(chains).toHaveLength(1); + expect(chains).toContain("1"); + }); + + it("supply action has reserveId/amount/onBehalfOf inputs and is a write", () => { + const supply = aaveV4Def.actions.find((a) => a.slug === "supply"); + expect(supply).toBeDefined(); + expect(supply?.type).toBe("write"); + expect(supply?.function).toBe("supply"); + expect(supply?.inputs).toHaveLength(3); + expect(supply?.inputs.map((i) => i.name)).toEqual([ + "reserveId", + "amount", + "onBehalfOf", + ]); + expect(supply?.inputs[0].type).toBe("uint256"); + expect(supply?.inputs[1].type).toBe("uint256"); + expect(supply?.inputs[2].type).toBe("address"); + }); + + it("set-collateral action has a bool input", () => { + const setCollateral = aaveV4Def.actions.find( + (a) => a.slug === "set-collateral" + ); + expect(setCollateral).toBeDefined(); + expect(setCollateral?.type).toBe("write"); + expect(setCollateral?.function).toBe("setUsingAsCollateral"); + const boolInput = setCollateral?.inputs.find( + (i) => i.name === "usingAsCollateral" + ); + expect(boolInput?.type).toBe("bool"); + }); + + it("get-reserve-id action has renamed output 'reserveId'", () => { + const getReserveId = aaveV4Def.actions.find( + (a) => a.slug === "get-reserve-id" + ); + expect(getReserveId).toBeDefined(); + expect(getReserveId?.type).toBe("read"); + expect(getReserveId?.outputs).toHaveLength(1); + expect(getReserveId?.outputs?.[0].name).toBe("reserveId"); + expect(getReserveId?.outputs?.[0].type).toBe("uint256"); + }); + + it("get-user-debt action returns two uint256 outputs (drawnDebt + premiumDebt)", () => { + const getUserDebt = aaveV4Def.actions.find( + (a) => a.slug === "get-user-debt" + ); + expect(getUserDebt).toBeDefined(); + expect(getUserDebt?.type).toBe("read"); + expect(getUserDebt?.outputs).toHaveLength(2); + expect(getUserDebt?.outputs?.[0].name).toBe("drawnDebt"); + expect(getUserDebt?.outputs?.[1].name).toBe("premiumDebt"); + }); + + it("get-user-supplied-assets action has a single named output", () => { + const getSupplied = aaveV4Def.actions.find( + (a) => a.slug === "get-user-supplied-assets" + ); + expect(getSupplied).toBeDefined(); + expect(getSupplied?.type).toBe("read"); + expect(getSupplied?.outputs).toHaveLength(1); + expect(getSupplied?.outputs?.[0].name).toBe("suppliedAmount"); + }); + + it("supply/withdraw/borrow/repay writes expose their Solidity return values as named outputs", () => { + const expected: Record = { + supply: ["suppliedShares", "suppliedAmount"], + withdraw: ["withdrawnShares", "withdrawnAmount"], + borrow: ["drawnShares", "drawnAmount"], + repay: ["drawnSharesBurned", "totalAmountRepaid"], + }; + for (const [slug, [name0, name1]] of Object.entries(expected)) { + const action = aaveV4Def.actions.find((a) => a.slug === slug); + expect(action, `action "${slug}" not found`).toBeDefined(); + expect(action?.outputs).toHaveLength(2); + expect(action?.outputs?.[0].name).toBe(name0); + expect(action?.outputs?.[1].name).toBe(name1); + } + }); + + it("set-collateral write has no outputs (Solidity returns void)", () => { + const setCollateral = aaveV4Def.actions.find( + (a) => a.slug === "set-collateral" + ); + expect(setCollateral?.outputs).toBeUndefined(); + }); + + it("registers in the protocol registry and is retrievable", () => { + registerProtocol(aaveV4Def); + const retrieved = getProtocol("aave-v4"); + expect(retrieved).toBeDefined(); + expect(retrieved?.slug).toBe("aave-v4"); + expect(retrieved?.name).toBe("Aave V4"); + }); +}); From 044f5ebcc18e8fa807eeecc9357e1fb2f35eec3b Mon Sep 17 00:00:00 2001 From: Simon KP Date: Tue, 21 Apr 2026 15:46:09 +1000 Subject: [PATCH 23/41] feat(earnings): per-chain breakdown + docs tooltip on KPI cards Makes the dual-chain revenue split visible to creators on the Earnings page so balances on Base and Tempo are both obviously real, not a bug. - lib/earnings/types.ts: add perChain { base, tempo } with grossRevenue and invocationCount per chain. - lib/earnings/queries.ts: new chain-grouped aggregation over workflow_payments using workflow_payments.chain. Extracted buildPerChainEarnings() for unit testing and to guarantee the UI always receives a fixed { base, tempo } shape (missing chain defaults to zero, unknown chains are ignored). - components/earnings/earnings-kpi-cards.tsx: show the per-chain split as subtext on the Total Revenue and Total Invocations cards ("Base $X -- Tempo $Y"). Adds a HelpCircle info link on the Total Revenue and Earnings cards that opens the new paid-workflows docs page in a new tab. Part 2 of KEEP-259. Part 1 (PR #909) added the underlying docs pages. Not in scope (deliberately parked): first-visit dismissible explainer overlay on the wallet overlay. The inline help link + docs covers the disambiguation need; a separate dismissible modal adds surface area with unclear measurement of whether it's seen. Revisit if creator support volume shows the current affordance is insufficient. Related: KEEP-259, KEEP-176, KEEP-294. --- components/earnings/earnings-kpi-cards.tsx | 36 ++++++++++++- lib/earnings/queries.ts | 60 ++++++++++++++++++++++ lib/earnings/types.ts | 14 +++++ tests/unit/earnings-queries.test.ts | 51 ++++++++++++++++++ 4 files changed, 159 insertions(+), 2 deletions(-) diff --git a/components/earnings/earnings-kpi-cards.tsx b/components/earnings/earnings-kpi-cards.tsx index 22348089d..53c3d9895 100644 --- a/components/earnings/earnings-kpi-cards.tsx +++ b/components/earnings/earnings-kpi-cards.tsx @@ -1,7 +1,8 @@ "use client"; import { useAtomValue } from "jotai"; -import { Activity, DollarSign, TrendingUp } from "lucide-react"; +import { Activity, DollarSign, HelpCircle, TrendingUp } from "lucide-react"; +import Link from "next/link"; import type { ReactNode } from "react"; import { Card, CardContent } from "@/components/ui/card"; import { earningsDataAtom, earningsLoadingAtom } from "@/lib/atoms/earnings"; @@ -28,6 +29,8 @@ type KpiCardProps = { label: string; value: string; subtext?: string; + helpHref?: string; + helpTitle?: string; iconClassName?: string; }; @@ -36,6 +39,8 @@ function KpiCard({ label, value, subtext, + helpHref, + helpTitle, iconClassName, }: KpiCardProps): ReactNode { return ( @@ -43,7 +48,21 @@ function KpiCard({
-

{label}

+
+

{label}

+ {helpHref ? ( + + + + ) : null} +

{value}

{subtext ? (

{subtext}

@@ -88,18 +107,30 @@ export function EarningsKpiCards(): ReactNode { totalCreatorEarnings, totalInvocations, creatorSharePercent, + perChain, } = data; + // Revenue arrives on Base (x402/USDC) or Tempo (MPP/USDC.e) depending on + // which protocol the calling agent used. Showing the split inline prevents + // creators from treating a zero on one chain as a bug. + const revenueChainSplit = `Base ${perChain.base.grossRevenue} -- Tempo ${perChain.tempo.grossRevenue}`; + const invocationChainSplit = `Base ${perChain.base.invocationCount.toLocaleString()} -- Tempo ${perChain.tempo.invocationCount.toLocaleString()}`; + return (
} iconClassName="bg-green-500/10 text-green-600 dark:text-green-400" label="Total Revenue" + subtext={revenueChainSplit} value={totalGrossRevenue} /> } iconClassName="bg-keeperhub-green/10 text-keeperhub-green-dark" label="Earnings" @@ -110,6 +141,7 @@ export function EarningsKpiCards(): ReactNode { icon={} iconClassName="bg-blue-500/10 text-blue-600 dark:text-blue-400" label="Total Invocations" + subtext={invocationChainSplit} value={totalInvocations.toLocaleString()} />
diff --git a/lib/earnings/queries.ts b/lib/earnings/queries.ts index 15d54e8e4..8e2f75e62 100644 --- a/lib/earnings/queries.ts +++ b/lib/earnings/queries.ts @@ -5,6 +5,7 @@ import { db } from "@/lib/db"; import { workflows } from "@/lib/db/schema"; import { workflowPayments } from "@/lib/db/schema-payments"; import type { + ChainEarnings, EarningsSummary, SettlementStatus, WorkflowEarningsRow, @@ -138,6 +139,10 @@ export async function getEarningsSummary( totalInvocations: 0, platformFeePercent, creatorSharePercent, + perChain: { + base: { grossRevenue: formatUsdc(0), invocationCount: 0 }, + tempo: { grossRevenue: formatUsdc(0), invocationCount: 0 }, + }, workflows: [], total: 0, page, @@ -181,6 +186,20 @@ export async function getEarningsSummary( const totalGross = Number(orgTotals?.grossRevenue ?? "0"); const totalInvocations = orgTotals?.invocationCount ?? 0; + // Per-chain breakdown so creators see Base (x402/USDC) vs Tempo (MPP/USDC.e) + // split instead of just an aggregate. See docs/workflows/paid-workflows.md. + const perChainRows = await db + .select({ + chain: workflowPayments.chain, + grossRevenue: sum(workflowPayments.amountUsdc), + invocationCount: count(workflowPayments.id), + }) + .from(workflowPayments) + .where(inArray(workflowPayments.workflowId, orgWorkflowIds)) + .groupBy(workflowPayments.chain); + + const perChain = buildPerChainEarnings(perChainRows); + // Per-workflow revenue for the current page only const revenueRows = await db .select({ @@ -259,6 +278,7 @@ export async function getEarningsSummary( totalInvocations, platformFeePercent, creatorSharePercent, + perChain, workflows: paginatedRows, total, page, @@ -266,3 +286,43 @@ export async function getEarningsSummary( hasListedWorkflows: true, }; } + +type PerChainRow = { + chain: string; + grossRevenue: string | null; + invocationCount: number; +}; + +/** + * Reshapes the chain-grouped SQL result into the fixed { base, tempo } shape + * expected by the UI. Missing chains default to zero so the UI never has to + * null-check. + */ +export function buildPerChainEarnings(rows: PerChainRow[]): { + base: ChainEarnings; + tempo: ChainEarnings; +} { + const base: ChainEarnings = { + grossRevenue: formatUsdc(0), + invocationCount: 0, + }; + const tempo: ChainEarnings = { + grossRevenue: formatUsdc(0), + invocationCount: 0, + }; + for (const row of rows) { + const gross = Number(row.grossRevenue ?? "0"); + const entry: ChainEarnings = { + grossRevenue: formatUsdc(gross), + invocationCount: row.invocationCount, + }; + if (row.chain === "base") { + base.grossRevenue = entry.grossRevenue; + base.invocationCount = entry.invocationCount; + } else if (row.chain === "tempo") { + tempo.grossRevenue = entry.grossRevenue; + tempo.invocationCount = entry.invocationCount; + } + } + return { base, tempo }; +} diff --git a/lib/earnings/types.ts b/lib/earnings/types.ts index 32012d8b5..5779d5010 100644 --- a/lib/earnings/types.ts +++ b/lib/earnings/types.ts @@ -12,6 +12,16 @@ export type WorkflowEarningsRow = { settlementStatus: SettlementStatus; }; +/** + * Revenue split for a single settlement chain. `grossRevenue` is USDC on Base + * (x402) or USDC.e on Tempo (MPP) -- both pegged to USD, so summed totals are + * meaningful even across chains for display purposes. + */ +export type ChainEarnings = { + grossRevenue: string; + invocationCount: number; +}; + export type EarningsSummary = { totalGrossRevenue: string; totalCreatorEarnings: string; @@ -19,6 +29,10 @@ export type EarningsSummary = { totalInvocations: number; platformFeePercent: number; creatorSharePercent: number; + perChain: { + base: ChainEarnings; + tempo: ChainEarnings; + }; workflows: WorkflowEarningsRow[]; total: number; page: number; diff --git a/tests/unit/earnings-queries.test.ts b/tests/unit/earnings-queries.test.ts index 22530949f..d35e560b1 100644 --- a/tests/unit/earnings-queries.test.ts +++ b/tests/unit/earnings-queries.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("server-only", () => ({})); import { + buildPerChainEarnings, computeRevenueSplit, deriveSettlementStatus, formatUsdc, @@ -140,3 +141,53 @@ describe("groupTopCallers", () => { expect(result.size).toBe(0); }); }); + +describe("buildPerChainEarnings", () => { + it("returns zeros for both chains when no rows are present", () => { + const result = buildPerChainEarnings([]); + expect(result.base).toEqual({ + grossRevenue: "$0.00 USDC", + invocationCount: 0, + }); + expect(result.tempo).toEqual({ + grossRevenue: "$0.00 USDC", + invocationCount: 0, + }); + }); + + it("maps base and tempo chain rows into the fixed shape", () => { + const result = buildPerChainEarnings([ + { chain: "base", grossRevenue: "1.50", invocationCount: 15 }, + { chain: "tempo", grossRevenue: "0.75", invocationCount: 9 }, + ]); + expect(result.base.grossRevenue).toBe("$1.50 USDC"); + expect(result.base.invocationCount).toBe(15); + expect(result.tempo.grossRevenue).toBe("$0.75 USDC"); + expect(result.tempo.invocationCount).toBe(9); + }); + + it("leaves the missing chain at zero when only one chain has activity", () => { + const result = buildPerChainEarnings([ + { chain: "base", grossRevenue: "2.00", invocationCount: 20 }, + ]); + expect(result.base.grossRevenue).toBe("$2.00 USDC"); + expect(result.tempo.grossRevenue).toBe("$0.00 USDC"); + expect(result.tempo.invocationCount).toBe(0); + }); + + it("ignores unknown chain values without throwing", () => { + const result = buildPerChainEarnings([ + { chain: "solana", grossRevenue: "99.00", invocationCount: 1 }, + ]); + expect(result.base.grossRevenue).toBe("$0.00 USDC"); + expect(result.tempo.grossRevenue).toBe("$0.00 USDC"); + }); + + it("treats null grossRevenue as zero", () => { + const result = buildPerChainEarnings([ + { chain: "base", grossRevenue: null, invocationCount: 0 }, + ]); + expect(result.base.grossRevenue).toBe("$0.00 USDC"); + expect(result.base.invocationCount).toBe(0); + }); +}); From f5378ab65d04e8a092d9859d27579a7f82c9968f Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Wed, 15 Apr 2026 15:24:39 +1000 Subject: [PATCH 24/41] refactor: rename Aave V3 slug from "aave" to "aave-v3" Per review on PR #846 - the V3 protocol's short slug "aave" conflicts with the expectation that both V3 and V4 coexist clearly. Rename to "aave-v3" so version is explicit in every reference (IntegrationType, actionType, MCP tool invocations, featured_protocol metadata). Code changes: - protocols/aave-v3.ts: slug "aave" -> "aave-v3" - tests/unit/protocol-aave-v3.test.ts: assertions updated - lib/types/integration.ts, protocols/index.ts: auto-regenerated - lib/mcp/tools.ts: update example strings in tool descriptions - scripts/seed/workflows/aave/ renamed to aave-v3/, contents updated - scripts/pr-test/seed-pr-data.sql: actionType and protocolSlug updated - scripts/README.md, docs/api/workflows.md: example strings - .claude/commands/test-protocol.md: slug-mapping example Migration 0048_rename_aave_slug_to_v3.sql: Data-only migration (no schema change) that rewrites existing workflows in place: - workflows.featured_protocol: "aave" -> "aave-v3" - workflows.nodes (jsonb): each node's data.config.actionType from "aave/*" to "aave-v3/*", and the stringified data.config._protocolMeta.protocolSlug from "aave" to "aave-v3" Uses text-level REPLACE on the canonical JSONB text rendition. Two distinct patterns because PostgreSQL's JSONB canonicalization adds spaces after colons at the top level ("actionType": "aave/...") but JSON.stringify output inside _protocolMeta has no spaces (\"protocolSlug\":\"aave\"). Both patterns are key-scoped so free-form text containing the word "aave" (e.g. descriptions) is untouched. Verified locally against a seeded workflow: all three aave references correctly rewritten, web3/write-contract action and a description string containing the word "aave" were left alone. Re-running the migration is a no-op (idempotent). --- .claude/commands/test-protocol.md | 4 +- docs/api/workflows.md | 2 +- drizzle/0051_rename_aave_slug_to_v3.sql | 47 +++++++++++++++++++ ...{0048_snapshot.json => 0051_snapshot.json} | 40 +++++++++++----- drizzle/meta/_journal.json | 7 +++ lib/mcp/tools.ts | 6 +-- protocols/index.ts | 6 +-- scripts/README.md | 2 +- scripts/pr-test/seed-pr-data.sql | 2 +- .../mcp-test-supply-weth.json | 4 +- .../mcp-test-withdraw-weth.json | 4 +- .../{aave => aave-v3}/read-actions.json | 6 +-- .../{aave => aave-v3}/write-actions.json | 12 ++--- tests/unit/protocol-aave-v3.test.ts | 6 +-- 14 files changed, 109 insertions(+), 39 deletions(-) create mode 100644 drizzle/0051_rename_aave_slug_to_v3.sql rename drizzle/meta/{0048_snapshot.json => 0051_snapshot.json} (99%) rename scripts/seed/workflows/{aave => aave-v3}/mcp-test-supply-weth.json (96%) rename scripts/seed/workflows/{aave => aave-v3}/mcp-test-withdraw-weth.json (93%) rename scripts/seed/workflows/{aave => aave-v3}/read-actions.json (92%) rename scripts/seed/workflows/{aave => aave-v3}/write-actions.json (94%) diff --git a/.claude/commands/test-protocol.md b/.claude/commands/test-protocol.md index 144e64bdd..5651a0ea5 100644 --- a/.claude/commands/test-protocol.md +++ b/.claude/commands/test-protocol.md @@ -36,7 +36,7 @@ Read the protocol definition file: protocols/$ARGUMENTS.ts ``` -If it does not exist, check alternate names (e.g., `aave-v3.ts` for `aave`, `compound-v3.ts` for `compound`, `uniswap-v3.ts` for `uniswap`, `yearn-v3.ts` for `yearn`). +If it does not exist, check alternate names (e.g., `compound-v3.ts` for `compound`, `uniswap-v3.ts` for `uniswap`, `yearn-v3.ts` for `yearn`). Aave versions use explicit slugs: `aave-v3.ts` has slug `aave-v3`, `aave-v4.ts` has slug `aave-v4`. Extract: - Protocol name, slug, and description @@ -132,7 +132,7 @@ Save tested workflow configurations to `scripts/seed/workflows/$ARGUMENTS/` for After write tests, create and execute withdrawal workflows to recover deposited funds: - `vault-withdraw` / `vault-redeem` for ERC-4626 vaults -- Protocol-specific withdraw actions (e.g., `aave/withdraw`, `compound/withdraw`) +- Protocol-specific withdraw actions (e.g., `aave-v3/withdraw`, `compound/withdraw`) - Run withdrawals **sequentially** (same nonce contention concern) Verify final balances match expectations. diff --git a/docs/api/workflows.md b/docs/api/workflows.md index 988bdf386..72d2cf877 100644 --- a/docs/api/workflows.md +++ b/docs/api/workflows.md @@ -265,7 +265,7 @@ Returns distinct categories and protocols from all public workflows. Useful for ```json { "categories": ["defi", "nft"], - "protocols": ["uniswap", "aave"] + "protocols": ["uniswap", "aave-v3"] } ``` diff --git a/drizzle/0051_rename_aave_slug_to_v3.sql b/drizzle/0051_rename_aave_slug_to_v3.sql new file mode 100644 index 000000000..26f54faf1 --- /dev/null +++ b/drizzle/0051_rename_aave_slug_to_v3.sql @@ -0,0 +1,47 @@ +-- Rename Aave V3 protocol slug from "aave" to "aave-v3" so it coexists +-- cleanly with the new "aave-v4" slug. Data-only migration: no schema change. +-- +-- Affects: +-- * workflows.featured_protocol (text column) +-- * workflows.nodes (jsonb): each node's data.config.actionType ("aave/*") +-- and the stringified data.config._protocolMeta.protocolSlug ("aave") +-- +-- Strategy: text-level REPLACE on the canonical JSONB text rendition, then +-- cast back. Two distinct patterns exist: +-- +-- (1) Top-level JSONB fields: PostgreSQL canonicalizes with a space after +-- the colon, so the actionType field renders as "actionType": "aave/...". +-- +-- (2) The _protocolMeta value is itself a stringified JSON produced by +-- JSON.stringify() on the client. Default JSON.stringify output has NO +-- space after colons, so within that string the pattern is +-- \"protocolSlug\":\"aave\" (escaped quotes, no spaces). +-- +-- These patterns are key-scoped so free-form text containing the word "aave" +-- (e.g. a description field) is untouched. +-- +-- LIKE-escape note: LIKE treats backslash as its own escape character by +-- default. To match a literal backslash-quote sequence in the text form we +-- must double the backslashes in the LIKE pattern (so '\\"' after string +-- literal parsing becomes '\\"' which LIKE processes as \ + ") -- mirroring +-- the pattern used in 0048_rename_weth_to_wrapped.sql. REPLACE has no such +-- escape processing, so the REPLACE patterns keep single backslashes. + +UPDATE workflows +SET featured_protocol = 'aave-v3' +WHERE featured_protocol = 'aave'; +--> statement-breakpoint + +UPDATE workflows +SET nodes = REPLACE( + REPLACE( + nodes::text, + '"actionType": "aave/', + '"actionType": "aave-v3/' + ), + '\"protocolSlug\":\"aave\"', + '\"protocolSlug\":\"aave-v3\"' +)::jsonb +WHERE + nodes::text LIKE '%"actionType": "aave/%' + OR nodes::text LIKE '%\\"protocolSlug\\":\\"aave\\"%'; diff --git a/drizzle/meta/0048_snapshot.json b/drizzle/meta/0051_snapshot.json similarity index 99% rename from drizzle/meta/0048_snapshot.json rename to drizzle/meta/0051_snapshot.json index 6122d0f0d..bf9cff656 100644 --- a/drizzle/meta/0048_snapshot.json +++ b/drizzle/meta/0051_snapshot.json @@ -1,6 +1,6 @@ { - "id": "ef803367-2686-4d5a-9ecb-fa6f7ba3cfb2", - "prevId": "a9b21ef3-5400-418a-a731-71b47718eb14", + "id": "d0f93421-e64d-4982-9b9f-61c75c83e53e", + "prevId": "4d96b65e-dc8d-4755-856d-f8cf15412118", "version": "7", "dialect": "postgresql", "tables": { @@ -2559,6 +2559,13 @@ "primaryKey": false, "notNull": false }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -2567,7 +2574,24 @@ "default": "now()" } }, - "indexes": {}, + "indexes": { + "para_wallets_org_active_unique": { + "name": "para_wallets_org_active_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"para_wallets\".\"is_active\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { "para_wallets_user_id_users_id_fk": { "name": "para_wallets_user_id_users_id_fk", @@ -2597,15 +2621,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "para_wallets_organization_id_unique": { - "name": "para_wallets_organization_id_unique", - "nullsNotDistinct": false, - "columns": [ - "organization_id" - ] - } - }, + "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 515b8fc03..e52c8a47e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -358,6 +358,13 @@ "when": 1776441892552, "tag": "0050_curly_gorilla_man", "breakpoints": true + }, + { + "idx": 51, + "version": "7", + "when": 1776750254755, + "tag": "0051_rename_aave_slug_to_v3", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/mcp/tools.ts b/lib/mcp/tools.ts index 68b0d3dc1..3f9ebfe80 100644 --- a/lib/mcp/tools.ts +++ b/lib/mcp/tools.ts @@ -891,7 +891,7 @@ export function registerMetaTools( .string() .optional() .describe( - "Filter by protocol name (e.g., 'chronicle', 'aave', 'morpho', 'uniswap', 'compound', 'lido', 'chainlink')" + "Filter by protocol name (e.g., 'chronicle', 'aave-v3', 'morpho', 'uniswap', 'compound', 'lido', 'chainlink')" ), }, { @@ -968,12 +968,12 @@ export function registerMetaTools( // Meta-tool 2: Execute any protocol action by actionType server.tool( "execute_protocol_action", - "Execute a DeFi protocol action directly. Use search_protocol_actions first to discover available actions and their required parameters. The actionType follows the format 'protocol/action-slug' (e.g., 'chronicle/eth-usd-read', 'aave/supply', 'morpho/get-position'). Pass all required parameters in the params object.", + "Execute a DeFi protocol action directly. Use search_protocol_actions first to discover available actions and their required parameters. The actionType follows the format 'protocol/action-slug' (e.g., 'chronicle/eth-usd-read', 'aave-v3/supply', 'morpho/get-position'). Pass all required parameters in the params object.", { actionType: z .string() .describe( - "The action identifier in 'protocol/action-slug' format (e.g., 'chronicle/eth-usd-read', 'aave/get-user-account-data')" + "The action identifier in 'protocol/action-slug' format (e.g., 'chronicle/eth-usd-read', 'aave-v3/get-user-account-data')" ), params: z .record(z.string(), z.unknown()) diff --git a/protocols/index.ts b/protocols/index.ts index 805700e18..26b4a0923 100644 --- a/protocols/index.ts +++ b/protocols/index.ts @@ -14,7 +14,7 @@ import { protocolToPlugin, registerProtocol } from "@/lib/protocol-registry"; import { registerIntegration } from "@/plugins/registry"; -import aaveDef from "./aave-v3"; +import aaveV3Def from "./aave-v3"; import aaveV4Def from "./aave-v4"; import aerodromeDef from "./aerodrome"; import ajnaDef from "./ajna"; @@ -35,8 +35,8 @@ import uniswapDef from "./uniswap-v3"; import wrappedDef from "./wrapped"; import yearnDef from "./yearn-v3"; -registerProtocol(aaveDef); -registerIntegration(protocolToPlugin(aaveDef)); +registerProtocol(aaveV3Def); +registerIntegration(protocolToPlugin(aaveV3Def)); registerProtocol(aaveV4Def); registerIntegration(protocolToPlugin(aaveV4Def)); registerProtocol(aerodromeDef); diff --git a/scripts/README.md b/scripts/README.md index b7ed22a0e..8914702af 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -70,7 +70,7 @@ The API also supports protocol-level featuring (e.g. featured workflows on a pro ```json { "workflowId": "", - "featuredProtocol": "aave", + "featuredProtocol": "aave-v3", "featuredProtocolOrder": 1 } ``` diff --git a/scripts/pr-test/seed-pr-data.sql b/scripts/pr-test/seed-pr-data.sql index e3c4519b0..aa5a3d50e 100644 --- a/scripts/pr-test/seed-pr-data.sql +++ b/scripts/pr-test/seed-pr-data.sql @@ -148,7 +148,7 @@ BEGIN false, false, 0, - '[{"id":"trigger-1","type":"trigger","position":{"x":100,"y":200},"data":{"type":"trigger","label":"Manual Trigger","config":{"triggerType":"Manual"}}},{"id":"action-1","type":"action","position":{"x":400,"y":200},"data":{"type":"action","label":"Wrap ETH to Aave WETH","config":{"actionType":"web3/write-contract","network":"sepolia","contractAddress":"0xC558DBdd856501FCd9aaF1E62eae57A9F0629a3c","abi":"[{\"inputs\":[],\"name\":\"deposit\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"}]","abiFunction":"deposit","functionArgs":"[]","ethValue":"0.001"}}},{"id":"action-2","type":"action","position":{"x":700,"y":200},"data":{"type":"action","label":"Approve ERC20 Token","config":{"actionType":"web3/approve-token","network":"sepolia","tokenConfig":"{\"mode\":\"custom\",\"customToken\":{\"address\":\"0xC558DBdd856501FCd9aaF1E62eae57A9F0629a3c\",\"symbol\":\"WETH\"}}","spenderAddress":"0x6Ae43d3271ff6888e7Fc43Fd7321a503ff738951","amount":"max"}}},{"id":"action-3","type":"action","position":{"x":1000,"y":200},"data":{"type":"action","label":"Aave V3: Get User Account Data","config":{"actionType":"aave/get-user-account-data","network":"11155111","user":"0x4f1089424DCf25B1290631Df483a436B320e51A1","_protocolMeta":"{\"protocolSlug\":\"aave\",\"contractKey\":\"pool\",\"functionName\":\"getUserAccountData\",\"actionType\":\"read\"}"}}},{"id":"action-4","type":"action","position":{"x":1300,"y":200},"data":{"type":"action","label":"Aave V3: Supply Asset","config":{"actionType":"aave/supply","network":"11155111","asset":"0xC558DBdd856501FCd9aaF1E62eae57A9F0629a3c","amount":"1000000000000000","onBehalfOf":"0x4f1089424DCf25B1290631Df483a436B320e51A1","referralCode":"0","_protocolMeta":"{\"protocolSlug\":\"aave\",\"contractKey\":\"pool\",\"functionName\":\"supply\",\"actionType\":\"write\"}"}}}]'::jsonb, + '[{"id":"trigger-1","type":"trigger","position":{"x":100,"y":200},"data":{"type":"trigger","label":"Manual Trigger","config":{"triggerType":"Manual"}}},{"id":"action-1","type":"action","position":{"x":400,"y":200},"data":{"type":"action","label":"Wrap ETH to Aave WETH","config":{"actionType":"web3/write-contract","network":"sepolia","contractAddress":"0xC558DBdd856501FCd9aaF1E62eae57A9F0629a3c","abi":"[{\"inputs\":[],\"name\":\"deposit\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"}]","abiFunction":"deposit","functionArgs":"[]","ethValue":"0.001"}}},{"id":"action-2","type":"action","position":{"x":700,"y":200},"data":{"type":"action","label":"Approve ERC20 Token","config":{"actionType":"web3/approve-token","network":"sepolia","tokenConfig":"{\"mode\":\"custom\",\"customToken\":{\"address\":\"0xC558DBdd856501FCd9aaF1E62eae57A9F0629a3c\",\"symbol\":\"WETH\"}}","spenderAddress":"0x6Ae43d3271ff6888e7Fc43Fd7321a503ff738951","amount":"max"}}},{"id":"action-3","type":"action","position":{"x":1000,"y":200},"data":{"type":"action","label":"Aave V3: Get User Account Data","config":{"actionType":"aave-v3/get-user-account-data","network":"11155111","user":"0x4f1089424DCf25B1290631Df483a436B320e51A1","_protocolMeta":"{\"protocolSlug\":\"aave-v3\",\"contractKey\":\"pool\",\"functionName\":\"getUserAccountData\",\"actionType\":\"read\"}"}}},{"id":"action-4","type":"action","position":{"x":1300,"y":200},"data":{"type":"action","label":"Aave V3: Supply Asset","config":{"actionType":"aave-v3/supply","network":"11155111","asset":"0xC558DBdd856501FCd9aaF1E62eae57A9F0629a3c","amount":"1000000000000000","onBehalfOf":"0x4f1089424DCf25B1290631Df483a436B320e51A1","referralCode":"0","_protocolMeta":"{\"protocolSlug\":\"aave-v3\",\"contractKey\":\"pool\",\"functionName\":\"supply\",\"actionType\":\"write\"}"}}}]'::jsonb, '[{"id":"edge-trigger-1-action-1","source":"trigger-1","target":"action-1","type":"default"},{"id":"edge-action-1-action-2","source":"action-1","target":"action-2","type":"default"},{"id":"edge-action-2-action-3","source":"action-2","target":"action-3","type":"default"},{"id":"edge-action-3-action-4","source":"action-3","target":"action-4","type":"default"}]'::jsonb, 'private', true, diff --git a/scripts/seed/workflows/aave/mcp-test-supply-weth.json b/scripts/seed/workflows/aave-v3/mcp-test-supply-weth.json similarity index 96% rename from scripts/seed/workflows/aave/mcp-test-supply-weth.json rename to scripts/seed/workflows/aave-v3/mcp-test-supply-weth.json index 188d7a76a..dd45a30d5 100644 --- a/scripts/seed/workflows/aave/mcp-test-supply-weth.json +++ b/scripts/seed/workflows/aave-v3/mcp-test-supply-weth.json @@ -1,5 +1,5 @@ { - "protocol": "aave", + "protocol": "aave-v3", "network": "1", "networkName": "mainnet", "type": "write", @@ -42,7 +42,7 @@ "label": "Supply WETH to Aave", "type": "action", "config": { - "actionType": "aave/supply", + "actionType": "aave-v3/supply", "network": "1", "asset": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "amount": "3000000000000000", diff --git a/scripts/seed/workflows/aave/mcp-test-withdraw-weth.json b/scripts/seed/workflows/aave-v3/mcp-test-withdraw-weth.json similarity index 93% rename from scripts/seed/workflows/aave/mcp-test-withdraw-weth.json rename to scripts/seed/workflows/aave-v3/mcp-test-withdraw-weth.json index 294a19607..4404ef4b9 100644 --- a/scripts/seed/workflows/aave/mcp-test-withdraw-weth.json +++ b/scripts/seed/workflows/aave-v3/mcp-test-withdraw-weth.json @@ -1,5 +1,5 @@ { - "protocol": "aave", + "protocol": "aave-v3", "network": "1", "networkName": "mainnet", "type": "write", @@ -25,7 +25,7 @@ "label": "Withdraw WETH from Aave", "type": "action", "config": { - "actionType": "aave/withdraw", + "actionType": "aave-v3/withdraw", "network": "1", "asset": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "amount": "3000000000000000", diff --git a/scripts/seed/workflows/aave/read-actions.json b/scripts/seed/workflows/aave-v3/read-actions.json similarity index 92% rename from scripts/seed/workflows/aave/read-actions.json rename to scripts/seed/workflows/aave-v3/read-actions.json index 9e6767e5d..2d4561ef2 100644 --- a/scripts/seed/workflows/aave/read-actions.json +++ b/scripts/seed/workflows/aave-v3/read-actions.json @@ -1,5 +1,5 @@ { - "protocol": "aave", + "protocol": "aave-v3", "network": "1", "networkName": "mainnet", "type": "read", @@ -26,7 +26,7 @@ "description": "Get overall account health for a known Aave user", "type": "action", "config": { - "actionType": "aave/get-user-account-data", + "actionType": "aave-v3/get-user-account-data", "network": "1", "user": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" }, @@ -42,7 +42,7 @@ "description": "Get per-asset position data for DAI", "type": "action", "config": { - "actionType": "aave/get-user-reserve-data", + "actionType": "aave-v3/get-user-reserve-data", "network": "1", "asset": "0x6B175474E89094C44Da98b954EedeAC495271d0F", "user": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" diff --git a/scripts/seed/workflows/aave/write-actions.json b/scripts/seed/workflows/aave-v3/write-actions.json similarity index 94% rename from scripts/seed/workflows/aave/write-actions.json rename to scripts/seed/workflows/aave-v3/write-actions.json index 4f65ef13a..6eaa5c41e 100644 --- a/scripts/seed/workflows/aave/write-actions.json +++ b/scripts/seed/workflows/aave-v3/write-actions.json @@ -1,5 +1,5 @@ { - "protocol": "aave", + "protocol": "aave-v3", "network": "11155111", "networkName": "sepolia", "type": "write", @@ -26,7 +26,7 @@ "description": "Supply DAI to Aave V3 on Sepolia", "type": "action", "config": { - "actionType": "aave/supply", + "actionType": "aave-v3/supply", "network": "11155111", "asset": "0xFF34B3d4Aee8ddCd6F9AFFFB6Fe49bD371b8a357", "amount": "1000000000000000000", @@ -45,7 +45,7 @@ "description": "Withdraw DAI from Aave V3 on Sepolia", "type": "action", "config": { - "actionType": "aave/withdraw", + "actionType": "aave-v3/withdraw", "network": "11155111", "asset": "0xFF34B3d4Aee8ddCd6F9AFFFB6Fe49bD371b8a357", "amount": "1000000000000000000", @@ -63,7 +63,7 @@ "description": "Borrow DAI from Aave V3 on Sepolia", "type": "action", "config": { - "actionType": "aave/borrow", + "actionType": "aave-v3/borrow", "network": "11155111", "asset": "0xFF34B3d4Aee8ddCd6F9AFFFB6Fe49bD371b8a357", "amount": "1000000000000000000", @@ -83,7 +83,7 @@ "description": "Repay DAI to Aave V3 on Sepolia", "type": "action", "config": { - "actionType": "aave/repay", + "actionType": "aave-v3/repay", "network": "11155111", "asset": "0xFF34B3d4Aee8ddCd6F9AFFFB6Fe49bD371b8a357", "amount": "1000000000000000000", @@ -102,7 +102,7 @@ "description": "Enable DAI as collateral on Aave V3 Sepolia", "type": "action", "config": { - "actionType": "aave/set-collateral", + "actionType": "aave-v3/set-collateral", "network": "11155111", "asset": "0xFF34B3d4Aee8ddCd6F9AFFFB6Fe49bD371b8a357", "useAsCollateral": "true" diff --git a/tests/unit/protocol-aave-v3.test.ts b/tests/unit/protocol-aave-v3.test.ts index dd03ded30..955a135e6 100644 --- a/tests/unit/protocol-aave-v3.test.ts +++ b/tests/unit/protocol-aave-v3.test.ts @@ -9,7 +9,7 @@ describe("Aave V3 Protocol Definition", () => { it("imports without throwing", () => { expect(aaveV3Def).toBeDefined(); expect(aaveV3Def.name).toBe("Aave V3"); - expect(aaveV3Def.slug).toBe("aave"); + expect(aaveV3Def.slug).toBe("aave-v3"); }); it("protocol slug is valid kebab-case", () => { @@ -131,9 +131,9 @@ describe("Aave V3 Protocol Definition", () => { it("registers in the protocol registry and is retrievable", () => { registerProtocol(aaveV3Def); - const retrieved = getProtocol("aave"); + const retrieved = getProtocol("aave-v3"); expect(retrieved).toBeDefined(); - expect(retrieved?.slug).toBe("aave"); + expect(retrieved?.slug).toBe("aave-v3"); expect(retrieved?.name).toBe("Aave V3"); }); }); From 25db005f4444e0e59dcb9255fad979d76dbd56d2 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Thu, 16 Apr 2026 12:36:25 +1000 Subject: [PATCH 25/41] test: add Aave V4 on-chain integration tests (mainnet) 5 integration tests verifying the ABI-driven protocol definition produces valid calldata against the live Lido Spoke contract on Ethereum mainnet. Gated on INTEGRATION_TEST_MAINNET_RPC_URL (separate from the Sepolia-targeting INTEGRATION_TEST_RPC_URL used by WETH/web3-write tests) because no Sepolia V4 deployment exists. Tests: - getReserveId: eth_call returns decodable uint256 - getUserSuppliedAssets: eth_call returns decodable uint256 - getUserDebt: eth_call returns two decodable uint256 values - supply: calldata encodes correctly (business revert, no ABI errors) - setUsingAsCollateral: calldata encodes correctly (business revert) All 5 pass against a public mainnet RPC (eth.llamarpc.com). This confirms the Lido Spoke address is valid, the reduced ABI matches the deployed bytecode, and the contract implements ISpoke. --- .../protocol-aave-v4-onchain.test.ts | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 tests/integration/protocol-aave-v4-onchain.test.ts diff --git a/tests/integration/protocol-aave-v4-onchain.test.ts b/tests/integration/protocol-aave-v4-onchain.test.ts new file mode 100644 index 000000000..ba2df8dac --- /dev/null +++ b/tests/integration/protocol-aave-v4-onchain.test.ts @@ -0,0 +1,191 @@ +/** + * Aave V4 On-Chain Integration Tests (Lido Spoke) + * + * Verifies that the ABI-driven Aave V4 protocol definition produces valid + * calldata that the deployed Lido Spoke contract accepts. Runs against a + * live Ethereum mainnet RPC endpoint. + * + * Uses a separate env var (INTEGRATION_TEST_MAINNET_RPC_URL) because Aave V4 + * has no Sepolia deployment - the existing INTEGRATION_TEST_RPC_URL targets + * Sepolia and would produce address mismatches. + * + * Gated on INTEGRATION_TEST_MAINNET_RPC_URL - skipped in CI without it. + */ + +import { ethers } from "ethers"; +import { describe, expect, it } from "vitest"; +import { reshapeArgsForAbi } from "@/lib/abi-struct-args"; +import type { + ProtocolAction, + ProtocolContract, + ProtocolDefinition, +} from "@/lib/protocol-registry"; +import aaveV4Def from "@/protocols/aave-v4"; + +const RPC_URL = process.env.INTEGRATION_TEST_MAINNET_RPC_URL; +const CHAIN_ID = "1"; +const TEST_ADDRESS = "0x0000000000000000000000000000000000000001"; +const CORE_HUB = "0xCca852Bc40e560adC3b1Cc58CA5b55638ce826c9"; + +function buildCalldata( + protocol: ProtocolDefinition, + actionSlug: string, + sampleInputs: Record +): { + to: string; + data: string; + action: ProtocolAction; + contract: ProtocolContract; +} { + const action = protocol.actions.find((a) => a.slug === actionSlug); + if (!action) { + throw new Error(`Action ${actionSlug} not found`); + } + + const contract = protocol.contracts[action.contract]; + if (!contract.abi) { + throw new Error(`Contract ${action.contract} has no ABI`); + } + + const contractAddress = contract.addresses[CHAIN_ID]; + if (!contractAddress) { + throw new Error(`Contract ${action.contract} not on chain ${CHAIN_ID}`); + } + + const rawArgs = action.inputs.map((inp) => { + const val = sampleInputs[inp.name] ?? inp.default ?? ""; + return val; + }); + + const abi = JSON.parse(contract.abi); + const functionAbi = abi.find( + (f: { name: string; type: string }) => + f.type === "function" && f.name === action.function + ); + const args = reshapeArgsForAbi(rawArgs, functionAbi); + const iface = new ethers.Interface(abi); + const data = iface.encodeFunctionData(action.function, args); + + return { to: contractAddress, data, action, contract }; +} + +describe.skipIf(!RPC_URL)("Aave V4 Lido Spoke on-chain integration", () => { + const getProvider = (): ethers.JsonRpcProvider => + new ethers.JsonRpcProvider(RPC_URL); + + it("getReserveId: eth_call returns a decodable uint256", async () => { + const { to, data, contract } = buildCalldata(aaveV4Def, "get-reserve-id", { + hub: CORE_HUB, + assetId: "0", + }); + + const provider = getProvider(); + try { + const result = await provider.call({ to, data }); + const abi = JSON.parse(contract.abi as string); + const iface = new ethers.Interface(abi); + const decoded = iface.decodeFunctionResult("getReserveId", result); + expect(decoded).toBeDefined(); + expect(typeof decoded[0]).toBe("bigint"); + } catch (error) { + const msg = String(error); + expect(msg).not.toContain("INVALID_ARGUMENT"); + expect(msg).not.toContain("could not decode"); + expect(msg).not.toContain("invalid function"); + } + }, 15_000); + + it("getUserSuppliedAssets: eth_call returns a decodable uint256", async () => { + const { to, data, contract } = buildCalldata( + aaveV4Def, + "get-user-supplied-assets", + { reserveId: "0", user: TEST_ADDRESS } + ); + + const provider = getProvider(); + try { + const result = await provider.call({ to, data }); + const abi = JSON.parse(contract.abi as string); + const iface = new ethers.Interface(abi); + const decoded = iface.decodeFunctionResult( + "getUserSuppliedAssets", + result + ); + expect(decoded).toBeDefined(); + expect(typeof decoded[0]).toBe("bigint"); + } catch (error) { + const msg = String(error); + expect(msg).not.toContain("INVALID_ARGUMENT"); + expect(msg).not.toContain("could not decode"); + expect(msg).not.toContain("invalid function"); + } + }, 15_000); + + it("getUserDebt: eth_call returns two decodable uint256 values", async () => { + const { to, data, contract } = buildCalldata(aaveV4Def, "get-user-debt", { + reserveId: "0", + user: TEST_ADDRESS, + }); + + const provider = getProvider(); + try { + const result = await provider.call({ to, data }); + const abi = JSON.parse(contract.abi as string); + const iface = new ethers.Interface(abi); + const decoded = iface.decodeFunctionResult("getUserDebt", result); + expect(decoded).toBeDefined(); + expect(decoded.length).toBeGreaterThanOrEqual(2); + expect(typeof decoded[0]).toBe("bigint"); + expect(typeof decoded[1]).toBe("bigint"); + } catch (error) { + const msg = String(error); + expect(msg).not.toContain("INVALID_ARGUMENT"); + expect(msg).not.toContain("could not decode"); + expect(msg).not.toContain("invalid function"); + } + }, 15_000); + + it("supply: calldata encodes correctly (business revert expected)", async () => { + const { to, data } = buildCalldata(aaveV4Def, "supply", { + reserveId: "0", + amount: "1000000000000000000", + onBehalfOf: TEST_ADDRESS, + }); + + const provider = getProvider(); + try { + await provider.estimateGas({ + to, + data, + from: TEST_ADDRESS, + }); + } catch (error) { + const msg = String(error); + expect(msg).not.toContain("INVALID_ARGUMENT"); + expect(msg).not.toContain("could not decode"); + expect(msg).not.toContain("invalid function"); + } + }, 15_000); + + it("setUsingAsCollateral: calldata encodes correctly (business revert expected)", async () => { + const { to, data } = buildCalldata(aaveV4Def, "set-collateral", { + reserveId: "0", + usingAsCollateral: "true", + onBehalfOf: TEST_ADDRESS, + }); + + const provider = getProvider(); + try { + await provider.estimateGas({ + to, + data, + from: TEST_ADDRESS, + }); + } catch (error) { + const msg = String(error); + expect(msg).not.toContain("INVALID_ARGUMENT"); + expect(msg).not.toContain("could not decode"); + expect(msg).not.toContain("invalid function"); + } + }, 15_000); +}); From 752a38d83a7b90d5c52a450dbcfb40384bb8b2d0 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Thu, 16 Apr 2026 12:58:44 +1000 Subject: [PATCH 26/41] feat: add getUserAccountData to Aave V4 protocol definition Adds the getUserAccountData read action which returns a UserAccountData struct (tuple) with 7 fields: riskPremium, avgCollateralFactor, healthFactor, totalCollateralValue, totalDebtValueRay, activeCollateralCount, borrowCount. Verified at the code level that this works end-to-end: - readContractCore.ts:238-252 unwraps single unnamed tuple returns to the raw decoded object, making struct fields directly accessible - The template engine (workflow-executor.ts:454-482) supports dotted paths: {{@node:Label.result.healthFactor}} resolves correctly - Unit tests in template.test.ts confirm nested path access The UI output picker shows one "accountData" entry (type tuple) rather than 7 individual fields - users discover the subfields via the action description or MCP schema. Acceptable for v1. Also resolves the reserveId UX question: chaining get-reserve-id output into supply/borrow via template refs is the standard supported pattern. No new field type needed. Closes both remaining draft caveats from PR #846. --- protocols/aave-v4.ts | 16 +++++++++++ protocols/abis/aave-v4.json | 21 ++++++++++++++ .../protocol-aave-v4-onchain.test.ts | 28 +++++++++++++++++++ tests/unit/protocol-aave-v4.test.ts | 23 ++++++++++++--- 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/protocols/aave-v4.ts b/protocols/aave-v4.ts index 11f1f3250..fb2cd75d2 100644 --- a/protocols/aave-v4.ts +++ b/protocols/aave-v4.ts @@ -179,6 +179,22 @@ export default defineAbiProtocol({ }, }, }, + getUserAccountData: { + slug: "get-user-account-data", + label: "Get User Account Data", + description: + "Get overall account health including collateral value, debt, health factor, and risk premium. Returns a struct - access individual fields via dotted path (e.g. result.healthFactor).", + inputs: { + user: { label: "User Address" }, + }, + outputs: { + result: { + name: "accountData", + label: + "Account Data (struct: riskPremium, avgCollateralFactor, healthFactor, totalCollateralValue, totalDebtValueRay, activeCollateralCount, borrowCount)", + }, + }, + }, }, }, }, diff --git a/protocols/abis/aave-v4.json b/protocols/abis/aave-v4.json index d044f6e40..b0d816068 100644 --- a/protocols/abis/aave-v4.json +++ b/protocols/abis/aave-v4.json @@ -98,5 +98,26 @@ { "name": "", "type": "uint256" }, { "name": "", "type": "uint256" } ] + }, + { + "type": "function", + "name": "getUserAccountData", + "stateMutability": "view", + "inputs": [{ "name": "user", "type": "address" }], + "outputs": [ + { + "name": "", + "type": "tuple", + "components": [ + { "name": "riskPremium", "type": "uint256" }, + { "name": "avgCollateralFactor", "type": "uint256" }, + { "name": "healthFactor", "type": "uint256" }, + { "name": "totalCollateralValue", "type": "uint256" }, + { "name": "totalDebtValueRay", "type": "uint256" }, + { "name": "activeCollateralCount", "type": "uint256" }, + { "name": "borrowCount", "type": "uint256" } + ] + } + ] } ] diff --git a/tests/integration/protocol-aave-v4-onchain.test.ts b/tests/integration/protocol-aave-v4-onchain.test.ts index ba2df8dac..e1c01ba44 100644 --- a/tests/integration/protocol-aave-v4-onchain.test.ts +++ b/tests/integration/protocol-aave-v4-onchain.test.ts @@ -167,6 +167,34 @@ describe.skipIf(!RPC_URL)("Aave V4 Lido Spoke on-chain integration", () => { } }, 15_000); + it("getUserAccountData: eth_call returns a decodable struct with named fields", async () => { + const { to, data, contract } = buildCalldata( + aaveV4Def, + "get-user-account-data", + { user: TEST_ADDRESS } + ); + + const provider = getProvider(); + try { + const result = await provider.call({ to, data }); + const abi = JSON.parse(contract.abi as string); + const iface = new ethers.Interface(abi); + const decoded = iface.decodeFunctionResult("getUserAccountData", result); + expect(decoded).toBeDefined(); + const struct = decoded[0]; + expect(struct.healthFactor).toBeDefined(); + expect(typeof struct.healthFactor).toBe("bigint"); + expect(struct.totalCollateralValue).toBeDefined(); + expect(struct.riskPremium).toBeDefined(); + expect(struct.borrowCount).toBeDefined(); + } catch (error) { + const msg = String(error); + expect(msg).not.toContain("INVALID_ARGUMENT"); + expect(msg).not.toContain("could not decode"); + expect(msg).not.toContain("invalid function"); + } + }, 15_000); + it("setUsingAsCollateral: calldata encodes correctly (business revert expected)", async () => { const { to, data } = buildCalldata(aaveV4Def, "set-collateral", { reserveId: "0", diff --git a/tests/unit/protocol-aave-v4.test.ts b/tests/unit/protocol-aave-v4.test.ts index 369fc9648..7698716f1 100644 --- a/tests/unit/protocol-aave-v4.test.ts +++ b/tests/unit/protocol-aave-v4.test.ts @@ -59,8 +59,8 @@ describe("Aave V4 Protocol Definition (ABI-driven)", () => { } }); - it("has 8 actions covering V3 parity + reserveId resolver", () => { - expect(aaveV4Def.actions).toHaveLength(8); + it("has 9 actions covering V3 parity + reserveId resolver + getUserAccountData", () => { + expect(aaveV4Def.actions).toHaveLength(9); const slugs = aaveV4Def.actions.map((a) => a.slug); expect(slugs).toEqual( expect.arrayContaining([ @@ -72,14 +72,15 @@ describe("Aave V4 Protocol Definition (ABI-driven)", () => { "get-reserve-id", "get-user-supplied-assets", "get-user-debt", + "get-user-account-data", ]) ); }); - it("has 5 write actions and 3 read actions", () => { + it("has 5 write actions and 4 read actions", () => { const reads = aaveV4Def.actions.filter((a) => a.type === "read"); const writes = aaveV4Def.actions.filter((a) => a.type === "write"); - expect(reads).toHaveLength(3); + expect(reads).toHaveLength(4); expect(writes).toHaveLength(5); }); @@ -171,6 +172,20 @@ describe("Aave V4 Protocol Definition (ABI-driven)", () => { } }); + it("get-user-account-data returns a single tuple output", () => { + const action = aaveV4Def.actions.find( + (a) => a.slug === "get-user-account-data" + ); + expect(action).toBeDefined(); + expect(action?.type).toBe("read"); + expect(action?.function).toBe("getUserAccountData"); + expect(action?.inputs).toHaveLength(1); + expect(action?.inputs[0].name).toBe("user"); + expect(action?.outputs).toHaveLength(1); + expect(action?.outputs?.[0].name).toBe("accountData"); + expect(action?.outputs?.[0].type).toBe("tuple"); + }); + it("set-collateral write has no outputs (Solidity returns void)", () => { const setCollateral = aaveV4Def.actions.find( (a) => a.slug === "set-collateral" From e1ddb440326e96d5309ba72326cfb37325aa0ee7 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Thu, 16 Apr 2026 13:28:01 +1000 Subject: [PATCH 27/41] fix: restrict protocol chain selector to deployed chains only Protocol actions were showing all EVM chains in the network dropdown, including chains where the protocol has no contract deployed. Users could select an unsupported chain and the workflow would fail at runtime. Added allowedChainIds to ActionConfigFieldBase. buildConfigFieldsFrom Action now computes this from the contract's addresses map and passes it to the chain-select field. ChainSelectField filters the fetched chain list by the allowed set. Affects all protocols - every protocol's chain selector now only shows chains where it has a contract address. --- .../workflow/config/chain-select-field.tsx | 18 +++++++++++++++--- lib/extensions.tsx | 1 + lib/protocol-registry.ts | 2 ++ plugins/registry.ts | 3 +++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/components/workflow/config/chain-select-field.tsx b/components/workflow/config/chain-select-field.tsx index cf3c46d43..b09dadf49 100644 --- a/components/workflow/config/chain-select-field.tsx +++ b/components/workflow/config/chain-select-field.tsx @@ -49,6 +49,11 @@ type ChainSelectFieldProps = { * keys (used to set usePrivateMempool alongside network). */ onUpdateConfig?: (key: string, value: unknown) => void; + /** + * Restrict to specific chain IDs (e.g., ["1", "8453"]). + * Used by protocol actions to show only chains where the contract is deployed. + */ + allowedChainIds?: string[]; }; /** @@ -75,6 +80,7 @@ export function ChainSelectField({ chainTypeFilter, showPrivateVariants, onUpdateConfig, + allowedChainIds, }: ChainSelectFieldProps) { const [chains, setChains] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -93,11 +99,17 @@ export function ChainSelectField({ const data = (await response.json()) as Chain[]; - // Filter by chain type if specified - const filteredChains = chainTypeFilter + let filteredChains = chainTypeFilter ? data.filter((chain) => chain.chainType === chainTypeFilter) : data; + if (allowedChainIds && allowedChainIds.length > 0) { + const allowed = new Set(allowedChainIds); + filteredChains = filteredChains.filter((chain) => + allowed.has(String(chain.chainId)) + ); + } + setChains(filteredChains); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load chains"); @@ -107,7 +119,7 @@ export function ChainSelectField({ } fetchChains(); - }, [chainTypeFilter]); + }, [chainTypeFilter, allowedChainIds]); if (isLoading) { return ( diff --git a/lib/extensions.tsx b/lib/extensions.tsx index abbf91c41..00a4c87db 100644 --- a/lib/extensions.tsx +++ b/lib/extensions.tsx @@ -88,6 +88,7 @@ registerFieldRenderer(
0 ? { allowedChainIds } : {}), }, ]; diff --git a/plugins/registry.ts b/plugins/registry.ts index e00cdb96e..bfa3493ab 100644 --- a/plugins/registry.ts +++ b/plugins/registry.ts @@ -51,6 +51,9 @@ export type ActionConfigFieldBase = { // For chain-select: filter by chain type (e.g., "evm" or "solana") chainTypeFilter?: string; + // For chain-select: restrict to specific chain IDs (e.g., ["1", "8453"]) + allowedChainIds?: string[]; + // Placeholder text placeholder?: string; From 88958620c78b4af467a1d3f046ee0258f8858214 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Thu, 16 Apr 2026 13:43:51 +1000 Subject: [PATCH 28/41] feat: link Aave V4 field tooltips to official docs Adds docUrl to input overrides with helpTips. Uses the existing ProtocolFieldLabel infrastructure which renders cursor-pointer on the info icon and opens the URL in a new tab when the tooltip or icon is clicked. Field linking: - supply/withdraw reserveId -> aave.com/docs/aave-v4/liquidity/spokes - borrow/repay/getUserDebt reserveId -> .../positions/borrow - setUsingAsCollateral bool, getUserSuppliedAssets reserveId -> .../positions/supply - getReserveId assetId -> .../liquidity/spokes - getUserAccountData user -> .../positions --- protocols/aave-v4.ts | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/protocols/aave-v4.ts b/protocols/aave-v4.ts index fb2cd75d2..5a413698f 100644 --- a/protocols/aave-v4.ts +++ b/protocols/aave-v4.ts @@ -41,6 +41,7 @@ export default defineAbiProtocol({ label: "Reserve ID", helpTip: "Opaque uint256 identifier for a reserve within this Spoke. Use the Get Reserve ID action to resolve from (hub, assetId).", + docUrl: "https://aave.com/docs/aave-v4/liquidity/spokes", }, amount: { label: "Amount (wei)" }, onBehalfOf: { label: "On Behalf Of Address" }, @@ -58,7 +59,10 @@ export default defineAbiProtocol({ label: "Withdraw Asset", description: "Withdraw a supplied asset from the Aave V4 Lido Spoke", inputs: { - reserveId: { label: "Reserve ID" }, + reserveId: { + label: "Reserve ID", + docUrl: "https://aave.com/docs/aave-v4/liquidity/spokes", + }, amount: { label: "Amount (wei)" }, onBehalfOf: { label: "Recipient Address" }, }, @@ -76,7 +80,10 @@ export default defineAbiProtocol({ description: "Borrow an asset from the Aave V4 Lido Spoke against supplied collateral. V4 uses a single rate model (no stable/variable mode).", inputs: { - reserveId: { label: "Reserve ID" }, + reserveId: { + label: "Reserve ID", + docUrl: "https://aave.com/docs/aave-v4/positions/borrow", + }, amount: { label: "Amount (wei)" }, onBehalfOf: { label: "On Behalf Of Address" }, }, @@ -93,7 +100,10 @@ export default defineAbiProtocol({ label: "Repay Debt", description: "Repay a borrowed asset to the Aave V4 Lido Spoke", inputs: { - reserveId: { label: "Reserve ID" }, + reserveId: { + label: "Reserve ID", + docUrl: "https://aave.com/docs/aave-v4/positions/borrow", + }, amount: { label: "Amount (wei)" }, onBehalfOf: { label: "On Behalf Of Address" }, }, @@ -119,6 +129,7 @@ export default defineAbiProtocol({ label: "Use as Collateral", helpTip: "Toggles the entire supplied balance of this reserve as collateral. There is no partial collateral in Aave V4.", + docUrl: "https://aave.com/docs/aave-v4/positions/supply", }, onBehalfOf: { label: "On Behalf Of Address" }, }, @@ -134,6 +145,7 @@ export default defineAbiProtocol({ label: "Hub Asset ID", helpTip: "Asset identifier within the Hub. Use the Hub's getAssetId(underlying) to resolve from an ERC-20 address.", + docUrl: "https://aave.com/docs/aave-v4/liquidity/spokes", }, }, outputs: { @@ -149,7 +161,10 @@ export default defineAbiProtocol({ description: "Get the amount of underlying asset supplied by a user for a given reserve", inputs: { - reserveId: { label: "Reserve ID" }, + reserveId: { + label: "Reserve ID", + docUrl: "https://aave.com/docs/aave-v4/positions/supply", + }, user: { label: "User Address" }, }, outputs: { @@ -165,7 +180,10 @@ export default defineAbiProtocol({ description: "Get the debt of a user for a given reserve, split into drawn debt and premium debt. Total debt = drawn + premium.", inputs: { - reserveId: { label: "Reserve ID" }, + reserveId: { + label: "Reserve ID", + docUrl: "https://aave.com/docs/aave-v4/positions/borrow", + }, user: { label: "User Address" }, }, outputs: { @@ -185,7 +203,10 @@ export default defineAbiProtocol({ description: "Get overall account health including collateral value, debt, health factor, and risk premium. Returns a struct - access individual fields via dotted path (e.g. result.healthFactor).", inputs: { - user: { label: "User Address" }, + user: { + label: "User Address", + docUrl: "https://aave.com/docs/aave-v4/positions", + }, }, outputs: { result: { From fa690bc636c3f640a19cc4191d5c85ab89dba47e Mon Sep 17 00:00:00 2001 From: Simon KP Date: Tue, 21 Apr 2026 15:51:13 +1000 Subject: [PATCH 29/41] test(x402-call-route): update db mock chain for leftJoin on tags lookupWorkflow now joins the tags table to project tagName for the bazaar extension (commit 2c2550b0). The chain-mock helper only covered select().from().where().limit() so the real code threw on the missing .leftJoin(), cascading every downstream assertion to 500. - setupDbSelectWorkflow: insert leftJoin between from and where - schema mock: add tags table and workflows.tagId key --- tests/unit/x402-call-route.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/unit/x402-call-route.test.ts b/tests/unit/x402-call-route.test.ts index 109cd5c98..67d1f6789 100644 --- a/tests/unit/x402-call-route.test.ts +++ b/tests/unit/x402-call-route.test.ts @@ -53,8 +53,14 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/db/schema", () => ({ - workflows: { id: "id", listedSlug: "listed_slug", isListed: "is_listed" }, + workflows: { + id: "id", + listedSlug: "listed_slug", + isListed: "is_listed", + tagId: "tag_id", + }, workflowExecutions: { id: "id" }, + tags: { id: "id", name: "name" }, })); vi.mock("@/lib/x402/payment-gate", () => ({ @@ -128,10 +134,15 @@ const FREE_WORKFLOW_NULL_PRICE = { ...LISTED_WORKFLOW, priceUsdcPerCall: null }; const CREATOR_WALLET = "0xCREATOR_WALLET"; function setupDbSelectWorkflow(row: unknown) { + // lookupWorkflow joins the tags table to project tagName into the row, so + // the chain is: select().from().leftJoin().where().limit(). Mirror that + // shape here or the real code throws on the missing .leftJoin(). mockDbSelect.mockReturnValue({ from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue(row ? [row] : []), + leftJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(row ? [row] : []), + }), }), }), }); From 6c6fe543c29cbe148c2e8df025ce656a643dfb85 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 21 Apr 2026 15:56:51 +1000 Subject: [PATCH 30/41] fix: remove write-action outputs that resolve to undefined at runtime The four Aave V4 write actions (supply, withdraw, borrow, repay) declared named outputs (suppliedShares, drawnAmount, etc.) that flowed through buildOutputFieldsFromAction (lib/protocol-registry.ts) into the UI template autocomplete. But writeContractCore (plugins/web3/steps/write-contract-core.ts) returns result: undefined for every write, so those template references resolved to undefined at runtime. V3 only declares outputs on read actions, for this reason. V4 now matches that convention. The write-path return decoding is a platform-level change tracked as a follow-up. Follow-up: KEEP-296 (decode write-action return values for protocol actions) --- protocols/aave-v4.ts | 35 ++++------------------------- tests/unit/protocol-aave-v4.test.ts | 18 ++++++--------- 2 files changed, 11 insertions(+), 42 deletions(-) diff --git a/protocols/aave-v4.ts b/protocols/aave-v4.ts index 5a413698f..950bf9db4 100644 --- a/protocols/aave-v4.ts +++ b/protocols/aave-v4.ts @@ -31,6 +31,10 @@ export default defineAbiProtocol({ "1": "0xe1900480ac69f0B296841Cd01cC37546d92F35Cd", }, overrides: { + // Write actions (supply/withdraw/borrow/repay) omit outputs pending + // KEEP-296: write-contract-core returns result: undefined today, so + // declared named outputs would surface in the UI picker but resolve + // to undefined at runtime. supply: { slug: "supply", label: "Supply Asset", @@ -46,13 +50,6 @@ export default defineAbiProtocol({ amount: { label: "Amount (wei)" }, onBehalfOf: { label: "On Behalf Of Address" }, }, - outputs: { - result0: { name: "suppliedShares", label: "Supplied Shares" }, - result1: { - name: "suppliedAmount", - label: "Supplied Amount (underlying)", - }, - }, }, withdraw: { slug: "withdraw", @@ -66,13 +63,6 @@ export default defineAbiProtocol({ amount: { label: "Amount (wei)" }, onBehalfOf: { label: "Recipient Address" }, }, - outputs: { - result0: { name: "withdrawnShares", label: "Withdrawn Shares" }, - result1: { - name: "withdrawnAmount", - label: "Withdrawn Amount (underlying)", - }, - }, }, borrow: { slug: "borrow", @@ -87,13 +77,6 @@ export default defineAbiProtocol({ amount: { label: "Amount (wei)" }, onBehalfOf: { label: "On Behalf Of Address" }, }, - outputs: { - result0: { name: "drawnShares", label: "Drawn Shares" }, - result1: { - name: "drawnAmount", - label: "Drawn Amount (underlying)", - }, - }, }, repay: { slug: "repay", @@ -107,16 +90,6 @@ export default defineAbiProtocol({ amount: { label: "Amount (wei)" }, onBehalfOf: { label: "On Behalf Of Address" }, }, - outputs: { - result0: { - name: "drawnSharesBurned", - label: "Drawn Shares Burned", - }, - result1: { - name: "totalAmountRepaid", - label: "Total Amount Repaid (underlying)", - }, - }, }, setUsingAsCollateral: { slug: "set-collateral", diff --git a/tests/unit/protocol-aave-v4.test.ts b/tests/unit/protocol-aave-v4.test.ts index 7698716f1..1c3d57f59 100644 --- a/tests/unit/protocol-aave-v4.test.ts +++ b/tests/unit/protocol-aave-v4.test.ts @@ -156,19 +156,15 @@ describe("Aave V4 Protocol Definition (ABI-driven)", () => { expect(getSupplied?.outputs?.[0].name).toBe("suppliedAmount"); }); - it("supply/withdraw/borrow/repay writes expose their Solidity return values as named outputs", () => { - const expected: Record = { - supply: ["suppliedShares", "suppliedAmount"], - withdraw: ["withdrawnShares", "withdrawnAmount"], - borrow: ["drawnShares", "drawnAmount"], - repay: ["drawnSharesBurned", "totalAmountRepaid"], - }; - for (const [slug, [name0, name1]] of Object.entries(expected)) { + it("write actions (supply/withdraw/borrow/repay) do not declare outputs", () => { + // KEEP-296: write path returns result: undefined, so declared outputs + // would mislead the UI picker. Revisit once the write path decodes + // function returns from simulation or events. + for (const slug of ["supply", "withdraw", "borrow", "repay"]) { const action = aaveV4Def.actions.find((a) => a.slug === slug); expect(action, `action "${slug}" not found`).toBeDefined(); - expect(action?.outputs).toHaveLength(2); - expect(action?.outputs?.[0].name).toBe(name0); - expect(action?.outputs?.[1].name).toBe(name1); + expect(action?.type).toBe("write"); + expect(action?.outputs).toBeUndefined(); } }); From c425e14bc9193169b7824d7636d7b8265a1bd943 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Tue, 21 Apr 2026 16:00:34 +1000 Subject: [PATCH 31/41] docs(agent-wallets): reframe as wallet-neutral + call out agentcash custody risk The earlier page implied agentcash was the install path for KeeperHub specifically. agentcash is actually a generic x402 wallet/client that works with any x402 service -- it is not KeeperHub-specific, and Coinbase agentic-wallet-skills covers the same ground via the CDP wallet ecosystem. Presenting agentcash as "the" option biased readers and missed half of the real landscape. - Rename agentcash-install.md -> agent-wallets.md; retitle "x402 Wallets for AI Agents" - List agentcash and `coinbase/agentic-wallet-skills` side-by-side as equivalent third-party options, neither KeeperHub-specific - Add a prominent custody warning on the agentcash section: plaintext keys in ~/.agentcash/wallet.json, no passphrase / keychain / backup, KeeperHub is not responsible for funds held there. Source: the security breakdown in KEEP-282 (agentcash threat model analysis) - Decision guidance framed as a setup/custody preference, not a KeeperHub preference - Keep the meta-tools section (search_workflows / call_workflow) as wallet-neutral since they apply regardless of which wallet is chosen - Update _meta.ts and index.md links to match the new slug Part of KEEP-259 docs iteration. --- docs/ai-tools/_meta.ts | 2 +- docs/ai-tools/agent-wallets.md | 61 ++++++++++++++++++++++++++++++ docs/ai-tools/agentcash-install.md | 53 -------------------------- docs/ai-tools/index.md | 2 +- 4 files changed, 63 insertions(+), 55 deletions(-) create mode 100644 docs/ai-tools/agent-wallets.md delete mode 100644 docs/ai-tools/agentcash-install.md diff --git a/docs/ai-tools/_meta.ts b/docs/ai-tools/_meta.ts index cc9d0638b..f40fe0b70 100644 --- a/docs/ai-tools/_meta.ts +++ b/docs/ai-tools/_meta.ts @@ -2,5 +2,5 @@ export default { overview: "Overview", "claude-code-plugin": "Claude Code Plugin", "mcp-server": "MCP Server", - "agentcash-install": "Install the Skill", + "agent-wallets": "x402 Wallets for Agents", }; diff --git a/docs/ai-tools/agent-wallets.md b/docs/ai-tools/agent-wallets.md new file mode 100644 index 000000000..4fede3799 --- /dev/null +++ b/docs/ai-tools/agent-wallets.md @@ -0,0 +1,61 @@ +--- +title: "x402 Wallets for AI Agents" +description: "Install an x402 wallet in your AI agent so it can pay for KeeperHub workflows (or any x402 service)." +--- + +# x402 Wallets for AI Agents + +KeeperHub paid workflows settle via the [x402 payment protocol](https://docs.cdp.coinbase.com/x402): each call carries a USDC payment, and the server returns the result only after the payment is verified. To call a paid workflow, your agent needs an x402 wallet. + +This page lists current x402 wallet options. KeeperHub does not run any of them -- both are third-party tools in the wider x402 ecosystem. Each works with KeeperHub and with every other x402-compliant service. + +## agentcash + +`agentcash` is a CLI + skill bundle from [agentcash.dev](https://agentcash.dev). It maintains a local USDC wallet and signs x402 payments on the agent's behalf. + +```bash +npx agentcash add https://app.keeperhub.com +``` + +This walks KeeperHub's `/openapi.json`, generates a `keeperhub` skill file, and symlinks it into every detected agent skill directory. After install, agents can call `search_workflows` and `call_workflow` as first-class tools; payment is routed through the agentcash wallet automatically. + +Supported agents (17 at time of writing): Claude Code, Cursor, Cline, Windsurf, Continue, Roo Code, Kilo Code, Goose, Trae, Junie, Crush, Kiro CLI, Qwen Code, OpenHands, Gemini CLI, Codex, GitHub Copilot. + +> **Testing only. Do not custody real funds.** +> agentcash stores the wallet key as an **unencrypted plaintext file** at `~/.agentcash/wallet.json`. There is no passphrase, no keychain integration, and no seed-phrase backup -- if the file is deleted, lost, or read by any process running as your user, the funds are gone or stolen. This is appropriate for development and automation experiments with small balances (e.g. a few dollars of USDC to pay for test calls), but it is **not** a production wallet. +> +> KeeperHub does not operate agentcash and is not responsible for funds stored in an agentcash wallet. Use it at your own risk and do not top it up beyond what you are willing to lose. + +## Coinbase agentic wallet skills + +Coinbase publishes a bundle of 9 general-purpose x402 skills that work with any x402-compliant service, including KeeperHub: + +```bash +npx skills add coinbase/agentic-wallet-skills +``` + +This installs skills including `authenticate-wallet`, `fund`, `pay-for-service`, `search-for-service`, `send-usdc`, `trade`, `query-onchain-data`, and `x402`. The wallet is managed through Coinbase Developer Platform; payment flows route through the CDP infrastructure. + +Full documentation and security risk ratings: https://skills.sh/coinbase/agentic-wallet-skills + +## Which wallet should I use? + +Both wallets can call any x402-compliant service, KeeperHub included, so the choice depends on your agent's existing setup and custody preferences, not on anything KeeperHub-specific. + +- **Pick agentcash** for a quick-start install of KeeperHub (or any x402 origin) as a first-class skill. Keep in mind agentcash keys are plaintext on disk -- it is a testing wallet, not a production one. +- **Pick Coinbase agentic wallet skills** if you already run a CDP wallet, want managed key infrastructure, or prefer the broader Coinbase x402 ecosystem. + +Nothing stops you from installing both -- they do not conflict. + +## What KeeperHub exposes to the agent + +Regardless of which wallet you install, the agent calls KeeperHub through two meta-tools (described in its OpenAPI at `/openapi.json`): + +- `search_workflows` -- find workflows by category, tag, or free text. Returns slug, description, inputSchema, and price for each match. +- `call_workflow` -- execute a listed workflow by slug. For read workflows the call executes and returns the result; for write workflows it returns unsigned calldata `{to, data, value}` for the caller to submit. + +This meta-tool pattern keeps the agent's tool list small no matter how many workflows are listed -- the agent discovers available workflows at runtime instead of registering one tool per workflow. + +## Paying for calls + +Paid workflows settle in USDC on Base (via x402) or USDC.e on Tempo (via MPP). Most workflows cost under `$0.05` per call. See [Paid Workflows](/workflows/paid-workflows) for the creator-side view of the same settlement. diff --git a/docs/ai-tools/agentcash-install.md b/docs/ai-tools/agentcash-install.md deleted file mode 100644 index 14b61aa1d..000000000 --- a/docs/ai-tools/agentcash-install.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: "Install the KeeperHub Skill" -description: "One-command install for Claude Code, Cursor, and 15 other AI agents using agentcash." ---- - -# Install the KeeperHub Skill - -Run KeeperHub workflows from any supported AI agent with a single command: - -```bash -npx agentcash add https://app.keeperhub.com -``` - -This walks KeeperHub's `/openapi.json`, generates a local `keeperhub` skill file, and symlinks it into every agent skill directory it finds on your machine. - -## Supported agents - -`agentcash add` auto-detects installed agents and installs the skill into each one. Currently supported: - -- Claude Code -- Cursor -- Cline -- Windsurf -- Continue -- Roo Code -- Kilo Code -- Goose -- Trae -- Junie -- Crush -- Kiro CLI -- Qwen Code -- OpenHands -- Gemini CLI -- Codex -- GitHub Copilot - -Once installed, your agent can tab-complete `/keeperhub` and route to KeeperHub's workflow catalog directly. No API key setup is required for this install path — per-call payments are handled by agentcash's wallet. - -## Paying for calls - -Paid workflows settle in USDC on Base (via x402) or USDC.e on Tempo (via MPP). The first time your agent calls a paid workflow, agentcash will prompt you to fund a wallet or approve a per-call spending limit. Most workflows cost under `$0.05` per call. - -If you already have an agentcash wallet, the balance applies automatically. - -## What the skill exposes - -After install, the agent has access to two meta-tools: - -- `search_workflows` -- find workflows by category, tag, or free text. Returns slug, description, inputSchema, and price for each match. -- `call_workflow` -- execute a listed workflow by slug. For read workflows the call executes and returns the result; for write workflows it returns unsigned calldata `{to, data, value}` for the caller to submit. Use `search_workflows` first to discover available workflows. - -This meta-tool pattern keeps the agent's tool list small no matter how many workflows are listed — the agent discovers available workflows at runtime instead of registering one tool per workflow. diff --git a/docs/ai-tools/index.md b/docs/ai-tools/index.md index 2cadfced9..f918be5d3 100644 --- a/docs/ai-tools/index.md +++ b/docs/ai-tools/index.md @@ -10,4 +10,4 @@ AI-powered tools that help you build, configure, and manage blockchain automatio - [Overview](/ai-tools/overview) -- How AI tools integrate with KeeperHub - [Claude Code Plugin](/ai-tools/claude-code-plugin) -- Use Claude Code for workflow development - [MCP Server](/ai-tools/mcp-server) -- KeeperHub MCP server for AI-assisted automation -- [Install the Skill](/ai-tools/agentcash-install) -- One-command skill install for 17 AI agents via agentcash +- [x402 Wallets for Agents](/ai-tools/agent-wallets) -- Install agentcash or Coinbase wallet skills so your agent can pay for KeeperHub workflows From baa08ef93e1a2cef043f1a689a1f80f6971a653a Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 21 Apr 2026 16:05:27 +1000 Subject: [PATCH 32/41] docs(billing): explain 2s billing details refresh heuristic --- components/billing/billing-page.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/billing/billing-page.tsx b/components/billing/billing-page.tsx index 2e2a815f2..6148275f0 100644 --- a/components/billing/billing-page.tsx +++ b/components/billing/billing-page.tsx @@ -97,7 +97,12 @@ export function BillingPage(): React.ReactElement { if (checkout === "success") { toast.success("Subscription activated successfully!"); window.history.replaceState({}, "", window.location.pathname); - // Re-fetch after Stripe finishes attaching the payment method (~2s delay) + // Heuristic delay: Stripe needs a moment after Checkout to attach the + // payment method where getBillingDetails can read it back via the API + // cascade. 2s works in practice but is a race, not a guarantee. The + // robust fix is to persist payment method details in our DB on the + // checkout.session.completed webhook and read from DB instead of + // hitting Stripe here. const timer = setTimeout(() => setRefreshKey((k) => k + 1), 2000); return () => clearTimeout(timer); } From 5b83078b1b32999bad051bff3c8c0afac8eb3ff5 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 21 Apr 2026 16:06:17 +1000 Subject: [PATCH 33/41] fix: gate write-action outputs in UI layer, not protocol overrides The previous commit (6c6fe543) only dropped the custom output overrides from write actions, but ABI-derived outputs still surfaced as UI template suggestions (result0/result1) that resolve to undefined at runtime, since deriveActionsFromAbi unconditionally attaches outputs from the ABI. Fix at the UI consumption layer: gate action.outputs -> outputFields in buildOutputFieldsFromAction on action.type === "read". The protocol model remains unchanged (protocol-abi-derive.test.ts already asserts that writes do get ABI outputs at the model layer -- that is correct, it is purely the UI surfacing that was lying). Applies to every ABI-driven protocol's write actions, not just Aave V4. No user-visible regression: the template references that disappear from autocomplete only ever resolved to undefined. Adds two regression tests exercising protocolActionToPluginAction to assert the behaviour both ways (writes hide ABI outputs, reads surface them). Follow-up: KEEP-296 (decode write-action return values for protocol actions) --- lib/protocol-registry.ts | 6 ++- protocols/aave-v4.ts | 9 ++-- tests/unit/protocol-aave-v4.test.ts | 64 +++++++++++++++++++++++++---- 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/lib/protocol-registry.ts b/lib/protocol-registry.ts index 31c6f3a01..3e5b180ce 100644 --- a/lib/protocol-registry.ts +++ b/lib/protocol-registry.ts @@ -369,7 +369,11 @@ function buildOutputFieldsFromAction( ): Array<{ field: string; description: string }> { const outputs: Array<{ field: string; description: string }> = []; - if (action.outputs) { + // KEEP-296: only reads surface action.outputs as UI template suggestions. + // Write actions still have ABI-derived outputs at the model layer, but + // writeContractCore returns result: undefined, so surfacing them would + // create template suggestions that resolve to undefined at runtime. + if (action.type === "read" && action.outputs) { for (const output of action.outputs) { outputs.push({ field: output.name, description: output.label }); } diff --git a/protocols/aave-v4.ts b/protocols/aave-v4.ts index 950bf9db4..a62ca49c6 100644 --- a/protocols/aave-v4.ts +++ b/protocols/aave-v4.ts @@ -31,10 +31,11 @@ export default defineAbiProtocol({ "1": "0xe1900480ac69f0B296841Cd01cC37546d92F35Cd", }, overrides: { - // Write actions (supply/withdraw/borrow/repay) omit outputs pending - // KEEP-296: write-contract-core returns result: undefined today, so - // declared named outputs would surface in the UI picker but resolve - // to undefined at runtime. + // Write actions (supply/withdraw/borrow/repay) omit output overrides + // pending KEEP-296. writeContractCore returns result: undefined, so + // UI template suggestions are gated in buildOutputFieldsFromAction; + // named overrides would be dead metadata until the write path decodes + // function returns. supply: { slug: "supply", label: "Supply Asset", diff --git a/tests/unit/protocol-aave-v4.test.ts b/tests/unit/protocol-aave-v4.test.ts index 1c3d57f59..75c74bd3d 100644 --- a/tests/unit/protocol-aave-v4.test.ts +++ b/tests/unit/protocol-aave-v4.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { getProtocol, registerProtocol } from "@/lib/protocol-registry"; +import { + getProtocol, + protocolActionToPluginAction, + registerProtocol, +} from "@/lib/protocol-registry"; import aaveV4Def from "@/protocols/aave-v4"; const KEBAB_CASE_REGEX = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; @@ -156,15 +160,61 @@ describe("Aave V4 Protocol Definition (ABI-driven)", () => { expect(getSupplied?.outputs?.[0].name).toBe("suppliedAmount"); }); - it("write actions (supply/withdraw/borrow/repay) do not declare outputs", () => { - // KEEP-296: write path returns result: undefined, so declared outputs - // would mislead the UI picker. Revisit once the write path decodes - // function returns from simulation or events. + it("write actions do not surface ABI-derived outputs as UI template suggestions (KEEP-296)", () => { + // writeContractCore returns result: undefined, so any action.outputs on + // write actions must not flow into pluginAction.outputFields (which + // drives template autocomplete). Gated in buildOutputFieldsFromAction. for (const slug of ["supply", "withdraw", "borrow", "repay"]) { const action = aaveV4Def.actions.find((a) => a.slug === slug); expect(action, `action "${slug}" not found`).toBeDefined(); - expect(action?.type).toBe("write"); - expect(action?.outputs).toBeUndefined(); + if (!action) { + continue; + } + expect(action.type).toBe("write"); + const pluginAction = protocolActionToPluginAction(aaveV4Def, action); + const outputFieldNames = (pluginAction.outputFields ?? []).map( + (f) => f.field + ); + // No ABI-derived outputs leak through + expect(outputFieldNames).not.toContain("result0"); + expect(outputFieldNames).not.toContain("result1"); + // Standard write-action fields are still present + expect(outputFieldNames).toEqual( + expect.arrayContaining([ + "success", + "error", + "transactionHash", + "transactionLink", + ]) + ); + } + }); + + it("read actions surface ABI-derived outputs as UI template suggestions", () => { + for (const slug of [ + "get-user-supplied-assets", + "get-user-debt", + "get-reserve-id", + ]) { + const action = aaveV4Def.actions.find((a) => a.slug === slug); + expect(action, `action "${slug}" not found`).toBeDefined(); + if (!action) { + continue; + } + expect(action.type).toBe("read"); + const pluginAction = protocolActionToPluginAction(aaveV4Def, action); + const outputFieldNames = (pluginAction.outputFields ?? []).map( + (f) => f.field + ); + // success/error always present; plus at least one ABI-derived output + expect(outputFieldNames).toEqual( + expect.arrayContaining(["success", "error"]) + ); + expect(outputFieldNames).not.toContain("transactionHash"); + const nonStandardFields = outputFieldNames.filter( + (f) => f !== "success" && f !== "error" + ); + expect(nonStandardFields.length).toBeGreaterThan(0); } }); From 7f9baba3c7fd3089e08e1ccb9c33b48a659458e0 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Tue, 21 Apr 2026 16:07:34 +1000 Subject: [PATCH 34/41] test(mcp-meta-tools): update db mock chain for leftJoin on tags Same fix as the x402-call-route.test.ts adjustment (commit fa690bc6): lookupWorkflow now joins the tags table for tagName, so the chain-mock helper has to include .leftJoin() or the real code throws and every assertion cascades to 500. - setupDbSelectWorkflow: insert leftJoin between from and where - schema mock: add tags table and workflows.tagId key Verified: `pnpm vitest run tests/unit/mcp-meta-tools.test.ts tests/unit/x402-call-route.test.ts --dir tests` -- 43/43 passing. --- tests/unit/mcp-meta-tools.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/unit/mcp-meta-tools.test.ts b/tests/unit/mcp-meta-tools.test.ts index 759ff22cb..56de392ad 100644 --- a/tests/unit/mcp-meta-tools.test.ts +++ b/tests/unit/mcp-meta-tools.test.ts @@ -420,8 +420,14 @@ describe("POST /api/mcp/workflows/[slug]/call: write workflow returns calldata", })); vi.mock("@/lib/db/schema", () => ({ - workflows: { id: "id", listedSlug: "listed_slug", isListed: "is_listed" }, + workflows: { + id: "id", + listedSlug: "listed_slug", + isListed: "is_listed", + tagId: "tag_id", + }, workflowExecutions: { id: "id" }, + tags: { id: "id", name: "name" }, })); vi.mock("@/lib/x402/server", () => ({ @@ -501,10 +507,15 @@ describe("POST /api/mcp/workflows/[slug]/call: write workflow returns calldata", }; function setupDbSelectWorkflow(row: unknown) { + // lookupWorkflow joins the tags table to project tagName into the row; + // the real chain is select().from().leftJoin().where().limit(). Mirror + // that shape here or the real code throws on the missing .leftJoin(). mockDbSelect.mockReturnValue({ from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue(row ? [row] : []), + leftJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(row ? [row] : []), + }), }), }), }); From aaffc102cf41a1297fc87cb713e319f0ad8c243b Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 21 Apr 2026 16:09:21 +1000 Subject: [PATCH 35/41] fix(billing): prefer active subscription in getBillingDetails cascade --- lib/billing/providers/stripe.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/billing/providers/stripe.ts b/lib/billing/providers/stripe.ts index 107d2f70a..ce9fef734 100644 --- a/lib/billing/providers/stripe.ts +++ b/lib/billing/providers/stripe.ts @@ -284,15 +284,25 @@ export class StripeBillingProvider implements BillingProvider { : null; // Stripe Checkout stores the default payment method on the subscription, - // not on the customer. Fall back to the most recent subscription's default, - // then to any card attached to the customer. + // not on the customer. Prefer the active subscription's default PM, since + // that's the card actually being charged. Fall back to any status only if + // there is no active sub, to preserve data for past_due / canceled users + // viewing billing history. if (!card) { - const subs = await s.subscriptions.list({ + let subs = await s.subscriptions.list({ customer: customerId, - status: "all", + status: "active", limit: 1, expand: ["data.default_payment_method"], }); + if (subs.data.length === 0) { + subs = await s.subscriptions.list({ + customer: customerId, + status: "all", + limit: 1, + expand: ["data.default_payment_method"], + }); + } const subDefault = subs.data[0]?.default_payment_method; if ( subDefault && From 27786002c3473875b3d3bca6fdada5e4c3e9dc06 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 21 Apr 2026 16:12:43 +1000 Subject: [PATCH 36/41] fix: broaden aave slug migration to cover event triggers and integrations.type Schema audit surfaced two slug-bearing surfaces the original 0051 missed: * workflows.nodes[].data._eventProtocolSlug -- event trigger nodes store the protocol slug here (separate from action nodes' actionType / _protocolMeta). Modelled after the 0025 safe-wallet precedent using jsonb_set via jsonb_agg, since _eventProtocolSlug is a native jsonb key (unlike the stringified _protocolMeta). * integrations.type -- $type. Protocol plugins set requiresCredentials: false so no rows are expected in practice, but adding the rename is idempotent and cheap insurance. Not touched: * _eventProtocolIconPath -- icon file itself is unchanged across the rename (protocols/aave-v3.ts still declares "/protocols/aave.png"), unlike the 0025 safe-wallet case which had to rewrite the icon path. * Historical execution tables (workflow_executions, workflow_execution_logs, direct_executions) -- rewriting them would falsify past-run history. Migration header now lists the audited surfaces explicitly. --- drizzle/0051_rename_aave_slug_to_v3.sql | 62 +++++++++++++++++++------ 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/drizzle/0051_rename_aave_slug_to_v3.sql b/drizzle/0051_rename_aave_slug_to_v3.sql index 26f54faf1..8d7130b1b 100644 --- a/drizzle/0051_rename_aave_slug_to_v3.sql +++ b/drizzle/0051_rename_aave_slug_to_v3.sql @@ -1,24 +1,30 @@ -- Rename Aave V3 protocol slug from "aave" to "aave-v3" so it coexists -- cleanly with the new "aave-v4" slug. Data-only migration: no schema change. -- --- Affects: --- * workflows.featured_protocol (text column) --- * workflows.nodes (jsonb): each node's data.config.actionType ("aave/*") --- and the stringified data.config._protocolMeta.protocolSlug ("aave") +-- Slug-bearing fields audited in the schema: +-- * workflows.featured_protocol (text) +-- * workflows.nodes[].data.config.actionType (jsonb, "aave/*") +-- * workflows.nodes[].data.config._protocolMeta.protocolSlug +-- (stringified JSON inside jsonb) +-- * workflows.nodes[].data._eventProtocolSlug (jsonb, on trigger nodes) +-- * integrations.type (text, stores IntegrationType) -- --- Strategy: text-level REPLACE on the canonical JSONB text rendition, then --- cast back. Two distinct patterns exist: +-- Not touched: workflows.nodes[].data._eventProtocolIconPath. Both "aave" +-- and "aave-v3" resolve to the same icon file (protocols/aave-v3.ts still +-- declares icon: "/protocols/aave.png"), so the icon path is stable across +-- the rename. The 0025 safe-wallet precedent had to update its icon path +-- because the safe icon file itself was being renamed; that's not the case +-- here. -- --- (1) Top-level JSONB fields: PostgreSQL canonicalizes with a space after --- the colon, so the actionType field renders as "actionType": "aave/...". +-- Historical tables (workflow_executions, workflow_execution_logs, +-- direct_executions) are intentionally NOT touched: they record past runs +-- with their slug-of-the-day, rewriting them would falsify history. -- --- (2) The _protocolMeta value is itself a stringified JSON produced by --- JSON.stringify() on the client. Default JSON.stringify output has NO --- space after colons, so within that string the pattern is --- \"protocolSlug\":\"aave\" (escaped quotes, no spaces). --- --- These patterns are key-scoped so free-form text containing the word "aave" --- (e.g. a description field) is untouched. +-- Strategy: text-level REPLACE on JSONB::text for actionType / protocolSlug +-- (the stringified _protocolMeta can't be reached by jsonb_set because its +-- value is itself a string, not nested jsonb). For _eventProtocolSlug, use +-- jsonb_set via jsonb_agg (the 0025 precedent): a native jsonb key on each +-- node's data object. -- -- LIKE-escape note: LIKE treats backslash as its own escape character by -- default. To match a literal backslash-quote sequence in the text form we @@ -45,3 +51,29 @@ SET nodes = REPLACE( WHERE nodes::text LIKE '%"actionType": "aave/%' OR nodes::text LIKE '%\\"protocolSlug\\":\\"aave\\"%'; +--> statement-breakpoint + +-- Event triggers (node.data._eventProtocolSlug). +-- Structured jsonb_set via jsonb_agg, mirroring the 0025 precedent. +UPDATE workflows +SET nodes = ( + SELECT jsonb_agg( + CASE + WHEN node->'data'->>'_eventProtocolSlug' = 'aave' + THEN jsonb_set(node, '{data,_eventProtocolSlug}', '"aave-v3"') + ELSE node + END + ) + FROM jsonb_array_elements(nodes) AS node +) +WHERE + nodes::text LIKE '%"_eventProtocolSlug":"aave"%' + OR nodes::text LIKE '%"_eventProtocolSlug": "aave"%'; +--> statement-breakpoint + +-- Defensive: integrations.type is $type which no longer +-- admits "aave". Protocol plugins set requiresCredentials: false, so no +-- rows are expected, but renaming is idempotent and cheap. +UPDATE integrations +SET type = 'aave-v3' +WHERE type = 'aave'; From b00f33bb8508fdd4a3fb5cf698c25f786f4fb138 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 21 Apr 2026 16:12:52 +1000 Subject: [PATCH 37/41] test(billing): cover getBillingDetails cascade tiers --- tests/unit/billing-stripe-provider.test.ts | 144 ++++++++++++++++++++- 1 file changed, 142 insertions(+), 2 deletions(-) diff --git a/tests/unit/billing-stripe-provider.test.ts b/tests/unit/billing-stripe-provider.test.ts index 0cba4bb07..cb2b72c69 100644 --- a/tests/unit/billing-stripe-provider.test.ts +++ b/tests/unit/billing-stripe-provider.test.ts @@ -2,14 +2,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@/lib/stripe", () => ({ stripe: { - customers: { create: vi.fn() }, + customers: { create: vi.fn(), retrieve: vi.fn() }, checkout: { sessions: { create: vi.fn() } }, billingPortal: { sessions: { create: vi.fn() } }, webhooks: { constructEvent: vi.fn() }, invoiceItems: { create: vi.fn() }, invoices: { list: vi.fn(), createPreview: vi.fn() }, - subscriptions: { retrieve: vi.fn(), update: vi.fn() }, + subscriptions: { retrieve: vi.fn(), update: vi.fn(), list: vi.fn() }, prices: { retrieve: vi.fn() }, + paymentMethods: { list: vi.fn() }, }, })); @@ -776,6 +777,145 @@ describe("StripeBillingProvider", () => { expect(callArgs.metadata).toBeUndefined(); }); }); + + describe("getBillingDetails", () => { + const customerBase = { + id: "cus_1", + deleted: false, + email: "user@test.com", + invoice_settings: { default_payment_method: null }, + }; + + function makeCard(last4: string): Record { + return { + id: `pm_${last4}`, + type: "card", + card: { + brand: "visa", + last4, + exp_month: 12, + exp_year: 2030, + }, + }; + } + + type CustomerResponse = Awaited>; + type SubsListResponse = Awaited>; + type PMListResponse = Awaited>; + + it("tier 1: returns the customer's default payment method", async () => { + vi.mocked(s.customers.retrieve).mockResolvedValue({ + ...customerBase, + invoice_settings: { default_payment_method: makeCard("1111") }, + } as unknown as CustomerResponse); + + const result = await provider.getBillingDetails("cus_1"); + + expect(result).toEqual({ + paymentMethod: { + brand: "visa", + last4: "1111", + expMonth: 12, + expYear: 2030, + }, + billingEmail: "user@test.com", + }); + expect(s.subscriptions.list).not.toHaveBeenCalled(); + expect(s.paymentMethods.list).not.toHaveBeenCalled(); + }); + + it("tier 2: returns the active subscription's default PM when customer has none", async () => { + vi.mocked(s.customers.retrieve).mockResolvedValue( + customerBase as unknown as CustomerResponse + ); + vi.mocked(s.subscriptions.list).mockResolvedValueOnce({ + data: [{ default_payment_method: makeCard("2222") }], + } as unknown as SubsListResponse); + + const result = await provider.getBillingDetails("cus_1"); + + expect(result.paymentMethod?.last4).toBe("2222"); + expect(s.subscriptions.list).toHaveBeenCalledTimes(1); + expect(s.subscriptions.list).toHaveBeenCalledWith( + expect.objectContaining({ status: "active" }) + ); + expect(s.paymentMethods.list).not.toHaveBeenCalled(); + }); + + it("tier 2 fallback: queries status=all only when the active list is empty", async () => { + vi.mocked(s.customers.retrieve).mockResolvedValue( + customerBase as unknown as CustomerResponse + ); + vi.mocked(s.subscriptions.list) + .mockResolvedValueOnce({ data: [] } as unknown as SubsListResponse) + .mockResolvedValueOnce({ + data: [{ default_payment_method: makeCard("3333") }], + } as unknown as SubsListResponse); + + const result = await provider.getBillingDetails("cus_1"); + + expect(result.paymentMethod?.last4).toBe("3333"); + expect(s.subscriptions.list).toHaveBeenCalledTimes(2); + expect(vi.mocked(s.subscriptions.list).mock.calls[0][0]).toMatchObject({ + status: "active", + }); + expect(vi.mocked(s.subscriptions.list).mock.calls[1][0]).toMatchObject({ + status: "all", + }); + expect(s.paymentMethods.list).not.toHaveBeenCalled(); + }); + + it("tier 3: falls back to paymentMethods.list when no subscription has a PM", async () => { + vi.mocked(s.customers.retrieve).mockResolvedValue( + customerBase as unknown as CustomerResponse + ); + vi.mocked(s.subscriptions.list).mockResolvedValue({ + data: [], + } as unknown as SubsListResponse); + vi.mocked(s.paymentMethods.list).mockResolvedValue({ + data: [makeCard("4444")], + } as unknown as PMListResponse); + + const result = await provider.getBillingDetails("cus_1"); + + expect(result.paymentMethod?.last4).toBe("4444"); + expect(s.paymentMethods.list).toHaveBeenCalledWith( + expect.objectContaining({ type: "card", limit: 1 }) + ); + }); + + it("returns null paymentMethod when no card is found anywhere", async () => { + vi.mocked(s.customers.retrieve).mockResolvedValue( + customerBase as unknown as CustomerResponse + ); + vi.mocked(s.subscriptions.list).mockResolvedValue({ + data: [], + } as unknown as SubsListResponse); + vi.mocked(s.paymentMethods.list).mockResolvedValue({ + data: [], + } as unknown as PMListResponse); + + const result = await provider.getBillingDetails("cus_1"); + + expect(result).toEqual({ + paymentMethod: null, + billingEmail: "user@test.com", + }); + }); + + it("returns both nulls for a deleted customer and skips the cascade", async () => { + vi.mocked(s.customers.retrieve).mockResolvedValue({ + id: "cus_1", + deleted: true, + } as unknown as CustomerResponse); + + const result = await provider.getBillingDetails("cus_1"); + + expect(result).toEqual({ paymentMethod: null, billingEmail: null }); + expect(s.subscriptions.list).not.toHaveBeenCalled(); + expect(s.paymentMethods.list).not.toHaveBeenCalled(); + }); + }); }); describe("UnknownEventTypeError", () => { From 095e866a0bfa239ec9f45a18032bb9af16d0e1d6 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 21 Apr 2026 16:14:15 +1000 Subject: [PATCH 38/41] test: strengthen aave v4 integration test assertions Previous pattern swallowed any error that didn't contain three specific strings into a passing test: try { // positive assertions -- only run if RPC succeeds } catch (error) { expect(String(error)).not.toContain("INVALID_ARGUMENT"); expect(String(error)).not.toContain("could not decode"); expect(String(error)).not.toContain("invalid function"); } An RPC timeout, a rate-limit, an unrelated revert reason, or a genuine ABI mismatch whose error string happened to miss those three needles would all pass green with no positive assertion actually running. Rewrite: - Read tests: remove try/catch. If provider.call fails or the return can't be decoded, the test fails loudly (as it should). - Write tests (supply, setUsingAsCollateral): switch from estimateGas to provider.call with the zero-balance TEST_ADDRESS to trigger a business-logic revert, and assert rejects.toMatchObject({ code: "CALL_EXCEPTION" }) -- ethers v6's canonical code for contract-level reverts. An ABI-level failure (INVALID_ARGUMENT, BAD_DATA) or an unknown selector raises a different error class, so the matcher distinguishes "calldata was understood by the contract" from "calldata never got that far". Test count unchanged (6). Integration tests remain gated on INTEGRATION_TEST_MAINNET_RPC_URL, so this change doesn't affect the default CI path. --- .../protocol-aave-v4-onchain.test.ts | 159 +++++++----------- 1 file changed, 62 insertions(+), 97 deletions(-) diff --git a/tests/integration/protocol-aave-v4-onchain.test.ts b/tests/integration/protocol-aave-v4-onchain.test.ts index e1c01ba44..edc8ca68c 100644 --- a/tests/integration/protocol-aave-v4-onchain.test.ts +++ b/tests/integration/protocol-aave-v4-onchain.test.ts @@ -69,6 +69,18 @@ function buildCalldata( return { to: contractAddress, data, action, contract }; } +// Assertion model: +// - Read tests: let the RPC call fail loudly. A success path asserts the +// decoded return has the expected shape; anything else (network error, +// ABI mismatch, decode failure) surfaces as a real test failure instead +// of being swallowed. +// - Write tests: use provider.call (not estimateGas) against a zero-balance +// TEST_ADDRESS to trigger a business-logic revert. Assert the rejection +// is a CALL_EXCEPTION (ethers v6 contract-revert code). That rules out +// ABI-level failures like INVALID_ARGUMENT, BAD_DATA, or an unknown +// selector (which return empty data and decode-fail before CALL_EXCEPTION +// is raised). The test fails if the tx unexpectedly succeeds -- a useful +// signal that business logic changed. describe.skipIf(!RPC_URL)("Aave V4 Lido Spoke on-chain integration", () => { const getProvider = (): ethers.JsonRpcProvider => new ethers.JsonRpcProvider(RPC_URL); @@ -80,19 +92,12 @@ describe.skipIf(!RPC_URL)("Aave V4 Lido Spoke on-chain integration", () => { }); const provider = getProvider(); - try { - const result = await provider.call({ to, data }); - const abi = JSON.parse(contract.abi as string); - const iface = new ethers.Interface(abi); - const decoded = iface.decodeFunctionResult("getReserveId", result); - expect(decoded).toBeDefined(); - expect(typeof decoded[0]).toBe("bigint"); - } catch (error) { - const msg = String(error); - expect(msg).not.toContain("INVALID_ARGUMENT"); - expect(msg).not.toContain("could not decode"); - expect(msg).not.toContain("invalid function"); - } + const result = await provider.call({ to, data }); + const abi = JSON.parse(contract.abi as string); + const iface = new ethers.Interface(abi); + const decoded = iface.decodeFunctionResult("getReserveId", result); + expect(decoded).toBeDefined(); + expect(typeof decoded[0]).toBe("bigint"); }, 15_000); it("getUserSuppliedAssets: eth_call returns a decodable uint256", async () => { @@ -103,22 +108,15 @@ describe.skipIf(!RPC_URL)("Aave V4 Lido Spoke on-chain integration", () => { ); const provider = getProvider(); - try { - const result = await provider.call({ to, data }); - const abi = JSON.parse(contract.abi as string); - const iface = new ethers.Interface(abi); - const decoded = iface.decodeFunctionResult( - "getUserSuppliedAssets", - result - ); - expect(decoded).toBeDefined(); - expect(typeof decoded[0]).toBe("bigint"); - } catch (error) { - const msg = String(error); - expect(msg).not.toContain("INVALID_ARGUMENT"); - expect(msg).not.toContain("could not decode"); - expect(msg).not.toContain("invalid function"); - } + const result = await provider.call({ to, data }); + const abi = JSON.parse(contract.abi as string); + const iface = new ethers.Interface(abi); + const decoded = iface.decodeFunctionResult( + "getUserSuppliedAssets", + result + ); + expect(decoded).toBeDefined(); + expect(typeof decoded[0]).toBe("bigint"); }, 15_000); it("getUserDebt: eth_call returns two decodable uint256 values", async () => { @@ -128,43 +126,14 @@ describe.skipIf(!RPC_URL)("Aave V4 Lido Spoke on-chain integration", () => { }); const provider = getProvider(); - try { - const result = await provider.call({ to, data }); - const abi = JSON.parse(contract.abi as string); - const iface = new ethers.Interface(abi); - const decoded = iface.decodeFunctionResult("getUserDebt", result); - expect(decoded).toBeDefined(); - expect(decoded.length).toBeGreaterThanOrEqual(2); - expect(typeof decoded[0]).toBe("bigint"); - expect(typeof decoded[1]).toBe("bigint"); - } catch (error) { - const msg = String(error); - expect(msg).not.toContain("INVALID_ARGUMENT"); - expect(msg).not.toContain("could not decode"); - expect(msg).not.toContain("invalid function"); - } - }, 15_000); - - it("supply: calldata encodes correctly (business revert expected)", async () => { - const { to, data } = buildCalldata(aaveV4Def, "supply", { - reserveId: "0", - amount: "1000000000000000000", - onBehalfOf: TEST_ADDRESS, - }); - - const provider = getProvider(); - try { - await provider.estimateGas({ - to, - data, - from: TEST_ADDRESS, - }); - } catch (error) { - const msg = String(error); - expect(msg).not.toContain("INVALID_ARGUMENT"); - expect(msg).not.toContain("could not decode"); - expect(msg).not.toContain("invalid function"); - } + const result = await provider.call({ to, data }); + const abi = JSON.parse(contract.abi as string); + const iface = new ethers.Interface(abi); + const decoded = iface.decodeFunctionResult("getUserDebt", result); + expect(decoded).toBeDefined(); + expect(decoded.length).toBeGreaterThanOrEqual(2); + expect(typeof decoded[0]).toBe("bigint"); + expect(typeof decoded[1]).toBe("bigint"); }, 15_000); it("getUserAccountData: eth_call returns a decodable struct with named fields", async () => { @@ -175,27 +144,32 @@ describe.skipIf(!RPC_URL)("Aave V4 Lido Spoke on-chain integration", () => { ); const provider = getProvider(); - try { - const result = await provider.call({ to, data }); - const abi = JSON.parse(contract.abi as string); - const iface = new ethers.Interface(abi); - const decoded = iface.decodeFunctionResult("getUserAccountData", result); - expect(decoded).toBeDefined(); - const struct = decoded[0]; - expect(struct.healthFactor).toBeDefined(); - expect(typeof struct.healthFactor).toBe("bigint"); - expect(struct.totalCollateralValue).toBeDefined(); - expect(struct.riskPremium).toBeDefined(); - expect(struct.borrowCount).toBeDefined(); - } catch (error) { - const msg = String(error); - expect(msg).not.toContain("INVALID_ARGUMENT"); - expect(msg).not.toContain("could not decode"); - expect(msg).not.toContain("invalid function"); - } + const result = await provider.call({ to, data }); + const abi = JSON.parse(contract.abi as string); + const iface = new ethers.Interface(abi); + const decoded = iface.decodeFunctionResult("getUserAccountData", result); + expect(decoded).toBeDefined(); + const struct = decoded[0]; + expect(typeof struct.healthFactor).toBe("bigint"); + expect(typeof struct.totalCollateralValue).toBe("bigint"); + expect(typeof struct.riskPremium).toBe("bigint"); + expect(typeof struct.borrowCount).toBe("bigint"); + }, 15_000); + + it("supply: deployed bytecode accepts the calldata (business revert expected)", async () => { + const { to, data } = buildCalldata(aaveV4Def, "supply", { + reserveId: "0", + amount: "1000000000000000000", + onBehalfOf: TEST_ADDRESS, + }); + + const provider = getProvider(); + await expect( + provider.call({ to, data, from: TEST_ADDRESS }) + ).rejects.toMatchObject({ code: "CALL_EXCEPTION" }); }, 15_000); - it("setUsingAsCollateral: calldata encodes correctly (business revert expected)", async () => { + it("setUsingAsCollateral: deployed bytecode accepts the calldata (business revert expected)", async () => { const { to, data } = buildCalldata(aaveV4Def, "set-collateral", { reserveId: "0", usingAsCollateral: "true", @@ -203,17 +177,8 @@ describe.skipIf(!RPC_URL)("Aave V4 Lido Spoke on-chain integration", () => { }); const provider = getProvider(); - try { - await provider.estimateGas({ - to, - data, - from: TEST_ADDRESS, - }); - } catch (error) { - const msg = String(error); - expect(msg).not.toContain("INVALID_ARGUMENT"); - expect(msg).not.toContain("could not decode"); - expect(msg).not.toContain("invalid function"); - } + await expect( + provider.call({ to, data, from: TEST_ADDRESS }) + ).rejects.toMatchObject({ code: "CALL_EXCEPTION" }); }, 15_000); }); From cfc6dcf2c080c67c3954931cdcf9715e654d9192 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 21 Apr 2026 16:23:21 +1000 Subject: [PATCH 39/41] test: accept clean-success as valid outcome for write integration tests Running the suite against mainnet revealed that setUsingAsCollateral silently succeeds on reserveId=0 (the Spoke no-ops on nonexistent reserves rather than reverting), returning "0x". My previous assertion rejects.toMatchObject({ code: "CALL_EXCEPTION" }) was too narrow -- it assumed every write would revert for a zero-balance caller. What the write tests are actually proving is "the deployed bytecode understood our calldata". Both a clean return ("0x" for void functions) and CALL_EXCEPTION are valid evidence of that. What we still reject: INVALID_ARGUMENT, BAD_DATA, BUFFER_OVERRUN -- ABI-level errors that would signal the protocol definition doesn't match the deployed contract. Extracted the try/catch into expectCallAcceptedByBytecode() so supply and setUsingAsCollateral share the assertion shape. Verified: all 6 integration tests pass against eth-mainnet. --- .../protocol-aave-v4-onchain.test.ts | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/tests/integration/protocol-aave-v4-onchain.test.ts b/tests/integration/protocol-aave-v4-onchain.test.ts index edc8ca68c..46afe4c49 100644 --- a/tests/integration/protocol-aave-v4-onchain.test.ts +++ b/tests/integration/protocol-aave-v4-onchain.test.ts @@ -75,12 +75,14 @@ function buildCalldata( // ABI mismatch, decode failure) surfaces as a real test failure instead // of being swallowed. // - Write tests: use provider.call (not estimateGas) against a zero-balance -// TEST_ADDRESS to trigger a business-logic revert. Assert the rejection -// is a CALL_EXCEPTION (ethers v6 contract-revert code). That rules out -// ABI-level failures like INVALID_ARGUMENT, BAD_DATA, or an unknown -// selector (which return empty data and decode-fail before CALL_EXCEPTION -// is raised). The test fails if the tx unexpectedly succeeds -- a useful -// signal that business logic changed. +// TEST_ADDRESS. The contract should either (a) revert with CALL_EXCEPTION +// on business logic, or (b) succeed and return "0x" for void functions. +// Both outcomes prove the deployed bytecode understood the calldata. +// What we reject: calldata-level ethers errors (INVALID_ARGUMENT, BAD_DATA, +// BUFFER_OVERRUN) which would indicate the ABI doesn't match the +// deployed contract. Observed: supply reverts (ERC20 transferFrom fails +// on zero allowance); setUsingAsCollateral silently succeeds on +// reserveId=0 because the Spoke no-ops on nonexistent reserves. describe.skipIf(!RPC_URL)("Aave V4 Lido Spoke on-chain integration", () => { const getProvider = (): ethers.JsonRpcProvider => new ethers.JsonRpcProvider(RPC_URL); @@ -156,7 +158,7 @@ describe.skipIf(!RPC_URL)("Aave V4 Lido Spoke on-chain integration", () => { expect(typeof struct.borrowCount).toBe("bigint"); }, 15_000); - it("supply: deployed bytecode accepts the calldata (business revert expected)", async () => { + it("supply: deployed bytecode accepts the calldata", async () => { const { to, data } = buildCalldata(aaveV4Def, "supply", { reserveId: "0", amount: "1000000000000000000", @@ -164,12 +166,10 @@ describe.skipIf(!RPC_URL)("Aave V4 Lido Spoke on-chain integration", () => { }); const provider = getProvider(); - await expect( - provider.call({ to, data, from: TEST_ADDRESS }) - ).rejects.toMatchObject({ code: "CALL_EXCEPTION" }); + await expectCallAcceptedByBytecode(provider, { to, data }); }, 15_000); - it("setUsingAsCollateral: deployed bytecode accepts the calldata (business revert expected)", async () => { + it("setUsingAsCollateral: deployed bytecode accepts the calldata", async () => { const { to, data } = buildCalldata(aaveV4Def, "set-collateral", { reserveId: "0", usingAsCollateral: "true", @@ -177,8 +177,24 @@ describe.skipIf(!RPC_URL)("Aave V4 Lido Spoke on-chain integration", () => { }); const provider = getProvider(); - await expect( - provider.call({ to, data, from: TEST_ADDRESS }) - ).rejects.toMatchObject({ code: "CALL_EXCEPTION" }); + await expectCallAcceptedByBytecode(provider, { to, data }); }, 15_000); }); + +/** + * Asserts the deployed bytecode accepted our calldata: either the call + * returned cleanly (void functions return "0x") or reverted at the contract + * level (CALL_EXCEPTION). Any other error class means the ABI doesn't match + * what's deployed. + */ +async function expectCallAcceptedByBytecode( + provider: ethers.JsonRpcProvider, + tx: { to: string; data: string } +): Promise { + try { + const result = await provider.call({ ...tx, from: TEST_ADDRESS }); + expect(result).toMatch(/^0x/); + } catch (err: unknown) { + expect(err).toMatchObject({ code: "CALL_EXCEPTION" }); + } +} From 3a9bdd02acfffad036d4cc0088c901cb8e2b5e07 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Tue, 21 Apr 2026 16:53:10 +1000 Subject: [PATCH 40/41] fix(mcp): make call_workflow wait for read completion (KEEP-265) Read-workflow calls via /api/mcp/workflows/[slug]/call now block up to 25s for execution to finish and return the mapped output inline. Long-running reads still degrade gracefully to {executionId, status: "running"} so callers can poll. Write workflows are unchanged. --- app/api/mcp/workflows/[slug]/call/route.ts | 20 +- lib/x402/execution-wait.ts | 145 +++++++++++ tests/unit/execution-wait.test.ts | 264 +++++++++++++++++++++ tests/unit/mcp-meta-tools.test.ts | 11 + tests/unit/x402-call-route.test.ts | 94 ++++++++ 5 files changed, 526 insertions(+), 8 deletions(-) create mode 100644 lib/x402/execution-wait.ts create mode 100644 tests/unit/execution-wait.test.ts diff --git a/app/api/mcp/workflows/[slug]/call/route.ts b/app/api/mcp/workflows/[slug]/call/route.ts index 4363ae6e4..920413243 100644 --- a/app/api/mcp/workflows/[slug]/call/route.ts +++ b/app/api/mcp/workflows/[slug]/call/route.ts @@ -15,6 +15,7 @@ import { } from "@/lib/payments/router"; import { executeWorkflow } from "@/lib/workflow-executor.workflow"; import type { WorkflowEdge, WorkflowNode } from "@/lib/workflow-store"; +import { buildCallCompletionResponse } from "@/lib/x402/execution-wait"; import { hashPaymentSignature, recordPayment, @@ -146,8 +147,9 @@ function startExecutionInBackground( } /** - * Free-path helper: prepares the execution and starts it. Used by the - * non-paid call path where there is no payment to record between the two. + * Free-path helper: prepares the execution, starts it, and awaits completion + * up to the read-wait timeout. Returns the mapped output inline on success or + * falls back to `{executionId, status: "running"}` on timeout. */ async function createAndStartExecution( workflow: CallRouteWorkflow, @@ -158,10 +160,11 @@ async function createAndStartExecution( return prepared.error; } startExecutionInBackground(workflow, body, prepared.executionId); - return NextResponse.json( - { executionId: prepared.executionId, status: "running" }, - { headers: corsHeaders } + const responseBody = await buildCallCompletionResponse( + prepared.executionId, + workflow.outputMapping ); + return NextResponse.json(responseBody, { headers: corsHeaders }); } async function lookupWorkflow(slug: string): Promise { @@ -333,10 +336,11 @@ async function handlePaidWorkflow( startExecutionInBackground(workflow, body, executionId); - return NextResponse.json( - { executionId, status: "running" }, - { headers: corsHeaders } + const responseBody = await buildCallCompletionResponse( + executionId, + workflow.outputMapping ); + return NextResponse.json(responseBody, { headers: corsHeaders }); }; } ); diff --git a/lib/x402/execution-wait.ts b/lib/x402/execution-wait.ts new file mode 100644 index 000000000..73b17d3b3 --- /dev/null +++ b/lib/x402/execution-wait.ts @@ -0,0 +1,145 @@ +import "server-only"; + +import { and, desc, eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { workflowExecutionLogs, workflowExecutions } from "@/lib/db/schema"; + +/** + * Default timeout for waiting on read-workflow completion before falling back + * to the async `{executionId, status: "running"}` response. Kept under typical + * HTTP/MCP client timeouts (~30s) so clients don't time out on us. + */ +export const DEFAULT_CALL_WAIT_TIMEOUT_MS = 25_000; +const DEFAULT_POLL_INTERVAL_MS = 250; + +type TerminalStatus = "success" | "error" | "cancelled"; + +type ExecutionResult = { + status: TerminalStatus; + output: unknown; + error: string | null; +}; + +/** + * Poll workflowExecutions.status until it reaches a terminal state (success, + * error, cancelled) or the timeout elapses. Returns null on timeout. + */ +export async function waitForExecutionCompletion( + executionId: string, + timeoutMs: number = DEFAULT_CALL_WAIT_TIMEOUT_MS, + pollIntervalMs: number = DEFAULT_POLL_INTERVAL_MS +): Promise { + if (timeoutMs <= 0) { + return null; + } + const deadline = Date.now() + timeoutMs; + while (true) { + const row = await db.query.workflowExecutions.findFirst({ + where: eq(workflowExecutions.id, executionId), + columns: { status: true, output: true, error: true }, + }); + if (!row) { + return null; + } + if ( + row.status === "success" || + row.status === "error" || + row.status === "cancelled" + ) { + return { + status: row.status, + output: row.output, + error: row.error ?? null, + }; + } + if (Date.now() + pollIntervalMs >= deadline) { + return null; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } +} + +/** + * Resolve the payload returned inline to the caller for a completed read + * workflow. outputMapping shape is `{ nodeId?: string, fields?: string[] }`: + * - If nodeId is set, fetch that node's successful log output. + * - If fields is set, pick only those keys from the node output. + * - Otherwise, return the workflow-level output as-is. + */ +export async function applyOutputMapping( + executionId: string, + workflowOutput: unknown, + outputMapping: Record | null | undefined +): Promise { + if (!outputMapping || typeof outputMapping !== "object") { + return workflowOutput; + } + const mapping = outputMapping as { nodeId?: unknown; fields?: unknown }; + const nodeId = typeof mapping.nodeId === "string" ? mapping.nodeId : null; + if (!nodeId) { + return workflowOutput; + } + + const log = await db.query.workflowExecutionLogs.findFirst({ + where: and( + eq(workflowExecutionLogs.executionId, executionId), + eq(workflowExecutionLogs.nodeId, nodeId), + eq(workflowExecutionLogs.status, "success") + ), + orderBy: [desc(workflowExecutionLogs.completedAt)], + }); + const nodeOutput = log?.output ?? workflowOutput; + + if ( + Array.isArray(mapping.fields) && + mapping.fields.length > 0 && + nodeOutput && + typeof nodeOutput === "object" + ) { + const picked: Record = {}; + for (const field of mapping.fields) { + if (typeof field === "string") { + picked[field] = (nodeOutput as Record)[field]; + } + } + return picked; + } + return nodeOutput; +} + +export type CallCompletionResponse = + | { executionId: string; status: "success"; output: unknown } + | { executionId: string; status: "error"; error: string } + | { executionId: string; status: "running" }; + +/** + * Wait for the read-workflow execution to complete, then build the response + * payload. On timeout, returns `{status: "running"}` so clients can fall back + * to polling the existing status/logs endpoints. + */ +export async function buildCallCompletionResponse( + executionId: string, + outputMapping: Record | null | undefined, + timeoutMs: number = DEFAULT_CALL_WAIT_TIMEOUT_MS +): Promise { + const result = await waitForExecutionCompletion(executionId, timeoutMs); + if (!result) { + return { executionId, status: "running" }; + } + if (result.status === "success") { + const output = await applyOutputMapping( + executionId, + result.output, + outputMapping + ); + return { executionId, status: "success", output }; + } + if (result.status === "cancelled") { + return { executionId, status: "error", error: "Execution cancelled" }; + } + return { + executionId, + status: "error", + error: result.error ?? "Execution failed", + }; +} diff --git a/tests/unit/execution-wait.test.ts b/tests/unit/execution-wait.test.ts new file mode 100644 index 000000000..537879025 --- /dev/null +++ b/tests/unit/execution-wait.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +// --------------------------------------------------------------------------- +// Hoisted mocks +// --------------------------------------------------------------------------- + +const { mockFindFirstExecution, mockFindFirstLog } = vi.hoisted(() => ({ + mockFindFirstExecution: vi.fn(), + mockFindFirstLog: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + db: { + query: { + workflowExecutions: { findFirst: mockFindFirstExecution }, + workflowExecutionLogs: { findFirst: mockFindFirstLog }, + }, + }, +})); + +vi.mock("@/lib/db/schema", () => ({ + workflowExecutions: { id: "id" }, + workflowExecutionLogs: { + executionId: "execution_id", + nodeId: "node_id", + status: "status", + completedAt: "completed_at", + }, +})); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("waitForExecutionCompletion (KEEP-265)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null immediately when timeout <= 0", async () => { + const { waitForExecutionCompletion } = await import( + "@/lib/x402/execution-wait" + ); + const result = await waitForExecutionCompletion("exec-1", 0); + expect(result).toBeNull(); + expect(mockFindFirstExecution).not.toHaveBeenCalled(); + }); + + it("returns success result when execution is already terminal", async () => { + mockFindFirstExecution.mockResolvedValue({ + status: "success", + output: { foo: "bar" }, + error: null, + }); + const { waitForExecutionCompletion } = await import( + "@/lib/x402/execution-wait" + ); + const result = await waitForExecutionCompletion("exec-1", 1000, 10); + expect(result).toEqual({ + status: "success", + output: { foo: "bar" }, + error: null, + }); + }); + + it("returns error result with error message when execution failed", async () => { + mockFindFirstExecution.mockResolvedValue({ + status: "error", + output: null, + error: "RPC down", + }); + const { waitForExecutionCompletion } = await import( + "@/lib/x402/execution-wait" + ); + const result = await waitForExecutionCompletion("exec-1", 1000, 10); + expect(result?.status).toBe("error"); + expect(result?.error).toBe("RPC down"); + }); + + it("returns null if execution row is missing", async () => { + mockFindFirstExecution.mockResolvedValue(undefined); + const { waitForExecutionCompletion } = await import( + "@/lib/x402/execution-wait" + ); + const result = await waitForExecutionCompletion("exec-missing", 100, 10); + expect(result).toBeNull(); + }); + + it("polls until terminal status appears", async () => { + mockFindFirstExecution + .mockResolvedValueOnce({ status: "running", output: null, error: null }) + .mockResolvedValueOnce({ status: "running", output: null, error: null }) + .mockResolvedValueOnce({ + status: "success", + output: { balance: "1.3286 ETH" }, + error: null, + }); + const { waitForExecutionCompletion } = await import( + "@/lib/x402/execution-wait" + ); + const result = await waitForExecutionCompletion("exec-2", 1000, 5); + expect(result?.status).toBe("success"); + expect(mockFindFirstExecution).toHaveBeenCalledTimes(3); + }); + + it("returns null on timeout when never reaching terminal state", async () => { + mockFindFirstExecution.mockResolvedValue({ + status: "running", + output: null, + error: null, + }); + const { waitForExecutionCompletion } = await import( + "@/lib/x402/execution-wait" + ); + const start = Date.now(); + const result = await waitForExecutionCompletion("exec-3", 40, 10); + const elapsed = Date.now() - start; + expect(result).toBeNull(); + expect(elapsed).toBeGreaterThanOrEqual(30); + }); +}); + +describe("applyOutputMapping (KEEP-265)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns raw workflow output when outputMapping is null", async () => { + const { applyOutputMapping } = await import("@/lib/x402/execution-wait"); + const result = await applyOutputMapping("exec-1", { balance: "1.5" }, null); + expect(result).toEqual({ balance: "1.5" }); + expect(mockFindFirstLog).not.toHaveBeenCalled(); + }); + + it("returns raw workflow output when outputMapping has no nodeId", async () => { + const { applyOutputMapping } = await import("@/lib/x402/execution-wait"); + const result = await applyOutputMapping( + "exec-1", + { balance: "1.5" }, + { fields: ["balance"] } + ); + expect(result).toEqual({ balance: "1.5" }); + }); + + it("picks specific fields from the mapped node output", async () => { + mockFindFirstLog.mockResolvedValue({ + output: { + riskScore: 3, + vulnerabilities: ["reentrancy"], + internalDebug: "ignore-me", + }, + }); + const { applyOutputMapping } = await import("@/lib/x402/execution-wait"); + const result = await applyOutputMapping("exec-1", null, { + nodeId: "audit-1", + fields: ["riskScore", "vulnerabilities"], + }); + expect(result).toEqual({ + riskScore: 3, + vulnerabilities: ["reentrancy"], + }); + }); + + it("returns full node output when nodeId is set but fields is not", async () => { + mockFindFirstLog.mockResolvedValue({ + output: { a: 1, b: 2 }, + }); + const { applyOutputMapping } = await import("@/lib/x402/execution-wait"); + const result = await applyOutputMapping("exec-1", null, { + nodeId: "audit-1", + }); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it("falls back to workflow output when the mapped node log is missing", async () => { + mockFindFirstLog.mockResolvedValue(undefined); + const { applyOutputMapping } = await import("@/lib/x402/execution-wait"); + const result = await applyOutputMapping( + "exec-1", + { fallback: true }, + { nodeId: "missing-node" } + ); + expect(result).toEqual({ fallback: true }); + }); +}); + +describe("buildCallCompletionResponse (KEEP-265)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns { status: 'running' } on timeout", async () => { + mockFindFirstExecution.mockResolvedValue({ + status: "running", + output: null, + error: null, + }); + const { buildCallCompletionResponse } = await import( + "@/lib/x402/execution-wait" + ); + const res = await buildCallCompletionResponse("exec-timeout", null, 30); + expect(res).toEqual({ executionId: "exec-timeout", status: "running" }); + }); + + it("returns mapped output on successful completion", async () => { + mockFindFirstExecution.mockResolvedValue({ + status: "success", + output: { balance: "1.3286 ETH", _debug: "noise" }, + error: null, + }); + mockFindFirstLog.mockResolvedValue({ + output: { balance: "1.3286 ETH", _debug: "noise" }, + }); + const { buildCallCompletionResponse } = await import( + "@/lib/x402/execution-wait" + ); + const res = await buildCallCompletionResponse( + "exec-success", + { nodeId: "last", fields: ["balance"] }, + 1000 + ); + expect(res).toEqual({ + executionId: "exec-success", + status: "success", + output: { balance: "1.3286 ETH" }, + }); + }); + + it("returns error payload when execution fails within timeout", async () => { + mockFindFirstExecution.mockResolvedValue({ + status: "error", + output: null, + error: "RPC failed", + }); + const { buildCallCompletionResponse } = await import( + "@/lib/x402/execution-wait" + ); + const res = await buildCallCompletionResponse("exec-err", null, 1000); + expect(res).toEqual({ + executionId: "exec-err", + status: "error", + error: "RPC failed", + }); + }); + + it("maps cancelled status to an error response", async () => { + mockFindFirstExecution.mockResolvedValue({ + status: "cancelled", + output: null, + error: null, + }); + const { buildCallCompletionResponse } = await import( + "@/lib/x402/execution-wait" + ); + const res = await buildCallCompletionResponse("exec-cancel", null, 1000); + expect(res).toEqual({ + executionId: "exec-cancel", + status: "error", + error: "Execution cancelled", + }); + }); +}); diff --git a/tests/unit/mcp-meta-tools.test.ts b/tests/unit/mcp-meta-tools.test.ts index 759ff22cb..5b6e37603 100644 --- a/tests/unit/mcp-meta-tools.test.ts +++ b/tests/unit/mcp-meta-tools.test.ts @@ -382,6 +382,7 @@ describe("POST /api/mcp/workflows/[slug]/call: write workflow returns calldata", mockGenerateCalldata, mockAuthenticateApiKey, mockAuthenticateOAuthToken, + mockBuildCallCompletionResponse, } = vi.hoisted(() => ({ mockDbSelect: vi.fn(), mockDbInsert: vi.fn(), @@ -401,6 +402,7 @@ describe("POST /api/mcp/workflows/[slug]/call: write workflow returns calldata", mockGenerateCalldata: vi.fn(), mockAuthenticateApiKey: vi.fn(), mockAuthenticateOAuthToken: vi.fn(), + mockBuildCallCompletionResponse: vi.fn(), })); vi.mock("@/lib/db", () => ({ @@ -463,6 +465,10 @@ describe("POST /api/mcp/workflows/[slug]/call: write workflow returns calldata", checkConcurrencyLimit: mockCheckConcurrencyLimit, })); + vi.mock("@/lib/x402/execution-wait", () => ({ + buildCallCompletionResponse: mockBuildCallCompletionResponse, + })); + vi.mock("@/lib/logging", () => ({ ErrorCategory: { WORKFLOW_ENGINE: "workflow_engine" }, logSystemError: mockLogSystemError, @@ -546,6 +552,11 @@ describe("POST /api/mcp/workflows/[slug]/call: write workflow returns calldata", where: vi.fn().mockResolvedValue(undefined), }), }); + // Default: completion wait times out so we fall back to running response. + mockBuildCallCompletionResponse.mockImplementation( + (executionId: string) => + Promise.resolve({ executionId, status: "running" }) + ); // Default: caller is authenticated. The write workflow path requires // an API key or MCP OAuth token, same as the free read path. mockAuthenticateOAuthToken.mockReturnValue({ diff --git a/tests/unit/x402-call-route.test.ts b/tests/unit/x402-call-route.test.ts index 109cd5c98..0e503e972 100644 --- a/tests/unit/x402-call-route.test.ts +++ b/tests/unit/x402-call-route.test.ts @@ -21,6 +21,7 @@ const { mockLogSystemError, mockAuthenticateApiKey, mockAuthenticateOAuthToken, + mockBuildCallCompletionResponse, } = vi.hoisted(() => ({ mockDbSelect: vi.fn(), mockDbInsert: vi.fn(), @@ -38,6 +39,7 @@ const { mockLogSystemError: vi.fn(), mockAuthenticateApiKey: vi.fn(), mockAuthenticateOAuthToken: vi.fn(), + mockBuildCallCompletionResponse: vi.fn(), })); // --------------------------------------------------------------------------- @@ -102,6 +104,10 @@ vi.mock("@/lib/logging", () => ({ logSystemError: mockLogSystemError, })); +vi.mock("@/lib/x402/execution-wait", () => ({ + buildCallCompletionResponse: mockBuildCallCompletionResponse, +})); + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -212,6 +218,12 @@ describe("POST /api/mcp/workflows/[slug]/call", () => { mockRecordPayment.mockResolvedValue(undefined); mockHashPaymentSignature.mockReturnValue("hash-abc"); mockResolveCreatorWallet.mockResolvedValue(CREATOR_WALLET); + // Default: simulate timeout so we fall back to running response. Tests + // exercising the synchronous completion path override this explicitly. + mockBuildCallCompletionResponse.mockImplementation( + (executionId: string) => + Promise.resolve({ executionId, status: "running" }) + ); // Default no-op update chain: db.update(table).set(values).where(filter) mockDbUpdate.mockReturnValue({ set: vi.fn().mockReturnValue({ @@ -534,6 +546,88 @@ describe("POST /api/mcp/workflows/[slug]/call", () => { expect(mockStart).not.toHaveBeenCalled(); }); + it("Test 15b: free read workflow returns mapped output inline when execution completes within timeout (KEEP-265)", async () => { + setupDbSelectWorkflow(FREE_WORKFLOW); + setupDbInsertExecution("exec-sync-1"); + mockBuildCallCompletionResponse.mockResolvedValue({ + executionId: "exec-sync-1", + status: "success", + output: { balance: "1.3286 ETH" }, + }); + const { POST } = await import("@/app/api/mcp/workflows/[slug]/call/route"); + const request = makeRequest("test-workflow"); + const params = Promise.resolve({ slug: "test-workflow" }); + const response = await POST(request, { params }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.executionId).toBe("exec-sync-1"); + expect(body.status).toBe("success"); + expect(body.output).toEqual({ balance: "1.3286 ETH" }); + // Workflow still kicked off in the background prior to the wait. + expect(mockStart).toHaveBeenCalled(); + }); + + it("Test 15c: free read workflow falls back to running on timeout (KEEP-265)", async () => { + setupDbSelectWorkflow(FREE_WORKFLOW); + setupDbInsertExecution("exec-timeout-1"); + mockBuildCallCompletionResponse.mockResolvedValue({ + executionId: "exec-timeout-1", + status: "running", + }); + const { POST } = await import("@/app/api/mcp/workflows/[slug]/call/route"); + const request = makeRequest("test-workflow"); + const params = Promise.resolve({ slug: "test-workflow" }); + const response = await POST(request, { params }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.executionId).toBe("exec-timeout-1"); + expect(body.status).toBe("running"); + expect(body.output).toBeUndefined(); + }); + + it("Test 15d: free read workflow returns error status when execution fails within timeout (KEEP-265)", async () => { + setupDbSelectWorkflow(FREE_WORKFLOW); + setupDbInsertExecution("exec-err-1"); + mockBuildCallCompletionResponse.mockResolvedValue({ + executionId: "exec-err-1", + status: "error", + error: "RPC provider returned 500", + }); + const { POST } = await import("@/app/api/mcp/workflows/[slug]/call/route"); + const request = makeRequest("test-workflow"); + const params = Promise.resolve({ slug: "test-workflow" }); + const response = await POST(request, { params }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.executionId).toBe("exec-err-1"); + expect(body.status).toBe("error"); + expect(body.error).toContain("RPC provider"); + }); + + it("Test 15e: paid read workflow returns mapped output inline on synchronous completion (KEEP-265)", async () => { + setupDbSelectWorkflow(LISTED_WORKFLOW); + setupDbInsertExecution("exec-paid-sync-1"); + makePassThroughGatePayment(); + mockBuildCallCompletionResponse.mockResolvedValue({ + executionId: "exec-paid-sync-1", + status: "success", + output: { riskScore: 2 }, + }); + const { POST } = await import("@/app/api/mcp/workflows/[slug]/call/route"); + const request = makeRequest("test-workflow", { + paymentSignature: "sig-sync", + }); + const params = Promise.resolve({ slug: "test-workflow" }); + const response = await POST(request, { params }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.executionId).toBe("exec-paid-sync-1"); + expect(body.status).toBe("success"); + expect(body.output).toEqual({ riskScore: 2 }); + // Payment must still be recorded before completion wait returned a result. + expect(mockRecordPayment).toHaveBeenCalled(); + }); + it("Test 16: paid workflow probe with empty body returns 402 before body validation", async () => { const workflowWithRequiredField = { ...LISTED_WORKFLOW, From 23edead3572349176fb4ef6a015345c0004ef3a6 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Tue, 21 Apr 2026 17:48:27 +1000 Subject: [PATCH 41/41] fix(analytics): account for app-banner height in page padding The main-content branch used a fixed pt-20, so when AppBanner was visible (fresh sessions without dismissal), the persistent WorkflowToolbar stacked below the banner covered the AnalyticsHeader and the time-range nav was unclickable. The hasNoData branch already used pt-[calc(5rem+var(--app-banner-height,0px))] -- applying the same pattern here. Fixes the failing analytics-gas e2e test on staging. --- components/analytics/analytics-page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/analytics/analytics-page.tsx b/components/analytics/analytics-page.tsx index 4a278eea6..f94d55b4b 100644 --- a/components/analytics/analytics-page.tsx +++ b/components/analytics/analytics-page.tsx @@ -118,7 +118,7 @@ export function AnalyticsPage(): ReactNode { return (
-
+