Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
173 changes: 157 additions & 16 deletions src/direct-executor.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,80 @@
import { KeeperHubError } from "./client.js";
import type { KeeperHubClient } from "./client.js";
import type {
DirectCheckAndExecuteInput,
DirectCheckAndExecuteResult,
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, unknown>): 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<string, unknown> | undefined {
return value && typeof value === "object"
? (value as Record<string, unknown>)
: 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<string, unknown>,
...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.
Expand All @@ -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<DirectWriteResult> {
return this.client.rawRequest<DirectWriteResult>("/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<DirectWriteResult> {
return this.client.rawRequest<DirectWriteResult>(
"/execute/transfer",
writeInit(input, opts)
);
}

/**
Expand All @@ -38,30 +112,97 @@ export class DirectExecutor {
* Use `isReadResult` to discriminate at the call site.
*/
callContract(
input: DirectContractCallInput
input: DirectContractCallInput,
opts?: DirectWriteOptions
): Promise<DirectReadResult | DirectWriteResult> {
return this.client.rawRequest<DirectReadResult | DirectWriteResult>(
"/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<DirectCheckAndExecuteResult> {
return this.client.rawRequest<DirectCheckAndExecuteResult>(
"/execute/check-and-execute",
{
method: "POST",
body: JSON.stringify(input),
}
writeInit(input, opts)
);
}

/** Simulate a transfer without broadcasting it. */
simulateTransfer(
input: DirectTransferInput
): Promise<DirectSimulationResult> {
return this.simulate("/execute/transfer", input);
}

/** Simulate a contract call without broadcasting it. */
simulateContractCall(
input: DirectContractCallInput
): Promise<DirectSimulationResult> {
return this.simulate("/execute/contract-call", input);
}

/** Simulate a check-and-execute without broadcasting it. */
simulateCheckAndExecute(
input: DirectCheckAndExecuteInput
): Promise<DirectSimulationResult> {
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<DirectSimulationResult> {
try {
const res = await this.client.rawRequest<unknown>(
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<DirectExecutionStatus> {
return this.client.rawRequest<DirectExecutionStatus>(
Expand Down
51 changes: 51 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading