diff --git a/README.md b/README.md index 2e24d70..442f300 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,72 @@ 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 === 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 +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..1292d2d 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,75 @@ 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; +} + +/** + * 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) + : 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. @@ -22,12 +88,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 +112,97 @@ 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); + } + + /** 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. + * + * 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, + // 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"), + 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, + executed: readBoolean(obj.executed), + conditionResult: obj.conditionResult, + error: readErrorText(obj), + code: readField(obj, "code", "errorCode"), + revertReason: readField(obj, "revertReason", "reason"), + 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..7b8e53e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -186,3 +186,54 @@ 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; + /** + * 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. */ + 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 new file mode 100644 index 0000000..6797dfa --- /dev/null +++ b/test/direct-write-options.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect } from "vitest"; +import { KeeperHubClient, DirectExecutor } from "../src/index.js"; +import type { + DirectTransferInput, + DirectCheckAndExecuteInput, +} from "../src/index.js"; + +const TRANSFER: DirectTransferInput = { + network: "sepolia", + recipientAddress: "0x0000000000000000000000000000000000000001", + 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; + 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(); + }); +}); + +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(); + }); +}); + +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"); + }); +});