Lifecycle hooks
onExecuted and onError react to the result of each tick without breaking the next one.
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, usectx.loggerinsidedecide(), 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
errorand discarded. A crashing hook has zero effect on whether the plugin is marked unhealthy (decided *before* the hook, by theoutcome) or whether the next tick fires. - Writes are not transactional. A hook that does
set("a",1); throw; set("b",2)leavesa=1andbunset. For an atomic multi-key write, do a singlesetof a structured value. - Ordering. When both are defined and the tick failed,
onExecutedruns beforeonError. They sharectx.stateandctx.logger, so writes from the first are visible in the second. ctx.vaultis 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 apmDeposit, theactionsarray 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 whatdecide()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 (
lastSuccessAtvslastErrorAt). - 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
| Intent | Hook |
|---|---|
| "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 |