From 2bfda719d3d0817366cb494130ee97f856186ae5 Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 18 Mar 2026 18:33:41 +0800 Subject: [PATCH 01/30] feat: init useX402Fetch and useX402Auth --- packages/no-modal/package.json | 8 +- packages/no-modal/src/base/index.ts | 1 + packages/no-modal/src/base/x402/index.ts | 2 + packages/no-modal/src/base/x402/interfaces.ts | 35 +++ packages/no-modal/src/base/x402/x402.ts | 238 ++++++++++++++++++ packages/no-modal/src/react/hooks/index.ts | 1 + .../no-modal/src/react/hooks/useX402Auth.ts | 105 ++++++++ .../no-modal/src/react/hooks/useX402Fetch.ts | 97 +++++++ 8 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 packages/no-modal/src/base/x402/index.ts create mode 100644 packages/no-modal/src/base/x402/interfaces.ts create mode 100644 packages/no-modal/src/base/x402/x402.ts create mode 100644 packages/no-modal/src/react/hooks/useX402Auth.ts create mode 100644 packages/no-modal/src/react/hooks/useX402Fetch.ts diff --git a/packages/no-modal/package.json b/packages/no-modal/package.json index 4c4f75327..17bdaea92 100644 --- a/packages/no-modal/package.json +++ b/packages/no-modal/package.json @@ -75,6 +75,8 @@ "@walletconnect/utils": "^2.23.3", "@web3auth/auth": "^10.8.0", "@web3auth/ws-embed": "^5.5.0", + "@x402/evm": "^2.7.0", + "@x402/fetch": "^2.7.0", "assert": "^2.1.0", "bignumber.js": "~9.3.1", "bn.js": "^5.2.2", @@ -88,8 +90,10 @@ "mipd": "^0.0.7", "permissionless": "^0.3.2", "ripple-keypairs": "^1.3.1", + "siwe": "^3.0.0", "ts-custom-error": "^3.3.1", - "xrpl": "^2.14.0" + "xrpl": "^2.14.0", + "wagmi": "^3.4.1" }, "devDependencies": { "@coinbase/wallet-sdk": "^4.3.7", @@ -99,7 +103,7 @@ "@wagmi/core": "^2.20.1", "@wagmi/vue": "^0.2.2", "react": "^19.2.3", - "viem": "^2.44.2", + "viem": "^2.45.0", "vue": "^3.x", "wagmi": "^2.16.6" }, diff --git a/packages/no-modal/src/base/index.ts b/packages/no-modal/src/base/index.ts index d172e79d4..2eee58eb8 100644 --- a/packages/no-modal/src/base/index.ts +++ b/packages/no-modal/src/base/index.ts @@ -13,3 +13,4 @@ export * from "./plugin"; export * from "./provider/IProvider"; export * from "./utils"; export * from "./wallet"; +export * from "./x402"; diff --git a/packages/no-modal/src/base/x402/index.ts b/packages/no-modal/src/base/x402/index.ts new file mode 100644 index 000000000..61ff064f0 --- /dev/null +++ b/packages/no-modal/src/base/x402/index.ts @@ -0,0 +1,2 @@ +export * from "./interfaces"; +export * from "./x402"; diff --git a/packages/no-modal/src/base/x402/interfaces.ts b/packages/no-modal/src/base/x402/interfaces.ts new file mode 100644 index 000000000..a264d3870 --- /dev/null +++ b/packages/no-modal/src/base/x402/interfaces.ts @@ -0,0 +1,35 @@ +export type X402SiweAuthResponse = { + token: string; + accountId: string; + expiresAt: string; +}; + +export type MethodProtocol = "JSON-RPC" | "REST"; + +export type Method = { + id: string; + name: string; + description: string; + protocol: MethodProtocol; + network: string; + networkDisplay: string; + rpcMethod?: string; + rpcParams?: unknown[]; + restPath?: string; + restMethod?: "GET" | "POST"; +}; + +export type MethodExecutionResult = { + id: string; + methodId: string; + methodName: string; + network: string; + networkDisplay: string; + protocol: MethodProtocol; + status: number; + ok: boolean; + requestedAt: string; + data: unknown; + error?: string; + paymentResponse?: unknown; +}; diff --git a/packages/no-modal/src/base/x402/x402.ts b/packages/no-modal/src/base/x402/x402.ts new file mode 100644 index 000000000..e73234439 --- /dev/null +++ b/packages/no-modal/src/base/x402/x402.ts @@ -0,0 +1,238 @@ +import { randomId } from "@web3auth/auth"; +import { ExactEvmScheme, toClientEvmSigner } from "@x402/evm"; +import { decodePaymentResponseHeader, wrapFetchWithPayment, x402Client } from "@x402/fetch"; +import { generateNonce, SiweMessage } from "siwe"; +import { WalletClient } from "viem"; +import { baseSepolia } from "viem/chains"; + +import { Method, MethodExecutionResult, X402SiweAuthResponse } from "./interfaces"; + +export const DEFAULT_X402_BASE_URL = "https://x402.quicknode.com"; +export const X402_AUTH_URL = `${DEFAULT_X402_BASE_URL}/api/v1/auth`; +export const EVM_CAIP2_WILDCARD = "eip155:*"; + +/** + * Authenticate with X402 server using SIWE and get the JWT Token. + * The token will be later used to make payment requests to the X402 server. + * + * @param walletClient - The wallet client to use for authentication + * @returns The authentication response + */ +export async function authenticateWithX402Server(walletClient: WalletClient): Promise { + const address = walletClient.account?.address; + if (!address) { + throw new Error("Wallet client account address is required"); + } + + const x402Url = new URL(DEFAULT_X402_BASE_URL); + const domain = x402Url.host; + + const siweMessage = new SiweMessage({ + domain, + address, + uri: x402Url.origin, + version: "1", + chainId: baseSepolia.id, // TODO: Get the chain id from the wallet client + nonce: generateNonce(), + }); + + const message = siweMessage.prepareMessage(); + const signature = await walletClient.signMessage({ + account: address, + message, + }); + + const response = await fetch(X402_AUTH_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ message, signature }), + }); + + if (!response.ok) { + throw new Error(await parseErrorResponse(response)); + } + + const data = await response.json(); + return data as X402SiweAuthResponse; +} + +/** + * Create a fetch function that wraps the original fetch function and adds JWT auth and normalizes 402 responses. + * The x402 client library only accepts v2 payment requirements in the PAYMENT-REQUIRED + * header, but some servers may return them in the response body. This shim detects that + * case and promotes the body into the header so wrapFetchWithPayment can process it. + * + * @param walletClient - The wallet client to use for authentication + * @param headers - The headers to add to the request + * @returns The fetch function + */ +export function createX402Fetch(walletClient: WalletClient, jwt: string): typeof fetch { + const address = walletClient.account?.address; + if (!address) { + throw new Error("Wallet account is unavailable."); + } + + // Fetch wrapper that adds JWT auth and normalizes 402 responses. + // The x402 client library only accepts v2 payment requirements in the PAYMENT-REQUIRED + // header, but the server may return them in the response body. This shim detects that + // case and promotes the body into the header so wrapFetchWithPayment can process it. + const authedFetch: typeof fetch = async (input, init) => { + const req = new Request(input, init); + req.headers.set("Authorization", `Bearer ${jwt}`); + + const response = await fetch(req); + + if (response.status === 402 && !response.headers.get("PAYMENT-REQUIRED")) { + const body = await response.text(); + try { + const parsed = JSON.parse(body) as { x402Version?: number }; + if (parsed.x402Version && parsed.x402Version >= 2) { + const encoded = btoa(JSON.stringify(parsed)); + const newHeaders = new Headers(response.headers); + newHeaders.set("PAYMENT-REQUIRED", encoded); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); + } + } catch { + // Not JSON — return original response + } + // Return a new response with the already-consumed body + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } + + return response; + }; + + const evmSigner = toClientEvmSigner({ + address, + signTypedData: async (params) => { + const typedDataParams = params as { + domain: Record; + types: Record; + primaryType: string; + message: Record; + }; + + return walletClient.signTypedData({ + account: address, + domain: typedDataParams.domain as never, + types: typedDataParams.types as never, + primaryType: typedDataParams.primaryType as never, + message: typedDataParams.message as never, + } as never); + }, + }); + + const client = new x402Client().register(EVM_CAIP2_WILDCARD, new ExactEvmScheme(evmSigner)); + return wrapFetchWithPayment(authedFetch, client); +} + +export async function executeX402Method(x402Fetch: typeof fetch, jwt: string, method: Method): Promise { + const requestedAt = new Date().toISOString(); + const resultId = randomId(); + const endpoint = `${DEFAULT_X402_BASE_URL}/${method.network}`; + + let requestUrl = endpoint; + let init: RequestInit; + + if (method.protocol === "JSON-RPC") { + if (!method.rpcMethod) { + throw new Error(`Method ${method.id} does not provide rpcMethod`); + } + + init = { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: Date.now(), + method: method.rpcMethod, + params: method.rpcParams ?? [], + }), + }; + } else { + if (!method.restPath || !method.restMethod) { + throw new Error(`Method ${method.id} does not provide REST path/method`); + } + + requestUrl = `${endpoint}${method.restPath}`; + init = { + method: method.restMethod, + headers: { + "Content-Type": "application/json", + }, + }; + } + + let response: Response; + try { + response = await x402Fetch(requestUrl, init); + } catch { + // wrapFetchWithPayment throws when it can't parse the 402 payment requirements. + // Fall back to a plain authenticated request so we can surface the raw server response. + const headers = new Headers(init.headers); + headers.set("Authorization", `Bearer ${jwt}`); + response = await fetch(requestUrl, { ...init, headers }); + } + + const responseText = await response.text(); + + let parsedBody: unknown = responseText; + if (responseText) { + try { + parsedBody = JSON.parse(responseText); + } catch { + parsedBody = responseText; + } + } + + const paymentHeader = response.headers.get("PAYMENT-RESPONSE") ?? response.headers.get("X-PAYMENT-RESPONSE"); + let paymentResponse: unknown; + if (paymentHeader) { + try { + paymentResponse = decodePaymentResponseHeader(paymentHeader); + } catch { + paymentResponse = paymentHeader; + } + } + + return { + id: resultId, + methodId: method.id, + methodName: method.name, + network: method.network, + networkDisplay: method.networkDisplay, + protocol: method.protocol, + requestedAt, + status: response.status, + ok: response.ok, + data: parsedBody, + error: response.ok ? undefined : (parsedBody as { error?: string })?.error, + paymentResponse, + }; +} + +async function parseErrorResponse(response: Response): Promise { + const body = await response.text(); + if (!body) { + return `Request failed with status ${response.status}`; + } + + try { + const parsed = JSON.parse(body) as { message?: string; error?: string }; + return parsed.message ?? parsed.error ?? body; + } catch { + return body; + } +} diff --git a/packages/no-modal/src/react/hooks/index.ts b/packages/no-modal/src/react/hooks/index.ts index e14ac8e2a..9fdc19388 100644 --- a/packages/no-modal/src/react/hooks/index.ts +++ b/packages/no-modal/src/react/hooks/index.ts @@ -14,3 +14,4 @@ export * from "./useWeb3Auth"; export * from "./useWeb3AuthConnect"; export * from "./useWeb3AuthDisconnect"; export * from "./useWeb3AuthUser"; +export * from "./useX402Fetch"; diff --git a/packages/no-modal/src/react/hooks/useX402Auth.ts b/packages/no-modal/src/react/hooks/useX402Auth.ts new file mode 100644 index 000000000..feca43445 --- /dev/null +++ b/packages/no-modal/src/react/hooks/useX402Auth.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useAccount, useWalletClient } from "wagmi"; + +import { authenticateWithX402Server } from "../../base/x402/x402"; + +export interface IUseX402AuthReturnValues { + jwt: string | null; + accountId: string | null; + expiresAt: Date | null; + isAuthenticated: boolean; + isAuthenticating: boolean; + authError: string | null; + authenticate: () => Promise; + clearSession: () => void; +} + +export const useX402Auth = (): IUseX402AuthReturnValues => { + const { isConnected, address } = useAccount(); + const { data: walletClient } = useWalletClient(); + + const [accountId, setAccountId] = useState(null); + const [expiresAt, setExpiresAt] = useState(null); + const [isAuthenticating, setIsAuthenticating] = useState(false); + const [authError, setAuthError] = useState(null); + const [jwt, setJwt] = useState(null); + + const isAuthenticated = useMemo(() => { + if (!jwt || !expiresAt) { + return false; + } + + return expiresAt.getTime() > Date.now(); + }, [expiresAt, jwt]); + + const authenticate = useCallback(async () => { + if (!walletClient) { + throw new Error("Connect a wallet before authentication."); + } + + setIsAuthenticating(true); + setAuthError(null); + + try { + const response = await authenticateWithX402Server(walletClient); + // eslint-disable-next-line no-console + console.log("response", response); + setJwt(response.token); + setAccountId(response.accountId); + setExpiresAt(new Date(response.expiresAt)); + } catch (error) { + const message = error instanceof Error ? error.message : "Authentication failed."; + setAuthError(message); + throw error; + } finally { + setIsAuthenticating(false); + } + }, [walletClient]); + + const clearSession = useCallback(() => { + setJwt(null); + setAccountId(null); + setExpiresAt(null); + setAuthError(null); + }, []); + + useEffect(() => { + if (!isConnected) { + clearSession(); + } + }, [clearSession, isConnected]); + + // Clear session when the wallet address changes (e.g. switching accounts) + useEffect(() => { + clearSession(); + }, [address]); + + useEffect(() => { + if (!expiresAt) { + return; + } + + const msUntilExpiry = expiresAt.getTime() - Date.now(); + if (msUntilExpiry <= 0) { + clearSession(); + return; + } + + const timeoutId = window.setTimeout(() => { + clearSession(); + }, msUntilExpiry); + + return () => window.clearTimeout(timeoutId); + }, [clearSession, expiresAt]); + + return { + jwt, + accountId, + expiresAt, + isAuthenticated, + isAuthenticating, + authError, + authenticate, + clearSession, + }; +}; diff --git a/packages/no-modal/src/react/hooks/useX402Fetch.ts b/packages/no-modal/src/react/hooks/useX402Fetch.ts new file mode 100644 index 000000000..09b02d83b --- /dev/null +++ b/packages/no-modal/src/react/hooks/useX402Fetch.ts @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { WalletClient } from "viem"; + +import { createX402Fetch, executeX402Method, Method, MethodExecutionResult } from "../../base"; + +export interface IUseX402FetchParams { + walletClient: WalletClient; + jwt: string; + method: Method; +} + +export interface IUseX402FetchReturnValues { + execute: () => Promise; + results: MethodExecutionResult[]; + lastResult: MethodExecutionResult | null; + isExecuting: boolean; + executionError: string | null; + clearResults: () => void; + x402Fetch: typeof fetch | null; +} + +export const usX402Fetch = ({ walletClient, jwt, method }: IUseX402FetchParams): IUseX402FetchReturnValues => { + const [results, setResults] = useState([]); + const [isExecuting, setIsExecuting] = useState(false); + const [executionError, setExecutionError] = useState(null); + + const x402Fetch = useMemo(() => { + if (!jwt) { + return null; + } + + return createX402Fetch(walletClient, jwt); + }, [walletClient, jwt]); + + const execute = useCallback(async () => { + if (!x402Fetch) { + throw new Error("Wallet is not authenticated for X402."); + } + + setIsExecuting(true); + setExecutionError(null); + + try { + const result = await executeX402Method(x402Fetch, jwt, method); + setResults((current) => [result, ...current]); + + if (!result.ok) { + setExecutionError(result.error ?? `Request failed with status ${result.status}`); + } + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Failed to execute x402 request."; + + setExecutionError(errorMessage); + + const errorResult: MethodExecutionResult = { + id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + methodId: method.id, + methodName: method.name, + network: method.network, + networkDisplay: method.networkDisplay, + protocol: method.protocol, + requestedAt: new Date().toISOString(), + status: 0, + ok: false, + data: null, + error: errorMessage, + paymentResponse: undefined, + }; + setResults((current) => [errorResult, ...current]); + + return errorResult; + } + }, [x402Fetch, jwt, method]); + + // Clear results and errors when wallet/session changes + useEffect(() => { + setResults([]); + setExecutionError(null); + }, [jwt]); + + const clearResults = useCallback(() => { + setResults([]); + setExecutionError(null); + }, []); + + return { + x402Fetch, + results, + lastResult: results[0] ?? null, + isExecuting, + executionError, + execute, + clearResults, + }; +}; From 9c2ade9e591f4f89ba9793b7f77ec86d94cd0001 Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 18 Mar 2026 20:06:51 +0800 Subject: [PATCH 02/30] fix: fixed exports from modal --- .gitignore | 1 + packages/modal/src/react/hooks/index.ts | 2 ++ packages/modal/src/react/hooks/useX402Auth.ts | 2 ++ packages/modal/src/react/hooks/useX402Fetch.ts | 2 ++ packages/no-modal/src/react/hooks/index.ts | 1 + packages/no-modal/src/react/hooks/useX402Fetch.ts | 2 +- 6 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 packages/modal/src/react/hooks/useX402Auth.ts create mode 100644 packages/modal/src/react/hooks/useX402Fetch.ts diff --git a/.gitignore b/.gitignore index 0699d6c57..81bfa6507 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ typings/ # Optional npm cache directory .npm +.npmrc # Optional eslint cache .eslintcache diff --git a/packages/modal/src/react/hooks/index.ts b/packages/modal/src/react/hooks/index.ts index e14ac8e2a..b4529d38d 100644 --- a/packages/modal/src/react/hooks/index.ts +++ b/packages/modal/src/react/hooks/index.ts @@ -14,3 +14,5 @@ export * from "./useWeb3Auth"; export * from "./useWeb3AuthConnect"; export * from "./useWeb3AuthDisconnect"; export * from "./useWeb3AuthUser"; +export * from "./useX402Auth"; +export * from "./useX402Fetch"; diff --git a/packages/modal/src/react/hooks/useX402Auth.ts b/packages/modal/src/react/hooks/useX402Auth.ts new file mode 100644 index 000000000..341ec8153 --- /dev/null +++ b/packages/modal/src/react/hooks/useX402Auth.ts @@ -0,0 +1,2 @@ +export type { IUseX402AuthReturnValues } from "@web3auth/no-modal/react"; +export { useX402Auth } from "@web3auth/no-modal/react"; diff --git a/packages/modal/src/react/hooks/useX402Fetch.ts b/packages/modal/src/react/hooks/useX402Fetch.ts new file mode 100644 index 000000000..a0f31dc4a --- /dev/null +++ b/packages/modal/src/react/hooks/useX402Fetch.ts @@ -0,0 +1,2 @@ +export type { IUseX402FetchParams, IUseX402FetchReturnValues } from "@web3auth/no-modal/react"; +export { useX402Fetch } from "@web3auth/no-modal/react"; diff --git a/packages/no-modal/src/react/hooks/index.ts b/packages/no-modal/src/react/hooks/index.ts index 9fdc19388..b4529d38d 100644 --- a/packages/no-modal/src/react/hooks/index.ts +++ b/packages/no-modal/src/react/hooks/index.ts @@ -14,4 +14,5 @@ export * from "./useWeb3Auth"; export * from "./useWeb3AuthConnect"; export * from "./useWeb3AuthDisconnect"; export * from "./useWeb3AuthUser"; +export * from "./useX402Auth"; export * from "./useX402Fetch"; diff --git a/packages/no-modal/src/react/hooks/useX402Fetch.ts b/packages/no-modal/src/react/hooks/useX402Fetch.ts index 09b02d83b..6c31a9a4a 100644 --- a/packages/no-modal/src/react/hooks/useX402Fetch.ts +++ b/packages/no-modal/src/react/hooks/useX402Fetch.ts @@ -19,7 +19,7 @@ export interface IUseX402FetchReturnValues { x402Fetch: typeof fetch | null; } -export const usX402Fetch = ({ walletClient, jwt, method }: IUseX402FetchParams): IUseX402FetchReturnValues => { +export const useX402Fetch = ({ walletClient, jwt, method }: IUseX402FetchParams): IUseX402FetchReturnValues => { const [results, setResults] = useState([]); const [isExecuting, setIsExecuting] = useState(false); const [executionError, setExecutionError] = useState(null); From 017d683c7f9ee7f711ec538a0dd23e65d38ac4ad Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 18 Mar 2026 20:07:09 +0800 Subject: [PATCH 03/30] feat: update wagmi-react-app demo --- demo/wagmi-react-app/src/components/Main.tsx | 7 +- demo/wagmi-react-app/src/components/X402.tsx | 215 +++++++++++++++++++ 2 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 demo/wagmi-react-app/src/components/X402.tsx diff --git a/demo/wagmi-react-app/src/components/Main.tsx b/demo/wagmi-react-app/src/components/Main.tsx index ec466f39a..1263470d3 100644 --- a/demo/wagmi-react-app/src/components/Main.tsx +++ b/demo/wagmi-react-app/src/components/Main.tsx @@ -17,11 +17,12 @@ import { useSolanaWallet } from "@web3auth/modal/react/solana"; import { useMemo, useState } from "react"; import { parseEther } from "viem"; import { - useAccount, useBalance, useCallsStatus, useCapabilities, useChainId, + useChains, + useConnection, useSendCalls, useShowCallsStatus, useSignMessage, @@ -30,6 +31,7 @@ import { } from "wagmi"; import styles from "../styles/Home.module.css"; +import X402 from "./X402"; const Main = () => { const { provider, isConnected, web3Auth, status } = useWeb3Auth(); @@ -332,6 +334,9 @@ const Main = () => { ))} + {/* X402 Payment Protocol */} + + {/* Disconnect */}

Logout

diff --git a/demo/wagmi-react-app/src/components/X402.tsx b/demo/wagmi-react-app/src/components/X402.tsx new file mode 100644 index 000000000..07a331be7 --- /dev/null +++ b/demo/wagmi-react-app/src/components/X402.tsx @@ -0,0 +1,215 @@ +import type { Method, MethodExecutionResult } from "@web3auth/modal"; +import { useX402Auth, useX402Fetch } from "@web3auth/modal/react"; +import { useState } from "react"; +import { WalletClient } from "viem"; +import { useWalletClient } from "wagmi"; + +import styles from "../styles/Home.module.css"; + +const DEMO_METHODS: Method[] = [ + { + id: "eth-block-number", + name: "eth_blockNumber", + description: "Get the latest block number on Base Sepolia", + protocol: "JSON-RPC", + network: "base-sepolia", + networkDisplay: "Base Sepolia", + rpcMethod: "eth_blockNumber", + rpcParams: [], + }, + { + id: "eth-gas-price", + name: "eth_gasPrice", + description: "Get the current gas price on Base Sepolia", + protocol: "JSON-RPC", + network: "base-sepolia", + networkDisplay: "Base Sepolia", + rpcMethod: "eth_gasPrice", + rpcParams: [], + }, + { + id: "eth-chain-id", + name: "eth_chainId", + description: "Get the chain ID of Base Sepolia", + protocol: "JSON-RPC", + network: "base-sepolia", + networkDisplay: "Base Sepolia", + rpcMethod: "eth_chainId", + rpcParams: [], + }, +]; + +interface X402MethodRunnerProps { + walletClient: WalletClient; + jwt: string; + method: Method; +} + +const X402MethodRunner = ({ walletClient, jwt, method }: X402MethodRunnerProps) => { + const { execute, results, isExecuting, executionError, clearResults } = useX402Fetch({ walletClient, jwt, method }); + + return ( +
+
+ + {results.length > 0 && ( + + )} +
+ + {executionError &&

Error: {executionError}

} + + {results.length > 0 && ( +
+

Results ({results.length})

+ {results.map((result: MethodExecutionResult) => ( +
+

+ {new Date(result.requestedAt).toLocaleTimeString()} — Status: {result.status} ({result.ok ? "OK" : "Failed"}) +

+