From b4ed780e6c029f76c7d31d4f19e5a44184f8f7b2 Mon Sep 17 00:00:00 2001 From: Scientivan Date: Fri, 18 Sep 2026 13:29:33 +0700 Subject: [PATCH 1/2] feat: add KeeperHub action provider --- .../.changeset/keeperhub-action-provider.md | 5 + .../agentkit/src/action-providers/index.ts | 1 + .../src/action-providers/keeperhub/README.md | 50 ++++ .../action-providers/keeperhub/constants.ts | 49 ++++ .../src/action-providers/keeperhub/index.ts | 2 + .../keeperhub/keeperHubActionProvider.test.ts | 222 +++++++++++++++++ .../keeperhub/keeperHubActionProvider.ts | 233 ++++++++++++++++++ .../keeperhub/keeperHubClient.test.ts | 57 +++++ .../keeperhub/keeperHubClient.ts | 204 +++++++++++++++ .../src/action-providers/keeperhub/schemas.ts | 51 ++++ 10 files changed, 874 insertions(+) create mode 100644 typescript/.changeset/keeperhub-action-provider.md create mode 100644 typescript/agentkit/src/action-providers/keeperhub/README.md create mode 100644 typescript/agentkit/src/action-providers/keeperhub/constants.ts create mode 100644 typescript/agentkit/src/action-providers/keeperhub/index.ts create mode 100644 typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.test.ts create mode 100644 typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.ts create mode 100644 typescript/agentkit/src/action-providers/keeperhub/keeperHubClient.test.ts create mode 100644 typescript/agentkit/src/action-providers/keeperhub/keeperHubClient.ts create mode 100644 typescript/agentkit/src/action-providers/keeperhub/schemas.ts diff --git a/typescript/.changeset/keeperhub-action-provider.md b/typescript/.changeset/keeperhub-action-provider.md new file mode 100644 index 000000000..bd103943a --- /dev/null +++ b/typescript/.changeset/keeperhub-action-provider.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added a KeeperHub action provider with `transfer` (simulate, then execute once under an idempotency key derived from the work) and `get_execution_status` (outcome with receipts re-read from chain) diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..2b68edfb7 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -18,6 +18,7 @@ export * from "./erc721"; export * from "./erc8004"; export * from "./farcaster"; export * from "./jupiter"; +export * from "./keeperhub"; export * from "./messari"; export * from "./pyth"; export * from "./moonwell"; diff --git a/typescript/agentkit/src/action-providers/keeperhub/README.md b/typescript/agentkit/src/action-providers/keeperhub/README.md new file mode 100644 index 000000000..1e731ac0e --- /dev/null +++ b/typescript/agentkit/src/action-providers/keeperhub/README.md @@ -0,0 +1,50 @@ +# KeeperHub Action Provider + +This directory contains the **KeeperHubActionProvider**, which routes transfers through [KeeperHub](https://keeperhub.com) instead of signing them with the local wallet, and lets the agent ask afterwards what actually happened. + +## Directory Structure + +``` +keeperhub/ +├── keeperHubActionProvider.ts # Provider with transfer and get_execution_status +├── keeperHubActionProvider.test.ts # Provider tests (fetch is faked, the real client runs) +├── keeperHubClient.ts # Thin REST client and idempotency key derivation +├── keeperHubClient.test.ts # Idempotency key tests +├── constants.ts # Base URL, supported chains +├── schemas.ts # Action schemas +├── index.ts # Main exports +└── README.md # This file +``` + +## Actions + +- `transfer`: simulates the transfer through KeeperHub, aborts before broadcast if the simulation predicts a revert, then executes once under an idempotency key derived from `taskId` and the fields that decide the onchain effect. Returns an `executionId`. +- `get_execution_status`: answers "did the money move?" for an `executionId`, with receipts re-read from chain: succeeded, reverted, or not yet known (which is not the same as failed). + +## Why + +When a transfer is broadcast but the confirmation is lost (for example, receipt polling fails), `erc20.transfer` returns an error string with no identifier to ask about again. The agent cannot tell "never sent" from "sent, answer lost", so a retry can pay twice (see #1483). With this provider a retry of the same `taskId` is replayed, not resent, and the outcome can always be asked for. + +Measured on Base Sepolia, 100 trials per arm with `eth_getTransactionReceipt` rejected on purpose: the outcome was determinable in 0 of 100 trials through `erc20.transfer` and in 99 of 100 through this provider; duplicate transfers 47 of 47 vs 0 of 100. Method and every transaction hash: https://github.com/scientivan/resi + +## Configuration + +```typescript +import { keeperHubActionProvider } from "@coinbase/agentkit"; + +const provider = keeperHubActionProvider({ + apiKey: process.env.KEEPERHUB_API_KEY, // organisation key, prefixed kh_ +}); +``` + +`KEEPERHUB_API_KEY` is read from the environment when `apiKey` is omitted. + +## Network Support + +Any EVM network KeeperHub supports, including Base, Base Sepolia, Ethereum, Sepolia, Arbitrum, Optimism and Polygon. See `constants.ts` for the full list. Unsupported chains are refused locally rather than sent to the server. + +## Notes + +- The simulation flag is set by code, never by model input, so it cannot be switched off. +- `taskId` is the durable handle. Recovery through the same `taskId` lasts 24 hours; after that the same key executes again. +- Only transfers are supported. Contract calls and protocol actions are not. diff --git a/typescript/agentkit/src/action-providers/keeperhub/constants.ts b/typescript/agentkit/src/action-providers/keeperhub/constants.ts new file mode 100644 index 000000000..21f330395 --- /dev/null +++ b/typescript/agentkit/src/action-providers/keeperhub/constants.ts @@ -0,0 +1,49 @@ +/** KeeperHub service base URL. */ +export const KEEPERHUB_BASE_URL = "https://app.keeperhub.com"; + +/** + * Chains KeeperHub supports as of 16 Sep 2026, read from + * `GET https://app.keeperhub.com/api/chains` (public endpoint, no auth). + * + * Gnosis (100) is deliberately absent: KeeperHub does not support it, and + * listing it would only make actions fail much later. + */ +export const SUPPORTED_CHAIN_IDS = [ + 1, + 10, + 56, + 137, + 4217, + 4663, + 8453, + 9745, + 16661, + 42161, + 43114, // mainnet + 97, + 9746, + 16602, + 42431, + 43113, + 46630, + 80002, + 84532, + 421614, + 11155111, + 11155420, // testnet +] as const; + +export const NETWORK_ID_TO_CHAIN_ID: Record = { + "ethereum-mainnet": 1, + "optimism-mainnet": 10, + "bnb-mainnet": 56, + "polygon-mainnet": 137, + "base-mainnet": 8453, + "arbitrum-mainnet": 42161, + "avalanche-mainnet": 43114, + "base-sepolia": 84532, + "ethereum-sepolia": 11155111, + "arbitrum-sepolia": 421614, + "optimism-sepolia": 11155420, + "polygon-amoy": 80002, +}; diff --git a/typescript/agentkit/src/action-providers/keeperhub/index.ts b/typescript/agentkit/src/action-providers/keeperhub/index.ts new file mode 100644 index 000000000..ed44210e4 --- /dev/null +++ b/typescript/agentkit/src/action-providers/keeperhub/index.ts @@ -0,0 +1,2 @@ +export * from "./keeperHubActionProvider"; +export * from "./schemas"; diff --git a/typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.test.ts b/typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.test.ts new file mode 100644 index 000000000..1bc1f2594 --- /dev/null +++ b/typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.test.ts @@ -0,0 +1,222 @@ +import { keeperHubActionProvider } from "./index"; + +/** + * Every test here fakes `fetch` rather than injecting a fake client, so the + * real client code is exercised too: headers, body shape, and response parsing. + */ + +type Reply = { status?: number; body: unknown }; +let queue: Reply[] = []; +let sent: Array<{ + url: string; + method: string; + body: Record; + headers: Record; +}> = []; + +const realFetch = globalThis.fetch; + +beforeEach(() => { + queue = []; + sent = []; + globalThis.fetch = (async (input: RequestInfo | URL, init: RequestInit = {}) => { + sent.push({ + url: String(input), + method: init.method ?? "GET", + body: init.body ? JSON.parse(String(init.body)) : undefined, + headers: (init.headers ?? {}) as Record, + }); + const next = queue.shift() ?? { body: {} }; + return new Response(JSON.stringify(next.body), { + status: next.status ?? 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = realFetch; + jest.restoreAllMocks(); +}); + +const walletOn = (chainId: string) => + ({ getNetwork: () => ({ protocolFamily: "evm", chainId, networkId: "base-sepolia" }) }) as never; + +const kh = () => keeperHubActionProvider({ apiKey: "kh_test" }); + +const args = { + recipientAddress: "0x33b1499a92793B3e634f2D8B9e83A7185f4eC44D", + amount: "1.5", + tokenAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + taskId: "invoice-1", +}; + +const OK_SIM = { body: { success: true, wouldRevert: false, gasEstimate: "45415" } }; + +describe("transfer: gate before the network", () => { + it("refuses chains KeeperHub does not support without calling the API", async () => { + // KeeperHub answers 503 for unsupported chains, which looks like a transient + // outage and invites endless retries. Refused here instead. + const out = await kh().transfer(walletOn("100"), args as never); + expect(out).toMatch(/does not support chainId 100/); + expect(sent).toHaveLength(0); + }); +}); + +describe("transfer: simulation gate", () => { + it("aborts when simulation predicts a revert, and does not execute", async () => { + queue.push({ + status: 400, + body: { + success: false, + wouldRevert: true, + failureKind: "revert", + revertReason: "Error(ERC20: transfer amount exceeds balance)", + }, + }); + const out = await kh().transfer(walletOn("84532"), args as never); + expect(out).toMatch(/Aborted before broadcast/); + expect(out).toMatch(/Simulation predicts a revert/); + expect(out).toMatch(/no gas was spent/); + expect(sent).toHaveLength(1); // simulation only, no execution + }); + + it("tells validation failures apart from revert predictions", async () => { + // The consequences differ: bad input must not be retried as-is, while a + // chain-state problem may be retried later. + queue.push({ + status: 400, + body: { + success: false, + wouldRevert: true, + failureKind: "validation", + revertReason: "bad address checksum", + }, + }); + const out = await kh().transfer(walletOn("84532"), args as never); + expect(out).toMatch(/Input rejected/); + expect(out).not.toMatch(/Simulation predicts a revert/); + }); + + it("always sends simulate:true, and the value never comes from input", async () => { + // Defence against the class KeeperHub tracks in #2004: a misspelled body key + // is accepted silently and the transaction is broadcast for real. + queue.push(OK_SIM, { status: 202, body: { executionId: "e1", status: "completed" } }); + await kh().transfer(walletOn("84532"), args as never); + expect(sent[0].body.simulate).toBe(true); + expect(sent[1].body.simulate).toBeUndefined(); + }); +}); + +describe("transfer: execution", () => { + it("sends a derived Idempotency-Key, not a random one", async () => { + queue.push(OK_SIM, { status: 202, body: { executionId: "e1", status: "completed" } }); + await kh().transfer(walletOn("84532"), args as never); + const key1 = sent[1].headers["Idempotency-Key"]; + + queue.push(OK_SIM, { status: 202, body: { executionId: "e1", status: "completed" } }); + await kh().transfer(walletOn("84532"), args as never); + const key2 = sent[3].headers["Idempotency-Key"]; + + expect(key1).toBeDefined(); + expect(key1).toBe(key2); // same work -> same key -> replayed + }); + + it("returns the executionId and says to ask about it instead of resending", async () => { + queue.push(OK_SIM, { + status: 202, + body: { executionId: "ks9u", status: "completed", transactionHash: "0xabc" }, + }); + const out = await kh().transfer(walletOn("84532"), args as never); + expect(out).toMatch(/executionId: ks9u/); + expect(out).toMatch(/get_execution_status/); + }); + + it("explains an idempotency conflict as a taskId misuse", async () => { + queue.push(OK_SIM, { + status: 409, + body: { code: "idempotency_conflict", originalExecutionId: "e0" }, + }); + const out = await kh().transfer(walletOn("84532"), args as never); + expect(out).toMatch(/already used for work with different details/); + }); + + it("never asks for or forwards raw calldata", async () => { + queue.push(OK_SIM, { status: 202, body: { executionId: "e1" } }); + await kh().transfer(walletOn("84532"), args as never); + for (const req of sent) { + expect(req.body).not.toHaveProperty("data"); + expect(req.body).not.toHaveProperty("calldata"); + expect(req.body).not.toHaveProperty("abi"); + } + }); +}); + +describe("get_execution_status: tells THREE states apart", () => { + it("SUCCEEDED when a verified receipt has status success", async () => { + queue.push({ + body: { + executionId: "e1", + status: "completed", + receipts: [ + { + hash: "0xabc", + chainId: 84532, + verified: true, + receiptStatus: "success", + blockNumber: 123, + gasUsed: "67338", + }, + ], + }, + }); + const out = await kh().getExecutionStatus(walletOn("84532"), { executionId: "e1" } as never); + expect(out).toMatch(/transaction SUCCEEDED/); + expect(out).toMatch(/Do not resend/); + }); + + it("REVERTED when a verified receipt is not success", async () => { + queue.push({ + body: { + executionId: "e1", + status: "completed", + receipts: [{ hash: "0xabc", chainId: 84532, verified: true, receiptStatus: "reverted" }], + }, + }); + const out = await kh().getExecutionStatus(walletOn("84532"), { executionId: "e1" } as never); + expect(out).toMatch(/REVERTED/); + expect(out).toMatch(/No funds moved/); + }); + + it("NOT YET KNOWN when no receipt is verified, and forbids resending", async () => { + // This is the whole argument: "not yet known" is not "failed". Treating them + // as the same is the most common way an agent pays twice. + queue.push({ body: { executionId: "e1", status: "pending", receipts: [] } }); + const out = await kh().getExecutionStatus(walletOn("84532"), { executionId: "e1" } as never); + expect(out).toMatch(/NOT YET KNOWN, which is not the same as failed/); + expect(out).toMatch(/Do not resend/); + }); + + it("ignores receipts that are not verified", async () => { + // `transactionHash` is self-reported by the write path; only receipts + // re-read from chain count as evidence. + queue.push({ + body: { + executionId: "e1", + status: "completed", + receipts: [{ hash: "0xabc", chainId: 84532, verified: false, receiptStatus: "success" }], + }, + }); + const out = await kh().getExecutionStatus(walletOn("84532"), { executionId: "e1" } as never); + expect(out).toMatch(/NOT YET KNOWN/); + }); +}); + +describe("supportsNetwork", () => { + it("accepts supported EVM chains and rejects the rest", () => { + const p = kh(); + expect(p.supportsNetwork({ protocolFamily: "evm", chainId: "84532" } as never)).toBe(true); + expect(p.supportsNetwork({ protocolFamily: "evm", chainId: "100" } as never)).toBe(false); + expect(p.supportsNetwork({ protocolFamily: "svm", chainId: "101" } as never)).toBe(false); + }); +}); diff --git a/typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.ts b/typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.ts new file mode 100644 index 000000000..e10d020f9 --- /dev/null +++ b/typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.ts @@ -0,0 +1,233 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { Network } from "../../network"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { KeeperHubClient, deriveIdempotencyKey } from "./keeperHubClient"; +import { TransferSchema, GetExecutionStatusSchema } from "./schemas"; +import { NETWORK_ID_TO_CHAIN_ID, SUPPORTED_CHAIN_IDS } from "./constants"; + +export interface KeeperHubActionProviderConfig { + /** Organisation API key, prefixed `kh_`. Default: process.env.KEEPERHUB_API_KEY */ + apiKey?: string; + baseUrl?: string; + timeoutMs?: number; +} + +/** + * Runs onchain actions through KeeperHub instead of signing them locally. + * + * Three things set it apart from AgentKit's ordinary wallet path: + * + * 1. SIMULATION AS A GATE. Every write is simulated first and aborted on + * `wouldRevert`. The simulate flag is written by code, not by the model, and + * cannot be switched off through input. + * + * 2. IDEMPOTENCY DERIVED FROM THE WORK. The key is computed from `taskId` plus + * the fields that determine the onchain effect. Retrying the same work yields + * the same key, so it is replayed instead of executed twice. + * + * 3. AN OUTCOME YOU CAN ASK FOR AGAIN. Every execution returns an `executionId`. + * After any failure, `get_execution_status` answers "what actually happened" + * with receipts re-read from chain. The local wallet path has no equivalent: + * a failed action returns only error text, with no identifier to ask about again. + */ +export class KeeperHubActionProvider extends ActionProvider { + readonly #client: KeeperHubClient; + + /** + * Creates the provider. + * + * @param config - API key (default: KEEPERHUB_API_KEY), optional base URL and timeout + */ + constructor(config: KeeperHubActionProviderConfig = {}) { + super("keeperhub", []); + const apiKey = config.apiKey ?? process.env.KEEPERHUB_API_KEY ?? ""; + this.#client = new KeeperHubClient({ + apiKey, + baseUrl: config.baseUrl, + timeoutMs: config.timeoutMs, + }); + } + + /** + * Simulates, then executes a transfer once through KeeperHub. + * + * @param walletProvider - Used only to read the current network + * @param args - Recipient, amount, optional token and the taskId of the work + * @returns A message with the executionId, or why nothing was sent + */ + @CreateAction({ + name: "transfer", + description: ` +Send tokens through KeeperHub rather than through the local wallet. + +The order is always: simulate, abort if it would revert, then execute once with +an idempotency key derived from taskId. + +Inputs: +- recipientAddress: the 0x… recipient address +- amount: whole units, for example "1.5", NOT wei +- tokenAddress: the ERC-20 contract address; omit for the native token +- taskId: a stable identifier for this work, for example an invoice number + +Important: +- Use the SAME taskId when retrying the same work. That is what prevents paying + twice. +- Use a DIFFERENT taskId for payments that really are different. + +taskId is the DURABLE handle, not executionId. executionId arrives inside the +response, and the response is exactly what gets lost when something goes wrong. +If the response is lost, call this action again with the same taskId: the same +key is derived and the same executionId comes back without executing again. + +Keep both if you can. If you can keep only one, keep taskId. + +Limit: recovery through taskId lasts 24 hours. After that the same key will +EXECUTE AGAIN, not replay. For work that can outlive a day, put a time bucket in +the taskId. +`, + schema: TransferSchema, + }) + async transfer( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const network = walletProvider.getNetwork(); + const chainId = network.chainId + ? Number(network.chainId) + : NETWORK_ID_TO_CHAIN_ID[network.networkId ?? ""]; + + if (!chainId) { + return `Error: cannot determine chainId from network ${JSON.stringify(network)}.`; + } + if (!(SUPPORTED_CHAIN_IDS as readonly number[]).includes(chainId)) { + // Refused here, not at the server. KeeperHub answers 503 for unsupported + // chains, which looks like a transient outage and invites endless retries. + return `Error: KeeperHub does not support chainId ${chainId}. Supported: ${SUPPORTED_CHAIN_IDS.join(", ")}.`; + } + + const body: Record = { + chainId, + recipientAddress: args.recipientAddress, + amount: args.amount, + ...(args.tokenAddress ? { tokenAddress: args.tokenAddress } : {}), + }; + + const sim = await this.#client.simulateTransfer(body); + if (!sim.success || sim.wouldRevert) { + const why = sim.revertReason ?? sim.error ?? "no reason given"; + // Kept distinct because the consequences differ: bad input must not be + // retried as-is, while a chain-state problem may be retried later. + const kind = + sim.failureKind === "validation" ? "Input rejected" : "Simulation predicts a revert"; + return `Aborted before broadcast. ${kind}: ${why}. No transaction was sent and no gas was spent.`; + } + + const idempotencyKey = deriveIdempotencyKey({ + taskId: args.taskId, + chainId, + recipientAddress: args.recipientAddress, + amount: args.amount, + tokenAddress: args.tokenAddress, + }); + + const exec = await this.#client.executeTransfer(body, idempotencyKey); + + if (exec.code === "idempotency_conflict") { + return `Error: taskId "${args.taskId}" was already used for work with different details. Use a new taskId for different work, and the same taskId only to retry the same work.`; + } + if (exec.httpStatus >= 400 || !exec.executionId) { + return `Error during execution (HTTP ${exec.httpStatus}): ${exec.error ?? "unknown"}. If an executionId is available, ask for its status before resending.`; + } + + return [ + `Transfer handed to KeeperHub.`, + `executionId: ${exec.executionId}`, + `status: ${exec.status ?? "unknown"}`, + exec.transactionHash ? `transactionHash: ${exec.transactionHash}` : null, + exec.transactionLink ? `explorer: ${exec.transactionLink}` : null, + `taskId: ${args.taskId}`, + `Keep both. If anything fails after this point, call get_execution_status with the executionId, or call transfer again with the same taskId to get the executionId back. Do not use a new taskId.`, + ] + .filter(Boolean) + .join("\n"); + } + + /** + * Reports the outcome of an execution with receipts re-read from chain. + * + * @param _walletProvider - Unused + * @param args - The executionId to ask about + * @returns A message with the verified outcome, or that it is not yet known + */ + @CreateAction({ + name: "get_execution_status", + description: ` +Ask what ACTUALLY happened to an execution, by executionId. + +Use this whenever a transfer ends in an error, a timeout, or a lost response. +The receipts returned are re-read from chain, not self-reported, so they tell +apart three different states: + +- succeeded : the transaction was mined and succeeded +- failed : the transaction was mined but reverted +- pending : not final yet; do NOT resend, the transaction may still land + +Resending without asking this first is the most common way an agent pays twice. + +If the executionId is lost, do not give up: call transfer again with the same +taskId to get it back (valid for 24 hours). +`, + schema: GetExecutionStatusSchema, + }) + async getExecutionStatus( + _walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const st = await this.#client.getStatus(args.executionId); + if (st.httpStatus >= 400) { + return `Error: cannot read status for ${args.executionId} (HTTP ${st.httpStatus}).`; + } + + const verified = (st.receipts ?? []).filter(r => r.verified === true); + if (verified.length === 0) { + return [ + `executionId: ${st.executionId}`, + `status: ${st.status}`, + `No receipt has been verified onchain yet. The outcome is NOT YET KNOWN, which is not the same as failed.`, + `Do not resend. Ask again shortly.`, + ].join("\n"); + } + + const lines = verified.map( + r => + ` hash ${r.hash} | ${r.receiptStatus ?? "?"} | block ${r.blockNumber ?? "?"} | gasUsed ${r.gasUsed ?? "?"} | verified ${r.verifiedAt ?? "?"}`, + ); + const anyFailed = verified.some(r => r.receiptStatus && r.receiptStatus !== "success"); + + return [ + `executionId: ${st.executionId}`, + `status: ${st.status}`, + `Verified onchain result (${verified.length} receipt${verified.length === 1 ? "" : "s"}):`, + ...lines, + anyFailed + ? `Conclusion: the transaction was mined but REVERTED. No funds moved.` + : `Conclusion: the transaction SUCCEEDED. Do not resend this work.`, + st.transactionLink ? `explorer: ${st.transactionLink}` : null, + ] + .filter(Boolean) + .join("\n"); + } + + supportsNetwork = (network: Network): boolean => { + if (network.protocolFamily !== "evm") return false; + const chainId = network.chainId + ? Number(network.chainId) + : NETWORK_ID_TO_CHAIN_ID[network.networkId ?? ""]; + return Boolean(chainId) && (SUPPORTED_CHAIN_IDS as readonly number[]).includes(chainId); + }; +} + +export const keeperHubActionProvider = (config: KeeperHubActionProviderConfig = {}) => + new KeeperHubActionProvider(config); diff --git a/typescript/agentkit/src/action-providers/keeperhub/keeperHubClient.test.ts b/typescript/agentkit/src/action-providers/keeperhub/keeperHubClient.test.ts new file mode 100644 index 000000000..34b411504 --- /dev/null +++ b/typescript/agentkit/src/action-providers/keeperhub/keeperHubClient.test.ts @@ -0,0 +1,57 @@ +import { deriveIdempotencyKey } from "./keeperHubClient"; + +const base = { + taskId: "invoice-2026-0042", + chainId: 84532, + recipientAddress: "0x33b1499a92793B3e634f2D8B9e83A7185f4eC44D", + amount: "1.5", + tokenAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", +}; + +describe("deriveIdempotencyKey", () => { + it("yields the same key for the same work", () => { + // This is the property that prevents paying twice: a second attempt at the + // same work must produce exactly the same key. + expect(deriveIdempotencyKey(base)).toBe(deriveIdempotencyKey({ ...base })); + }); + + it("yields different keys for different taskIds", () => { + // Two payments that really are different must never merge. + expect(deriveIdempotencyKey(base)).not.toBe( + deriveIdempotencyKey({ ...base, taskId: "invoice-2026-0043" }), + ); + }); + + it.each([ + ["amount", { amount: "1.6" }], + ["recipientAddress", { recipientAddress: "0x0000000000000000000000000000000000000001" }], + ["chainId", { chainId: 8453 }], + ["tokenAddress", { tokenAddress: "0x0000000000000000000000000000000000000002" }], + ])("changes when %s changes", (_field, patch) => { + // Fields that determine the onchain effect are part of the key, so a taskId + // reused with different details is caught as a conflict instead of being + // silently replayed. + expect(deriveIdempotencyKey(base)).not.toBe(deriveIdempotencyKey({ ...base, ...patch })); + }); + + it("is a valid UUID v4", () => { + // Some layers below reject keys that are not UUID v4. + expect(deriveIdempotencyKey(base)).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + it("treats a missing tokenAddress as its own value", () => { + // A native transfer and an ERC-20 transfer of the same amount are different + // work. + const { tokenAddress: _tokenAddress, ...native } = base; + expect(deriveIdempotencyKey(native)).not.toBe(deriveIdempotencyKey(base)); + }); + + it("is stable across processes (a fixed value, not random)", () => { + // The key must be rebuildable after the process dies and restarts. This + // fixed value pins the algorithm; if it changes, old retries will no longer + // match old executions. + expect(deriveIdempotencyKey(base)).toBe("4b92288e-4690-4ad4-970e-8a8787520266"); + }); +}); diff --git a/typescript/agentkit/src/action-providers/keeperhub/keeperHubClient.ts b/typescript/agentkit/src/action-providers/keeperhub/keeperHubClient.ts new file mode 100644 index 000000000..231ceb622 --- /dev/null +++ b/typescript/agentkit/src/action-providers/keeperhub/keeperHubClient.ts @@ -0,0 +1,204 @@ +import { createHash } from "node:crypto"; +import { KEEPERHUB_BASE_URL } from "./constants"; + +export interface KeeperHubClientConfig { + apiKey: string; + baseUrl?: string; + /** Per-request HTTP timeout, in milliseconds. */ + timeoutMs?: number; +} + +export interface SimulationResult { + success: boolean; + wouldRevert: boolean; + failureKind?: string; + revertReason?: string; + gasEstimate?: string; + error?: string; +} + +export interface ExecutionResult { + executionId?: string; + status?: string; + transactionHash?: string; + transactionLink?: string; + error?: string; + code?: string; + httpStatus: number; +} + +export interface Receipt { + hash: string; + chainId: number; + gasUsed?: string; + verified?: boolean; + verifiedAt?: string; + blockNumber?: number; + receiptStatus?: string; +} + +export interface StatusResult { + executionId: string; + status: string; + transactionHash?: string; + transactionLink?: string; + receipts?: Receipt[]; + error?: string | null; + httpStatus: number; +} + +/** + * Deterministic idempotency key. + * + * The scheme is exactly the one KeeperHub documents in its "Choosing a stable + * key" guide: `taskId|chainId|recipientAddress|amount|tokenAddress`, joined by + * U+007C with no surrounding spaces. + * + * Why derived rather than random: a UUID generated per attempt does not survive + * a retry. The second attempt gets a different UUID, is treated as new work, + * and executes again. The key must identify the WORK, not the ATTEMPT. + * + * The hash is shaped into a UUID v4 because some layers below require that + * format. + * + * @param parts - The fields that decide the onchain effect of the work + * @param parts.taskId - Stable identifier of the work, e.g. an invoice number + * @param parts.chainId - Chain the transfer runs on + * @param parts.recipientAddress - Recipient address + * @param parts.amount - Amount in whole units + * @param parts.tokenAddress - ERC-20 address, or undefined for the native token + * @returns The idempotency key, shaped as a UUID v4 + */ +export function deriveIdempotencyKey(parts: { + taskId: string; + chainId: number; + recipientAddress: string; + amount: string; + tokenAddress?: string; +}): string { + const canonical = [ + parts.taskId, + parts.chainId, + parts.recipientAddress, + parts.amount, + parts.tokenAddress ?? "", + ].join("|"); + const b = Buffer.from(createHash("sha256").update(canonical).digest().subarray(0, 16)); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + const h = b.toString("hex"); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; +} + +/** + * Minimal REST client for KeeperHub direct execution. + */ +export class KeeperHubClient { + readonly #apiKey: string; + readonly #baseUrl: string; + readonly #timeoutMs: number; + + /** + * Creates a client. + * + * @param config - API key, optional base URL and per-request timeout + */ + constructor(config: KeeperHubClientConfig) { + if (!config.apiKey) throw new Error("KEEPERHUB_API_KEY is not set"); + this.#apiKey = config.apiKey; + this.#baseUrl = config.baseUrl ?? KEEPERHUB_BASE_URL; + this.#timeoutMs = config.timeoutMs ?? 60_000; + } + + /** + * Dry run. The `simulate` key is written here, once, by code. It never comes + * from model input, so the misspelling class KeeperHub tracks in #2004 cannot + * happen through this path. + * + * @param body - Transfer body without the simulate flag + * @returns The simulation outcome + */ + async simulateTransfer(body: Record): Promise { + const { json } = await this.#request("/api/execute/transfer", { + method: "POST", + body: JSON.stringify({ ...body, simulate: true }), + }); + return { + success: json.success === true, + wouldRevert: json.wouldRevert === true, + failureKind: json.failureKind as string | undefined, + revertReason: json.revertReason as string | undefined, + gasEstimate: json.gasEstimate as string | undefined, + error: json.error as string | undefined, + }; + } + + /** + * Executes a transfer once under the given idempotency key. + * + * @param body - Transfer body + * @param idempotencyKey - Key derived from the work, see deriveIdempotencyKey + * @returns The execution handle and HTTP status + */ + async executeTransfer( + body: Record, + idempotencyKey: string, + ): Promise { + const { httpStatus, json } = await this.#request("/api/execute/transfer", { + method: "POST", + body: JSON.stringify(body), + idempotencyKey, + }); + return { + executionId: json.executionId as string | undefined, + status: json.status as string | undefined, + transactionHash: json.transactionHash as string | undefined, + transactionLink: json.transactionLink as string | undefined, + error: json.error as string | undefined, + code: json.code as string | undefined, + httpStatus, + }; + } + + /** + * Final outcome of an execution, with receipts re-read from chain. + * + * @param executionId - The executionId returned by executeTransfer + * @returns Status and receipts + */ + async getStatus(executionId: string): Promise { + const { httpStatus, json } = await this.#request(`/api/execute/${executionId}/status`); + return { + executionId: (json.executionId as string) ?? executionId, + status: (json.status as string) ?? "unknown", + transactionHash: json.transactionHash as string | undefined, + transactionLink: json.transactionLink as string | undefined, + receipts: json.receipts as Receipt[] | undefined, + error: (json.error as string | null) ?? null, + httpStatus, + }; + } + + /** + * Sends an authenticated request and parses the JSON body. + * + * @param path - API path, starting with / + * @param init - Fetch options, plus an optional idempotency key + * @returns The HTTP status and parsed JSON body + */ + async #request(path: string, init: RequestInit & { idempotencyKey?: string } = {}) { + const { idempotencyKey, ...rest } = init; + const res = await fetch(`${this.#baseUrl}${path}`, { + ...rest, + signal: AbortSignal.timeout(this.#timeoutMs), + headers: { + authorization: `Bearer ${this.#apiKey}`, + ...(rest.body ? { "content-type": "application/json" } : {}), + ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), + ...(rest.headers as Record | undefined), + }, + }); + const json = (await res.json().catch(() => ({}))) as Record; + return { httpStatus: res.status, json }; + } +} diff --git a/typescript/agentkit/src/action-providers/keeperhub/schemas.ts b/typescript/agentkit/src/action-providers/keeperhub/schemas.ts new file mode 100644 index 000000000..f59dead60 --- /dev/null +++ b/typescript/agentkit/src/action-providers/keeperhub/schemas.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +/** + * Transfer input schema. + * + * Note what is NOT here: `calldata`, `data`, `abi`, and `simulate`. + * + * - Raw calldata is never requested from the model. The model picks the + * recipient, amount and token; code builds the transaction. Same idea as an + * address book: models cannot be relied on to produce correct hex. + * - `simulate` is not a parameter. This provider ALWAYS simulates first and + * refuses on `wouldRevert`. Making it optional would let the model turn it + * off, and would open the misspelling class KeeperHub tracks in issue #2004 + * (a misspelled body key is accepted silently and the transaction is + * broadcast for real). + */ +export const TransferSchema = z + .object({ + recipientAddress: z + .string() + .describe("Recipient address. 0x…, either all lowercase or a correct EIP-55 checksum."), + amount: z.string().describe('Amount in whole units, not wei. Example: "1.5" for 1.5 USDC.'), + tokenAddress: z + .string() + .optional() + .describe("ERC-20 contract address. Omit to send the chain's native token."), + taskId: z + .string() + .describe( + "A stable identifier for this piece of WORK, not for this attempt. " + + "Examples: an invoice number, a payroll period, a job id. It must be " + + "the same when the same work is retried, and different for different " + + "work. The idempotency key is derived from it.", + ), + }) + .strict() + .describe("Send tokens through KeeperHub: simulate first, then execute idempotently."); + +/** + * Schema for asking about the outcome of an execution. + * + * This is the action AgentKit has no equivalent for. After an action fails, + * AgentKit returns only error text; there is no identifier to ask about again. + * `executionId` makes "what actually happened?" answerable at any time. + */ +export const GetExecutionStatusSchema = z + .object({ + executionId: z.string().describe("The executionId returned by an earlier transfer action."), + }) + .strict() + .describe("Ask for the final outcome of an execution, with receipts re-read from chain."); From 733b7d1ca45a4374a2554dcf0ee06b5cf208cd7a Mon Sep 17 00:00:00 2001 From: Scientivan Date: Fri, 18 Sep 2026 13:57:15 +0700 Subject: [PATCH 2/2] feat(keeperhub): retry status reads and 409 in-progress, enforce request deadline --- .../src/action-providers/keeperhub/README.md | 2 + .../keeperhub/keeperHubActionProvider.test.ts | 100 +++++++++++++++- .../keeperhub/keeperHubActionProvider.ts | 40 ++++++- .../keeperhub/keeperHubClient.ts | 107 ++++++++++++++---- 4 files changed, 224 insertions(+), 25 deletions(-) diff --git a/typescript/agentkit/src/action-providers/keeperhub/README.md b/typescript/agentkit/src/action-providers/keeperhub/README.md index 1e731ac0e..ce1274430 100644 --- a/typescript/agentkit/src/action-providers/keeperhub/README.md +++ b/typescript/agentkit/src/action-providers/keeperhub/README.md @@ -47,4 +47,6 @@ Any EVM network KeeperHub supports, including Base, Base Sepolia, Ethereum, Sepo - The simulation flag is set by code, never by model input, so it cannot be switched off. - `taskId` is the durable handle. Recovery through the same `taskId` lasts 24 hours; after that the same key executes again. +- `get_execution_status` retries on timeout, network error, 429 and 5xx, behind a deadline that holds even if fetch ignores its abort signal. If no answer is obtained it reports "not yet known", never "failed". +- A transfer answered with 409 "already being processed" is retried with the same idempotency key, so it cannot execute twice. - Only transfers are supported. Contract calls and protocol actions are not. diff --git a/typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.test.ts b/typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.test.ts index 1bc1f2594..712b75675 100644 --- a/typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.test.ts @@ -40,7 +40,11 @@ afterEach(() => { }); const walletOn = (chainId: string) => - ({ getNetwork: () => ({ protocolFamily: "evm", chainId, networkId: "base-sepolia" }) }) as never; + ({ + getName: () => "fake_wallet", + getAddress: () => "0x0000000000000000000000000000000000000001", + getNetwork: () => ({ protocolFamily: "evm", chainId, networkId: "base-sepolia" }), + }) as never; const kh = () => keeperHubActionProvider({ apiKey: "kh_test" }); @@ -212,6 +216,100 @@ describe("get_execution_status: tells THREE states apart", () => { }); }); +describe("get_execution_status: survives a flaky or hanging status endpoint", () => { + // The one unresolved trial in the 0.10.4 campaign: the transfer landed, but + // the single status call hung for 925 s and was never retried. + const fast = () => keeperHubActionProvider({ apiKey: "kh_test", timeoutMs: 50, retryDelayMs: 1 }); + const verified = { + body: { + executionId: "e1", + status: "completed", + receipts: [{ hash: "0xabc", chainId: 84532, verified: true, receiptStatus: "success" }], + }, + }; + + it("retries after 5xx and 429, then reports the verified outcome", async () => { + queue.push({ status: 503, body: {} }, { status: 429, body: {} }, verified); + const out = await fast().getExecutionStatus(walletOn("84532"), { executionId: "e1" } as never); + expect(out).toMatch(/transaction SUCCEEDED/); + expect(sent).toHaveLength(3); + }); + + it("retries the SAME key after 409 in-progress, and returns the executionId", async () => { + // The unresolved trial in the 0.9.1 run: we gave up on this 409 and never + // got the executionId, although the transfer landed. + queue.push( + OK_SIM, + { + status: 409, + body: { + error: + "A request with this Idempotency-Key is already being processed. Retry the same key shortly; do not rotate it.", + }, + }, + { body: { executionId: "e9", status: "completed" } }, + ); + const out = await fast().transfer(walletOn("84532"), args as never); + expect(out).toMatch(/executionId: e9/); + const keys = sent.slice(1).map(r => r.headers["Idempotency-Key"]); + expect(keys).toHaveLength(2); + expect(keys[0]).toBe(keys[1]); + }); + + it("does not retry a 404: the answer is definite", async () => { + queue.push({ status: 404, body: { error: "not found" } }); + const out = await fast().getExecutionStatus(walletOn("84532"), { executionId: "e1" } as never); + expect(out).toMatch(/HTTP 404/); + expect(sent).toHaveLength(1); + }); + + it("enforces the deadline even when fetch ignores the abort signal", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + if (calls <= 2) return new Promise(() => {}); // never settles, ignores the signal + return new Response(JSON.stringify(verified.body), { status: 200 }); + }) as typeof fetch; + const started = Date.now(); + const out = await fast().getExecutionStatus(walletOn("84532"), { executionId: "e1" } as never); + expect(out).toMatch(/transaction SUCCEEDED/); + expect(calls).toBe(3); + expect(Date.now() - started).toBeLessThan(2_000); + }); + + it("says NOT YET KNOWN, not failed, when every attempt times out", async () => { + globalThis.fetch = (async () => new Promise(() => {})) as typeof fetch; + const out = await fast().getExecutionStatus(walletOn("84532"), { executionId: "e1" } as never); + expect(out).toMatch(/did not answer after 4 attempts/); + expect(out).toMatch(/NOT YET KNOWN, which is not the same as failed/); + expect(out).toMatch(/Do not resend/); + }); +}); + +describe("through AgentKit's action list, the way an agent calls it", () => { + // Found by the LLM agent demo: with a type-only import of EvmWalletProvider, + // the decorator metadata was `Function`, AgentKit did not pass the wallet, and + // every action failed with "walletProvider.getNetwork is not a function". + // The other tests call the methods directly, so they could not see it. + it("passes the wallet provider to the action", async () => { + const actions = kh().getActions(walletOn("100")); + const transfer = actions.find(a => a.name.endsWith("_transfer"))!; + const out = await transfer.invoke(args as never); + expect(out).toMatch(/does not support chainId 100/); + }); + + it("exposes exactly transfer and get_execution_status", () => { + const names = kh() + .getActions(walletOn("84532")) + .map(a => a.name) + .sort(); + expect(names).toEqual([ + "KeeperHubActionProvider_get_execution_status", + "KeeperHubActionProvider_transfer", + ]); + }); +}); + describe("supportsNetwork", () => { it("accepts supported EVM chains and rejects the rest", () => { const p = kh(); diff --git a/typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.ts b/typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.ts index e10d020f9..2ebc43b79 100644 --- a/typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.ts +++ b/typescript/agentkit/src/action-providers/keeperhub/keeperHubActionProvider.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +// EvmWalletProvider must be a VALUE import: @CreateAction reads the parameter +// type from decorator metadata to decide whether to pass the wallet. import { ActionProvider } from "../actionProvider"; import { CreateAction } from "../actionDecorator"; import { Network } from "../../network"; @@ -12,6 +14,12 @@ export interface KeeperHubActionProviderConfig { apiKey?: string; baseUrl?: string; timeoutMs?: number; + /** + * Extra attempts for get_execution_status, and for a transfer answered with + * 409 "already being processed" (same key, so it cannot execute twice). Default 3. + */ + statusRetries?: number; + retryDelayMs?: number; } /** @@ -34,11 +42,13 @@ export interface KeeperHubActionProviderConfig { */ export class KeeperHubActionProvider extends ActionProvider { readonly #client: KeeperHubClient; + readonly #inProgressRetries: number; + readonly #retryDelayMs: number; /** * Creates the provider. * - * @param config - API key (default: KEEPERHUB_API_KEY), optional base URL and timeout + * @param config - API key (default: KEEPERHUB_API_KEY), base URL, timeout and retry settings */ constructor(config: KeeperHubActionProviderConfig = {}) { super("keeperhub", []); @@ -47,7 +57,11 @@ export class KeeperHubActionProvider extends ActionProvider { apiKey, baseUrl: config.baseUrl, timeoutMs: config.timeoutMs, + statusRetries: config.statusRetries, + retryDelayMs: config.retryDelayMs, }); + this.#inProgressRetries = config.statusRetries ?? 3; + this.#retryDelayMs = config.retryDelayMs ?? 1_000; } /** @@ -132,7 +146,21 @@ the taskId. tokenAddress: args.tokenAddress, }); - const exec = await this.#client.executeTransfer(body, idempotencyKey); + // A 409 other than idempotency_conflict means the same key is still being + // processed ("Retry the same key shortly; do not rotate it"). This cost the + // unresolved trial in the 0.9.1 run: we gave up and had no executionId. + // Retrying the SAME key is safe: it cannot execute twice. + let exec = await this.#client.executeTransfer(body, idempotencyKey); + for ( + let i = 0; + i < this.#inProgressRetries && + exec.httpStatus === 409 && + exec.code !== "idempotency_conflict"; + i++ + ) { + await new Promise(r => setTimeout(r, this.#retryDelayMs * 2 ** i)); + exec = await this.#client.executeTransfer(body, idempotencyKey); + } if (exec.code === "idempotency_conflict") { return `Error: taskId "${args.taskId}" was already used for work with different details. Use a new taskId for different work, and the same taskId only to retry the same work.`; @@ -186,6 +214,14 @@ taskId to get it back (valid for 24 hours). args: z.infer, ): Promise { const st = await this.#client.getStatus(args.executionId); + if (st.httpStatus === 0) { + // No answer after every retry. That says nothing about the transfer. + return [ + `executionId: ${args.executionId}`, + `KeeperHub did not answer after ${st.attempts} attempts (${st.error}).`, + `The outcome is NOT YET KNOWN, which is not the same as failed. Do not resend. Ask again later.`, + ].join("\n"); + } if (st.httpStatus >= 400) { return `Error: cannot read status for ${args.executionId} (HTTP ${st.httpStatus}).`; } diff --git a/typescript/agentkit/src/action-providers/keeperhub/keeperHubClient.ts b/typescript/agentkit/src/action-providers/keeperhub/keeperHubClient.ts index 231ceb622..4c449648c 100644 --- a/typescript/agentkit/src/action-providers/keeperhub/keeperHubClient.ts +++ b/typescript/agentkit/src/action-providers/keeperhub/keeperHubClient.ts @@ -6,6 +6,14 @@ export interface KeeperHubClientConfig { baseUrl?: string; /** Per-request HTTP timeout, in milliseconds. */ timeoutMs?: number; + /** + * Extra attempts for the status read after a timeout, network error, 429 or + * 5xx. Default 3. Only the read is retried: it has no side effects. Transfers + * are not retried here; the caller retries them with the same taskId. + */ + statusRetries?: number; + /** Delay before the first status retry, doubled each time. Default 1000 ms. */ + retryDelayMs?: number; } export interface SimulationResult { @@ -44,7 +52,10 @@ export interface StatusResult { transactionLink?: string; receipts?: Receipt[]; error?: string | null; + /** 0 when no answer was obtained after every retry. */ httpStatus: number; + /** How many requests it took. */ + attempts?: number; } /** @@ -97,17 +108,21 @@ export class KeeperHubClient { readonly #apiKey: string; readonly #baseUrl: string; readonly #timeoutMs: number; + readonly #statusRetries: number; + readonly #retryDelayMs: number; /** * Creates a client. * - * @param config - API key, optional base URL and per-request timeout + * @param config - API key, optional base URL, timeout and retry settings */ constructor(config: KeeperHubClientConfig) { if (!config.apiKey) throw new Error("KEEPERHUB_API_KEY is not set"); this.#apiKey = config.apiKey; this.#baseUrl = config.baseUrl ?? KEEPERHUB_BASE_URL; this.#timeoutMs = config.timeoutMs ?? 60_000; + this.#statusRetries = config.statusRetries ?? 3; + this.#retryDelayMs = config.retryDelayMs ?? 1_000; } /** @@ -163,24 +178,55 @@ export class KeeperHubClient { /** * Final outcome of an execution, with receipts re-read from chain. * + * Retried on timeout, network error, 429 and 5xx, with exponential backoff. + * If every attempt fails, returns `httpStatus: 0` rather than throwing, so + * the caller can say "not known yet" instead of reporting a failure. + * * @param executionId - The executionId returned by executeTransfer - * @returns Status and receipts + * @returns Status and receipts, or httpStatus 0 if no answer was obtained */ async getStatus(executionId: string): Promise { - const { httpStatus, json } = await this.#request(`/api/execute/${executionId}/status`); + let lastError = "unknown"; + for (let attempt = 0; attempt <= this.#statusRetries; attempt++) { + if (attempt > 0) { + await new Promise(r => setTimeout(r, this.#retryDelayMs * 2 ** (attempt - 1))); + } + try { + const { httpStatus, json } = await this.#request(`/api/execute/${executionId}/status`); + if (httpStatus === 429 || httpStatus >= 500) { + lastError = `HTTP ${httpStatus}`; + continue; + } + return { + executionId: (json.executionId as string) ?? executionId, + status: (json.status as string) ?? "unknown", + transactionHash: json.transactionHash as string | undefined, + transactionLink: json.transactionLink as string | undefined, + receipts: json.receipts as Receipt[] | undefined, + error: (json.error as string | null) ?? null, + httpStatus, + attempts: attempt + 1, + }; + } catch (e) { + lastError = e instanceof Error ? `${e.name}: ${e.message}` : String(e); + } + } return { - executionId: (json.executionId as string) ?? executionId, - status: (json.status as string) ?? "unknown", - transactionHash: json.transactionHash as string | undefined, - transactionLink: json.transactionLink as string | undefined, - receipts: json.receipts as Receipt[] | undefined, - error: (json.error as string | null) ?? null, - httpStatus, + executionId, + status: "unknown", + error: lastError, + httpStatus: 0, + attempts: this.#statusRetries + 1, }; } /** - * Sends an authenticated request and parses the JSON body. + * One HTTP call with a deadline that covers the whole exchange, body included. + * + * `AbortSignal.timeout` alone was not enough: in the 0.10.4 campaign a status + * call configured for 60 s hung for 925 s. We have not found why, so the + * deadline is enforced twice: the abort signal, and a race against a timer + * that rejects regardless of what fetch does with the signal. * * @param path - API path, starting with / * @param init - Fetch options, plus an optional idempotency key @@ -188,17 +234,34 @@ export class KeeperHubClient { */ async #request(path: string, init: RequestInit & { idempotencyKey?: string } = {}) { const { idempotencyKey, ...rest } = init; - const res = await fetch(`${this.#baseUrl}${path}`, { - ...rest, - signal: AbortSignal.timeout(this.#timeoutMs), - headers: { - authorization: `Bearer ${this.#apiKey}`, - ...(rest.body ? { "content-type": "application/json" } : {}), - ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), - ...(rest.headers as Record | undefined), - }, + const controller = new AbortController(); + let timer: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject( + new DOMException(`KeeperHub request exceeded ${this.#timeoutMs} ms`, "TimeoutError"), + ); + }, this.#timeoutMs); }); - const json = (await res.json().catch(() => ({}))) as Record; - return { httpStatus: res.status, json }; + const exchange = (async () => { + const res = await fetch(`${this.#baseUrl}${path}`, { + ...rest, + signal: controller.signal, + headers: { + authorization: `Bearer ${this.#apiKey}`, + ...(rest.body ? { "content-type": "application/json" } : {}), + ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), + ...(rest.headers as Record | undefined), + }, + }); + const json = (await res.json().catch(() => ({}))) as Record; + return { httpStatus: res.status, json }; + })(); + try { + return await Promise.race([exchange, deadline]); + } finally { + clearTimeout(timer); + } } }