Skip to content
Runner · Examples

Lifecycle hooks

onExecuted and onError react to the result of each tick without breaking the next one.

Derived from runner/notes/lifecycle-hooks.md

Two optional hooks run after the runtime has tried to execute what decide() returned: onExecuted and onError. Both are isolated from each other and from the next tick.

The split exists so you don't have to branch on result.outcome inside one giant hook. onExecuted runs for every outcome (submitted / noop / failed); onError runs only on failure, with a focused payload. Define one, the other, or both.

Dry-runs skip both hooks. Always. Most hooks mutate the same persistent state production uses (cooldowns, ledgers, bans), and a simulation firing them would corrupt it. For observability in a dry-run, use ctx.logger inside decide(), which logs normally.

Canonical patterns

Audit log: persist every trade that landed

async onExecuted(ctx, result) {
  if (result.outcome !== "submitted") return;
  for (const ev of result.events ?? []) {
    if (ev.type.endsWith("::vault::VaultMintBinary")) {
      await ctx.state.set(`trade:${result.digest}`, {
        digest: result.digest,
        recordedAtMs: ctx.now,
        gasMist: result.gasUsed?.toString() ?? "0",
        params: ev.parsedJson,
      });
    }
  }
}

Keyed by the digest, so re-running the same tick is idempotent (the digest is unique per submitted tx).

Cooldown on success only

async onExecuted(ctx, result) {
  if (result.outcome !== "submitted") return;
  await ctx.state.set("lastSuccessMs", ctx.now);
}

Only on submitted. Not on noop, because no action was taken and resetting the cooldown would block legitimate attempts next tick. Not on failure either, because nothing was taken and you want an earlier retry, not to wait out the whole cooldown for nothing.

Ban-aware: skip strikes that aborted

async onError(ctx, err) {
  if (err.phase === "client") {
    // RPC down, signer rejected, or preflight failed. Retry on the next tick.
    ctx.logger.warn("client error (transient)", { error: err.error });
    return;
  }
  // On-chain abort. Distinguish degenerate strikes from other failures.
  const isDegenerate =
    err.error.includes("EFairPriceAlreadySettled") ||
    err.error.includes("EInvalidStrike");
  if (!isDegenerate) return;

  for (const action of err.actions) {
    if (action.kind !== "vault.mintBinary") continue;
    await ctx.state.setWithTTL(
      `ban:${action.params.marketId}:${action.params.strike}`,
      { reason: err.error },
      minutes(15),   // ban expires in 15 min; the oracle may have settled by then
    );
  }
}

Contract guarantees

  • Isolation. Both hooks run inside a try/catch in the runtime. Exceptions are logged at error and discarded. A crashing hook has zero effect on whether the plugin is marked unhealthy (decided *before* the hook, by the outcome) or whether the next tick fires.
  • Writes are not transactional. A hook that does set("a",1); throw; set("b",2) leaves a=1 and b unset. For an atomic multi-key write, do a single set of a structured value.
  • Ordering. When both are defined and the tick failed, onExecuted runs before onError. They share ctx.state and ctx.logger, so writes from the first are visible in the second.
  • ctx.vault is pre-tx. The vault cache is invalidated *after* a submitted tx, and the hook runs *between* submission and invalidation. To read post-tx state, wait for the next tick or re-fetch manually.
  • Auto-fund shows up in actions. When auto-fund prepends a pmDeposit, the actions array passed to the hook includes that action. Iterate with .filter(a => a.kind === "vault.mintBinary") rather than indexing [0].

Anti-patterns

  • Synchronous retry inside the hook. The scheduler already retries via unhealthy backoff. Re-firing decide() inside the hook blocks the event loop and tends toward an infinite loop.
  • Mutating result.actions / err.actions. It's only an echo of what decide() returned, kept for correlation. The tx is already submitted.
  • Writing the same state key from both hooks. It works (they run in sequence) but it's confusing: the second write silently overwrites. Use distinct keys (lastSuccessAt vs lastErrorAt).
  • Heavy I/O blocking the next tick. Hooks run inline with the tick. For heavy I/O (external metrics, a webhook), make it fire-and-forget with void ...catch(...).

Choosing the hook

IntentHook
"Did my trade land? Record it."onExecuted (outcome === "submitted")
"Was it a noop? Measure the rate."onExecuted (outcome === "noop")
"Something went wrong, log it."onError
"On-chain abort? Ban the strike."onError (phase === "on-chain")
"RPC instability? Report it to the dashboard."onError (phase === "client")
"I want to react to everything in one place."onExecuted, branching on the outcome