Skip to content
Runner · ExamplesLadderrung 11

Multi-trigger plugin (cron + event) + onError minimal pattern

multi-trigger (cron + event)

Mirrors runner/src/plugins/11-exampleMultiTrigger

What this teaches

  • A plugin can register MULTIPLE triggers — same decide() runs for each
  • Cron trigger does periodic health checks
  • Event trigger reacts to upstream notifications (pg_notify topics from the indexer pipeline) — wired by the runtime via scheduler.dispatchEvent
  • The plugin doesn't see which trigger fired; it acts on current state
  • onError (minimal): log every failure with discriminated phase (client vs on-chain). No state, no audit — just visibility. This is the simplest possible error handler.

New vs exampleFailureAware

  • 2 triggers driving the same logic
  • onError dedicated to failure logging (no branching inside onExecuted)
import type { StrategyPlugin } from "@automark/runtime-core";
import { Market } from "@automark/sdk/market";
import { minutes, seconds } from "@automark/sdk/duration";

const MIN_TICK_INTERVAL_MS = seconds(30);   // dedupe overlapping triggers

export default function createExampleMultiTrigger(): StrategyPlugin {
  const vaultId = process.env.VAULT_ID;
  if (!vaultId) throw new Error("exampleMultiTrigger: VAULT_ID not set");

  return {
    name: "exampleMultiTrigger",
    vaultId,
    triggers: [
      { kind: "cron", everySeconds: 3600 },                  // periodic safety net
      { kind: "event", topic: "deposit_processed" },        // react to new deposits
    ],

    async decide(ctx) {
      if (ctx.vault.isFrozen) return [{ kind: "noop", reason: "frozen" }];

      // Idempotency: don't double-act if cron + event fire close together
      const lastTickMs = (await ctx.state.get<number>("lastTickMs")) ?? 0;
      if (ctx.now - lastTickMs < MIN_TICK_INTERVAL_MS) {
        return [{ kind: "noop", reason: "tick dedupe" }];
      }
      await ctx.state.set("lastTickMs", ctx.now);

      // Deploy idle capital — react both to time passing AND fresh deposits
      const { quoteBalance, exposureHeadroom } = ctx.vault;
      const deployable = quoteBalance < exposureHeadroom ? quoteBalance : exposureHeadroom;
      if (deployable === 0n) {
        return [{ kind: "noop", reason: "nothing to deploy" }];
      }
      const half = deployable / 2n;

      const btc = await Market.find({
        asset: "BTC",
        expiryAfterMs: ctx.now + minutes(5),
        client: ctx.suiClient,
      });
      const p = await btc.price();

      return [
        { kind: "vault.pmDeposit", params: { amount: half } },
        {
          kind: "vault.mintBinary",
          params: {
            marketId: btc.id,
            strike: btc.strikeAbove(p.forwardRaw, { pctBps: 250 }),
            isUp: true,
            quantity: half,
          },
        },
      ];
    },

    // Minimal error handler — just visibility. The phase discriminator lets
    // ops differentiate transient RPC issues from real on-chain rejections.
    async onError(ctx, err) {
      ctx.logger.warn("execution failed", {
        phase: err.phase,
        digest: err.digest,
        error: err.error,
        dryRun: err.dryRun,
      });
    },
  };
}


// insights

// Right at the start of the code, the triggers for running decide() are set up via a cron and via an external event
// Now, besides being fired every 3600 as in the example, the code also fires decide() whenever the "" event happens.

Environment variables

  • VAULT_ID