From dc86a665cd92b146c6021290a94ac5835005513d Mon Sep 17 00:00:00 2001 From: seekdaseek Date: Wed, 5 Aug 2026 20:28:06 +0300 Subject: [PATCH 1/5] Direct Execution: simulate and Idempotency-Key support, plus a README quickstart --- README.md | 57 ++++++++++++++ src/direct-executor.ts | 123 +++++++++++++++++++++++++---- src/types.ts | 31 ++++++++ test/direct-write-options.test.ts | 125 ++++++++++++++++++++++++++++++ 4 files changed, 320 insertions(+), 16 deletions(-) create mode 100644 test/direct-write-options.test.ts diff --git a/README.md b/README.md index 2e24d70..65aef7f 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,63 @@ Early development (`0.x`). The public surface and REST contract are still stabil npm install @keeperhub/sdk ``` +## Quickstart + +```ts +import { KeeperHubClient, DirectExecutor } from "@keeperhub/sdk"; + +const client = new KeeperHubClient({ apiKey: process.env.KEEPERHUB_API_KEY! }); +const direct = new DirectExecutor(client); + +const res = await direct.transfer({ + network: "sepolia", + recipientAddress: "0xabc...", + amount: "0.0001", +}); + +console.log(res.executionId, res.status); +``` + +The API key is an organization key beginning with `kh_`. Keep it in the environment, never in source. + +## Direct Execution + +Direct Execution runs a single on-chain operation without a workflow definition. It is the right surface for agent tools that compose calls at runtime. + +| Method | Endpoint | Returns | +| --- | --- | --- | +| `transfer` | `POST /execute/transfer` | `{ executionId, status }` | +| `callContract` | `POST /execute/contract-call` | `{ result }` for view functions, `{ executionId, status }` for writes | +| `checkAndExecute` | `POST /execute/check-and-execute` | condition verdict plus an optional execution | +| `getStatus` | `GET /execute/{id}/status` | status, `transactionHash`, `transactionLink` | + +A contract call auto-detects read versus write, so the return type is a union. Use `isReadResult` to narrow it. + +### Simulate before you broadcast + +```ts +const verdict = await direct.simulateTransfer({ + network: "sepolia", + recipientAddress: "0xabc...", + amount: "0.0001", +}); + +if (!verdict.success || verdict.wouldRevert) { + console.error(verdict.error); + return; +} +``` + +A simulation that reports the call would revert is an answer about the chain, not a transport failure. `simulateTransfer` and `simulateContractCall` return that answer as a value; anything else still throws `KeeperHubError`. + +### Make a retry safe + +```ts +await direct.transfer(input, { idempotencyKey: "payout-2026-08-05-0001" }); +``` + +Replaying the same key with the same body returns the original execution instead of sending a second transaction. Keys are scoped to your organization for 24 hours. Any client-side retry, backoff, or crash-recovery path that moves funds should set one. + ## License [Apache-2.0](./LICENSE) diff --git a/src/direct-executor.ts b/src/direct-executor.ts index 381660f..614b65d 100644 --- a/src/direct-executor.ts +++ b/src/direct-executor.ts @@ -1,3 +1,4 @@ +import { KeeperHubError } from "./client.js"; import type { KeeperHubClient } from "./client.js"; import type { DirectCheckAndExecuteInput, @@ -5,10 +6,43 @@ import type { DirectContractCallInput, DirectExecutionStatus, DirectReadResult, + DirectSimulationResult, DirectTransferInput, + DirectWriteOptions, DirectWriteResult, } from "./types.js"; +/** Build the RequestInit for a Direct Execution write. */ +function writeInit(input: object, opts?: DirectWriteOptions): RequestInit { + const body = + opts?.simulate === undefined + ? { ...input } + : { ...input, simulate: opts.simulate }; + const init: RequestInit = { method: "POST", body: JSON.stringify(body) }; + if (opts?.idempotencyKey) { + init.headers = { "Idempotency-Key": opts.idempotencyKey }; + } + return init; +} + +/** + * Pull a human-readable reason out of an error body. Direct Execution puts a + * sentence in `error` and structured context in `details`; the rest of the API + * puts a stable code in `error`. Check both. + */ +function readErrorText(obj: Record): string | undefined { + if (typeof obj.error === "string") return obj.error; + if (typeof obj.details === "string") return obj.details; + if (typeof obj.message === "string") return obj.message; + return undefined; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" + ? (value as Record) + : undefined; +} + /** * DirectExecutor wraps KeeperHub's Direct Execution API — synchronous * blockchain operations that don't require a workflow definition. @@ -22,12 +56,20 @@ import type { export class DirectExecutor { constructor(private readonly client: KeeperHubClient) {} - /** Transfer native tokens (omit tokenAddress) or ERC-20 tokens. */ - transfer(input: DirectTransferInput): Promise { - return this.client.rawRequest("/execute/transfer", { - method: "POST", - body: JSON.stringify(input), - }); + /** + * Transfer native tokens (omit tokenAddress) or ERC-20 tokens. + * + * Pass `{ idempotencyKey }` so a retry after a timeout replays the original + * execution instead of sending a second transaction. + */ + transfer( + input: DirectTransferInput, + opts?: DirectWriteOptions + ): Promise { + return this.client.rawRequest( + "/execute/transfer", + writeInit(input, opts) + ); } /** @@ -38,30 +80,79 @@ export class DirectExecutor { * Use `isReadResult` to discriminate at the call site. */ callContract( - input: DirectContractCallInput + input: DirectContractCallInput, + opts?: DirectWriteOptions ): Promise { return this.client.rawRequest( "/execute/contract-call", - { - method: "POST", - body: JSON.stringify(input), - } + writeInit(input, opts) ); } /** Read a value, evaluate a condition, conditionally execute a write. */ checkAndExecute( - input: DirectCheckAndExecuteInput + input: DirectCheckAndExecuteInput, + opts?: DirectWriteOptions ): Promise { return this.client.rawRequest( "/execute/check-and-execute", - { - method: "POST", - body: JSON.stringify(input), - } + writeInit(input, opts) ); } + /** Simulate a transfer without broadcasting it. */ + simulateTransfer( + input: DirectTransferInput + ): Promise { + return this.simulate("/execute/transfer", input); + } + + /** Simulate a contract call without broadcasting it. */ + simulateContractCall( + input: DirectContractCallInput + ): Promise { + return this.simulate("/execute/contract-call", input); + } + + /** + * Run a Direct Execution call with `simulate: true` and return the verdict. + * + * The API answers a would-revert simulation with HTTP 400 and + * `wouldRevert: true`. That is a real answer about the chain, not a + * transport failure, so it is returned rather than thrown. Anything else + * still throws. + */ + private async simulate( + path: string, + input: object + ): Promise { + try { + const res = await this.client.rawRequest( + path, + writeInit(input, { simulate: true }) + ); + const obj = asRecord(res) ?? {}; + return { + success: obj.success === true, + wouldRevert: obj.wouldRevert === true, + error: readErrorText(obj), + raw: res, + }; + } catch (err) { + const khErr = err instanceof KeeperHubError ? err : undefined; + const obj = asRecord(khErr?.body); + if (obj?.wouldRevert === true) { + return { + success: false, + wouldRevert: true, + error: readErrorText(obj), + raw: khErr?.body, + }; + } + throw err; + } + } + /** Status of a direct execution by its id. */ getStatus(executionId: string): Promise { return this.client.rawRequest( diff --git a/src/types.ts b/src/types.ts index fb9a118..334512d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -186,3 +186,34 @@ export interface DirectExecutionStatus { createdAt?: string; completedAt?: string; } + +/** Options for a Direct Execution write call. */ +export interface DirectWriteOptions { + /** + * Simulate the call instead of broadcasting it. The API requires a strict + * boolean; strings and numbers are rejected with 400. + */ + simulate?: boolean; + /** + * Value for the `Idempotency-Key` header. Replaying the same key with the + * same body returns the original execution instead of sending a second + * transaction. Keys are scoped per organization for 24 hours. + */ + idempotencyKey?: string; +} + +/** + * Verdict from a simulated Direct Execution call. + * + * A simulation reporting that the call would revert is a successful answer, + * not a transport failure, so the `simulate*` helpers return this shape + * instead of throwing. + */ +export interface DirectSimulationResult { + success: boolean; + wouldRevert: boolean; + /** Server-supplied reason when the call would not succeed. */ + error?: string; + /** Raw response body; the simulate payload carries more than this shape. */ + raw: unknown; +} diff --git a/test/direct-write-options.test.ts b/test/direct-write-options.test.ts new file mode 100644 index 0000000..145a72f --- /dev/null +++ b/test/direct-write-options.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; +import { KeeperHubClient, DirectExecutor } from "../src/index.js"; +import type { DirectTransferInput } from "../src/index.js"; + +const TRANSFER: DirectTransferInput = { + network: "sepolia", + recipientAddress: "0x0000000000000000000000000000000000000001", + amount: "0.0001", +}; + +interface Capture { + url: string; + headers: Record; + body: Record; +} + +function harness( + respond: () => { status: number; payload: unknown } +): { executor: DirectExecutor; calls: Capture[] } { + const calls: Capture[] = []; + const fetchImpl = (async (url: string, init: RequestInit = {}) => { + calls.push({ + url: String(url), + headers: (init.headers ?? {}) as Record, + body: JSON.parse(String(init.body ?? "{}")), + }); + const { status, payload } = respond(); + return new Response(JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const client = new KeeperHubClient({ apiKey: "kh_test", fetch: fetchImpl }); + return { executor: new DirectExecutor(client), calls }; +} + +const ok = () => ({ + status: 200, + payload: { executionId: "direct_1", status: "completed" }, +}); + +describe("DirectExecutor write options", () => { + it("sends no simulate key and no Idempotency-Key by default", async () => { + const { executor, calls } = harness(ok); + await executor.transfer(TRANSFER); + expect("simulate" in calls[0].body).toBe(false); + expect(calls[0].headers["Idempotency-Key"]).toBeUndefined(); + }); + + it("sends simulate as a strict boolean when asked", async () => { + const { executor, calls } = harness(ok); + await executor.transfer(TRANSFER, { simulate: true }); + expect(calls[0].body.simulate).toBe(true); + }); + + it("sets the Idempotency-Key header without touching the body", async () => { + const { executor, calls } = harness(ok); + await executor.transfer(TRANSFER, { idempotencyKey: "abc-123" }); + expect(calls[0].headers["Idempotency-Key"]).toBe("abc-123"); + expect("idempotencyKey" in calls[0].body).toBe(false); + }); + + it("keeps the caller's input fields intact alongside the options", async () => { + const { executor, calls } = harness(ok); + await executor.transfer(TRANSFER, { + simulate: true, + idempotencyKey: "abc-123", + }); + expect(calls[0].body.recipientAddress).toBe(TRANSFER.recipientAddress); + expect(calls[0].body.amount).toBe("0.0001"); + }); +}); + +describe("DirectExecutor.simulateTransfer", () => { + it("returns a clean verdict on a successful simulation", async () => { + const { executor, calls } = harness(() => ({ + status: 200, + payload: { success: true, wouldRevert: false }, + })); + const verdict = await executor.simulateTransfer(TRANSFER); + expect(calls[0].body.simulate).toBe(true); + expect(verdict).toMatchObject({ success: true, wouldRevert: false }); + }); + + it("returns a verdict instead of throwing when the call would revert", async () => { + const { executor } = harness(() => ({ + status: 400, + payload: { + wouldRevert: true, + error: + "Insufficient ETH balance. Have: 0.0, Need: 0.0001. Fund the wallet and retry.", + }, + })); + const verdict = await executor.simulateTransfer(TRANSFER); + expect(verdict.wouldRevert).toBe(true); + expect(verdict.success).toBe(false); + expect(verdict.error).toContain("Insufficient ETH balance"); + }); + + it("reads details when the reason is not in error", async () => { + const { executor } = harness(() => ({ + status: 400, + payload: { wouldRevert: true, details: "execution reverted: ERC20: bad" }, + })); + const verdict = await executor.simulateTransfer(TRANSFER); + expect(verdict.error).toBe("execution reverted: ERC20: bad"); + }); + + it("still throws on a real failure that is not a revert", async () => { + const { executor } = harness(() => ({ + status: 401, + payload: { error: "unauthorized" }, + })); + await expect(executor.simulateTransfer(TRANSFER)).rejects.toThrow(); + }); + + it("still throws on a 400 that carries no revert verdict", async () => { + const { executor } = harness(() => ({ + status: 400, + payload: { error: "simulate must be a boolean" }, + })); + await expect(executor.simulateTransfer(TRANSFER)).rejects.toThrow(); + }); +}); From 14397de8c2a24cd540cdca2278aa45a23f12fc8f Mon Sep 17 00:00:00 2001 From: seekdaseek Date: Fri, 7 Aug 2026 09:38:03 +0300 Subject: [PATCH 2/5] Direct Execution: add simulateCheckAndExecute, and code and revertReason on DirectSimulationResult --- src/direct-executor.ts | 34 ++++++++++++ src/types.ts | 4 ++ test/direct-write-options.test.ts | 92 ++++++++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/direct-executor.ts b/src/direct-executor.ts index 614b65d..424b0c1 100644 --- a/src/direct-executor.ts +++ b/src/direct-executor.ts @@ -43,6 +43,29 @@ function asRecord(value: unknown): Record | undefined { : undefined; } +/** + * Read the first non-empty string found under any of `keys`, looking at the + * body and then at `details`. Direct Execution puts structured context in + * `details`, so a caller should not have to reach into `raw` for it. + */ +function readField( + obj: Record, + ...keys: string[] +): string | undefined { + for (const key of keys) { + const value = obj[key]; + if (typeof value === "string" && value.length > 0) return value; + } + const details = asRecord(obj.details); + if (details) { + for (const key of keys) { + const value = details[key]; + if (typeof value === "string" && value.length > 0) return value; + } + } + return undefined; +} + /** * DirectExecutor wraps KeeperHub's Direct Execution API — synchronous * blockchain operations that don't require a workflow definition. @@ -114,6 +137,13 @@ export class DirectExecutor { return this.simulate("/execute/contract-call", input); } + /** Simulate a check-and-execute without broadcasting it. */ + simulateCheckAndExecute( + input: DirectCheckAndExecuteInput + ): Promise { + return this.simulate("/execute/check-and-execute", input); + } + /** * Run a Direct Execution call with `simulate: true` and return the verdict. * @@ -136,6 +166,8 @@ export class DirectExecutor { success: obj.success === true, wouldRevert: obj.wouldRevert === true, error: readErrorText(obj), + code: readField(obj, "code", "errorCode"), + revertReason: readField(obj, "revertReason", "reason"), raw: res, }; } catch (err) { @@ -146,6 +178,8 @@ export class DirectExecutor { success: false, wouldRevert: true, error: readErrorText(obj), + code: readField(obj, "code", "errorCode"), + revertReason: readField(obj, "revertReason", "reason"), raw: khErr?.body, }; } diff --git a/src/types.ts b/src/types.ts index 334512d..4657c8f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -214,6 +214,10 @@ export interface DirectSimulationResult { wouldRevert: boolean; /** Server-supplied reason when the call would not succeed. */ error?: string; + /** Stable machine-readable code when the server supplies one. */ + code?: string; + /** Chain-supplied revert reason when the call would revert. */ + revertReason?: string; /** Raw response body; the simulate payload carries more than this shape. */ raw: unknown; } diff --git a/test/direct-write-options.test.ts b/test/direct-write-options.test.ts index 145a72f..ca129d8 100644 --- a/test/direct-write-options.test.ts +++ b/test/direct-write-options.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect } from "vitest"; import { KeeperHubClient, DirectExecutor } from "../src/index.js"; -import type { DirectTransferInput } from "../src/index.js"; +import type { + DirectTransferInput, + DirectCheckAndExecuteInput, +} from "../src/index.js"; const TRANSFER: DirectTransferInput = { network: "sepolia", @@ -123,3 +126,90 @@ describe("DirectExecutor.simulateTransfer", () => { await expect(executor.simulateTransfer(TRANSFER)).rejects.toThrow(); }); }); + +const CHECK: DirectCheckAndExecuteInput = { + network: "sepolia", + contractAddress: "0x0000000000000000000000000000000000000002", + functionName: "balanceOf", + condition: { operator: "gte", value: "1000" }, + action: { + network: "sepolia", + contractAddress: "0x0000000000000000000000000000000000000003", + functionName: "withdraw", + }, +}; + +describe("DirectExecutor.simulateCheckAndExecute", () => { + it("simulates against the check-and-execute route", async () => { + const { executor, calls } = harness(() => ({ + status: 200, + payload: { success: true, wouldRevert: false }, + })); + const verdict = await executor.simulateCheckAndExecute(CHECK); + expect(calls[0].url).toContain("/execute/check-and-execute"); + expect(calls[0].body.simulate).toBe(true); + expect(verdict).toMatchObject({ success: true, wouldRevert: false }); + }); + + it("returns a verdict instead of throwing when the call would revert", async () => { + const { executor } = harness(() => ({ + status: 400, + payload: { wouldRevert: true, error: "execution reverted: not ready" }, + })); + const verdict = await executor.simulateCheckAndExecute(CHECK); + expect(verdict.wouldRevert).toBe(true); + expect(verdict.success).toBe(false); + expect(verdict.error).toContain("not ready"); + }); + + it("still throws on a failure that is not a revert", async () => { + const { executor } = harness(() => ({ + status: 401, + payload: { error: "unauthorized" }, + })); + await expect(executor.simulateCheckAndExecute(CHECK)).rejects.toThrow(); + }); +}); + +describe("DirectSimulationResult code and revertReason", () => { + it("surfaces code and revertReason from the body", async () => { + const { executor } = harness(() => ({ + status: 400, + payload: { + wouldRevert: true, + error: "Insufficient ETH balance. Have: 0.0, Need: 0.0001.", + code: "INSUFFICIENT_BALANCE", + revertReason: "execution reverted", + }, + })); + const verdict = await executor.simulateTransfer(TRANSFER); + expect(verdict.code).toBe("INSUFFICIENT_BALANCE"); + expect(verdict.revertReason).toBe("execution reverted"); + }); + + it("reads them from details when they are nested there", async () => { + const { executor } = harness(() => ({ + status: 400, + payload: { + wouldRevert: true, + details: { + code: "GAS_TOO_LOW", + revertReason: "execution reverted: ERC20: bad", + }, + }, + })); + const verdict = await executor.simulateTransfer(TRANSFER); + expect(verdict.code).toBe("GAS_TOO_LOW"); + expect(verdict.revertReason).toBe("execution reverted: ERC20: bad"); + }); + + it("leaves both undefined when the server sends neither", async () => { + const { executor } = harness(() => ({ + status: 200, + payload: { success: true, wouldRevert: false }, + })); + const verdict = await executor.simulateTransfer(TRANSFER); + expect(verdict.code).toBeUndefined(); + expect(verdict.revertReason).toBeUndefined(); + }); +}); From f6d05610c9a8a2b66ddb620f5e80f07a8521384b Mon Sep 17 00:00:00 2001 From: joelorzet Date: Fri, 7 Aug 2026 18:37:45 -0300 Subject: [PATCH 3/5] fix: keep an absent wouldRevert distinct from false wouldRevert was declared required and computed with `obj.wouldRevert === true`, so a response that omits the field produced `false`. That reads as "checked, and it would not revert" when the truth is that nothing was checked. check-and-execute stops before the action when the condition is not met or the action is read-only. Those responses carry no wouldRevert, because no write was ever encoded or estimated. Collapsing them to false turns a dry run that examined nothing into a green light, and a broadcast issued on the strength of it can run a write the simulation never looked at, which is the one thing a preflight exists to prevent. wouldRevert is now optional and carried through as the server sent it, so undefined means "not checked". executed and conditionResult come through too, so a caller can tell why no action was simulated instead of digging into raw. --- src/direct-executor.ts | 18 +++++++++++++++++- src/types.ts | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/direct-executor.ts b/src/direct-executor.ts index 424b0c1..1292d2d 100644 --- a/src/direct-executor.ts +++ b/src/direct-executor.ts @@ -37,6 +37,15 @@ function readErrorText(obj: Record): string | undefined { return undefined; } +/** + * Read a boolean the server actually sent, leaving an absent or non-boolean + * value as `undefined`. "The server did not say" and "the server said false" + * are different answers and must stay distinguishable at the call site. + */ +function readBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + function asRecord(value: unknown): Record | undefined { return value && typeof value === "object" ? (value as Record) @@ -164,7 +173,12 @@ export class DirectExecutor { const obj = asRecord(res) ?? {}; return { success: obj.success === true, - wouldRevert: obj.wouldRevert === true, + // Carried through rather than coerced. Collapsing an absent field to + // `false` would report "would not revert" for a call that was never + // encoded, which is the one answer a preflight must not invent. + wouldRevert: readBoolean(obj.wouldRevert), + executed: readBoolean(obj.executed), + conditionResult: obj.conditionResult, error: readErrorText(obj), code: readField(obj, "code", "errorCode"), revertReason: readField(obj, "revertReason", "reason"), @@ -177,6 +191,8 @@ export class DirectExecutor { return { success: false, wouldRevert: true, + executed: readBoolean(obj.executed), + conditionResult: obj.conditionResult, error: readErrorText(obj), code: readField(obj, "code", "errorCode"), revertReason: readField(obj, "revertReason", "reason"), diff --git a/src/types.ts b/src/types.ts index 4657c8f..7b8e53e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -211,7 +211,23 @@ export interface DirectWriteOptions { */ export interface DirectSimulationResult { success: boolean; - wouldRevert: boolean; + /** + * Whether the simulated call would revert. + * + * Absent when nothing was simulated. `check-and-execute` stops before the + * action when the condition is not met or the action is read-only, so it + * reports nothing about a write it never encoded. Treat `undefined` as "not + * checked", never as "safe": a later broadcast may run a write this dry run + * never looked at. + */ + wouldRevert?: boolean; + /** + * Whether the action would have run. Present on `check-and-execute`, where + * `false` means the condition was not met and no write was simulated. + */ + executed?: boolean; + /** The condition verdict, present on `check-and-execute`. */ + conditionResult?: unknown; /** Server-supplied reason when the call would not succeed. */ error?: string; /** Stable machine-readable code when the server supplies one. */ From 1e64a3b31d5750902070549bc1725dedf769568d Mon Sep 17 00:00:00 2001 From: joelorzet Date: Fri, 7 Aug 2026 18:38:33 -0300 Subject: [PATCH 4/5] test: pin that an absent wouldRevert stays undefined Four cases over the distinction the fix restores: a check-and-execute dry run that simulated nothing leaves wouldRevert undefined and reports executed false, the condition verdict reaches the caller without going through raw, a checked call still reports false, and a would-revert 400 still reports true. The first fails against the previous coercion, so the guarantee is held by a test rather than by the comment next to it. --- test/direct-write-options.test.ts | 71 +++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/test/direct-write-options.test.ts b/test/direct-write-options.test.ts index ca129d8..6797dfa 100644 --- a/test/direct-write-options.test.ts +++ b/test/direct-write-options.test.ts @@ -11,6 +11,18 @@ const TRANSFER: DirectTransferInput = { amount: "0.0001", }; +const CHECK_AND_EXECUTE: DirectCheckAndExecuteInput = { + network: "sepolia", + contractAddress: "0x0000000000000000000000000000000000000002", + functionName: "balanceOf", + condition: { operator: "gt", value: "50" }, + action: { + network: "sepolia", + contractAddress: "0x0000000000000000000000000000000000000003", + functionName: "transfer", + }, +}; + interface Capture { url: string; headers: Record; @@ -213,3 +225,62 @@ describe("DirectSimulationResult code and revertReason", () => { expect(verdict.revertReason).toBeUndefined(); }); }); + +describe("DirectSimulationResult distinguishes absent from false", () => { + it("leaves wouldRevert undefined when the server omits it", async () => { + const { executor } = harness(() => ({ + status: 200, + payload: { + success: true, + status: "simulated", + executed: false, + conditionResult: { met: false }, + }, + })); + + const verdict = await executor.simulateCheckAndExecute(CHECK_AND_EXECUTE); + + expect(verdict.success).toBe(true); + expect(verdict.wouldRevert).toBeUndefined(); + expect(verdict.executed).toBe(false); + }); + + it("surfaces the condition verdict so a caller knows why nothing ran", async () => { + const { executor } = harness(() => ({ + status: 200, + payload: { + success: true, + status: "simulated", + executed: false, + conditionResult: { met: false, observedValue: "10" }, + }, + })); + + const verdict = await executor.simulateCheckAndExecute(CHECK_AND_EXECUTE); + + expect(verdict.conditionResult).toMatchObject({ met: false }); + }); + + it("still reports false when the server checked and found it safe", async () => { + const { executor } = harness(() => ({ + status: 200, + payload: { success: true, wouldRevert: false, executed: true }, + })); + + const verdict = await executor.simulateTransfer(TRANSFER); + + expect(verdict.wouldRevert).toBe(false); + }); + + it("still reports true on a would-revert 400", async () => { + const { executor } = harness(() => ({ + status: 400, + payload: { wouldRevert: true, revertReason: "ERC20: bad" }, + })); + + const verdict = await executor.simulateTransfer(TRANSFER); + + expect(verdict.wouldRevert).toBe(true); + expect(verdict.revertReason).toBe("ERC20: bad"); + }); +}); From 37d1bce5a1e4eaeb7e980786892067cc1b967d3d Mon Sep 17 00:00:00 2001 From: joelorzet Date: Fri, 7 Aug 2026 18:38:40 -0300 Subject: [PATCH 5/5] docs: guard against a dry run that simulated nothing The quickstart guard tested wouldRevert for truthiness, which passes when the field is absent and so treats "nothing was simulated" as a green light. It now compares against true and undefined explicitly, and says why the two are different answers. --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 65aef7f..442f300 100644 --- a/README.md +++ b/README.md @@ -57,14 +57,23 @@ const verdict = await direct.simulateTransfer({ amount: "0.0001", }); -if (!verdict.success || verdict.wouldRevert) { +if (!verdict.success || verdict.wouldRevert === true) { console.error(verdict.error); return; } + +if (verdict.wouldRevert === undefined) { + // Nothing was simulated, so this is not a green light. On + // simulateCheckAndExecute it means the condition was not met, or the action + // is read-only. Read verdict.executed and verdict.conditionResult to see why. + return; +} ``` A simulation that reports the call would revert is an answer about the chain, not a transport failure. `simulateTransfer` and `simulateContractCall` return that answer as a value; anything else still throws `KeeperHubError`. +Compare `wouldRevert` against `true` and `undefined` explicitly rather than testing it for truthiness. Absent means the endpoint never encoded a call, which is a different answer from a call it checked and found safe, and treating the two alike lets a later broadcast run a write the dry run never examined. + ### Make a retry safe ```ts