Skip to content
Runner · ExamplesLadderrung 18

Pure-lifecycle custodian: never opens, only tidies up

pure custody: only redeem by expiry + refreshNav/crystallize permissionless

Mirrors runner/src/plugins/18-exampleExpiryJanitor

What this teaches

  • The close/maintain half of the lifecycle (every other example only opens)
  • Reading open positions on-chain via a shared reader (the qty for a position you did NOT open lives on-chain, not in your per-plugin state) — this is the cross-protocol "read other on-chain state" pattern
  • Deterministic redemption by expiry window (binary AND range)
  • refreshNavSnapshot / crystallizeFees as a plugin's whole reason to exist, not a preamble to a mint (cf. 09/13 which only prepend a refresh)
  • A useful plugin can run on a vault it never trades on

New vs 15-exampleChaos

  • Chaos redeems at RANDOM as a side effect of trading. This opens nothing and redeems DETERMINISTICALLY by expiry, plus runs permissionless upkeep. Why the on-chain read (and not ctx.state): ctx.state and ctx.lifecycle are scoped to THIS plugin — they'd only ever show trades this plugin made, and it makes none. So the open size comes from `readOpenPositions` (one DevInspect). A builder outside the monorepo would use the predict-server REST positions endpoint or their own DevInspect; here we reuse @automark/shared's reader.
import { noop, type Action, type StrategyPlugin } from "@automark/runtime-core";
import { readOpenPositions } from "@automark/shared/sui";
import { getNetworkConfig, type SuiNetwork } from "@automark/shared/network";
import type { MarketKey, RangeKey } from "@automark/shared/types";
import { days, minutes } from "@automark/sdk/duration";
import { dueForRedeem, shouldCrystallize, shouldRefreshNav } from "./schedule";

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

  const network = (process.env.SUI_NETWORK ?? "testnet") as SuiNetwork;
  const redeemLeadMs = Number(process.env.REDEEM_LEAD_MS ?? minutes(10));
  const crystallizeCadenceMs = Number(process.env.CRYSTALLIZE_CADENCE_MS ?? days(1));

  return {
    name: "exampleExpiryJanitor",
    vaultId,
    triggers: [{ kind: "cron", everySeconds: 120 }],

    async decide(ctx) {
      if (ctx.vault.isFrozen) return [noop("frozen")];

      const actions: Action[] = [];

      // 1) Redeem positions ripe by expiry. Open qty is read on-chain — it is
      //    not in our per-plugin state because we never opened them.
      const pmId = ctx.vault.predictManagerId;
      if (pmId && (ctx.vault.canRedeemBinary || ctx.vault.canRedeemRange)) {
        const cfg = getNetworkConfig(network);
        const open = await readOpenPositions(
          ctx.suiClient,
          {
            packageId: cfg.predictPackageId,
            predictObjectId: cfg.predictObjectId,
            predictManagerId: pmId,
          },
          ctx.vault.rawState,
        ).catch((e) => {
          ctx.logger.warn("readOpenPositions failed", { error: String(e) });
          return [];
        });

        for (const pos of open) {
          const expiryMs = Number((pos.raw as { expiry: bigint }).expiry);
          if (!dueForRedeem(expiryMs, ctx.now, redeemLeadMs)) continue;

          if (pos.kind === "binary" && ctx.vault.canRedeemBinary) {
            const k = pos.raw as MarketKey;
            actions.push({
              kind: "vault.redeemBinary",
              params: { marketId: k.oracle_id, strike: k.strike, isUp: k.is_up, quantity: pos.quantity },
            });
          } else if (pos.kind === "range" && ctx.vault.canRedeemRange) {
            const k = pos.raw as RangeKey;
            actions.push({
              kind: "vault.redeemRange",
              params: {
                marketId: k.oracle_id,
                lowerStrike: k.lower_strike,
                higherStrike: k.higher_strike,
                quantity: pos.quantity,
              },
            });
          }
        }
      }

      // 2) Permissionless upkeep. crystallizeFees(includeNavRefresh) subsumes a
      //    separate NAV refresh, so we emit at most one of the two. Only book
      //    fees when there's something accrued to book.
      const intervalSec = Number(ctx.vault.navParams.snapshot_interval_seconds);
      const navStale = shouldRefreshNav(ctx.vault.timeSinceLastSnapshotMs(ctx.now), intervalSec);
      const feesWorthBooking = ctx.vault.accruedFeesQuote > 0n;
      const crystallizeDue =
        feesWorthBooking &&
        shouldCrystallize(ctx.now, Number(ctx.vault.lastCrystallizationTs), crystallizeCadenceMs);

      if (crystallizeDue) {
        actions.push({ kind: "vault.crystallizeFees", params: { includeNavRefresh: true } });
      } else if (navStale) {
        actions.push({ kind: "vault.refreshNavSnapshot" });
      }

      if (actions.length === 0) return [noop("nothing ripe; nav fresh; no fees due")];
      ctx.logger.info("janitor batch", { actions: actions.map((a) => a.kind) });
      return actions;
    },
  };
}

Environment variables

  • VAULT_ID
  • SUI_NETWORK default "testnet" (resolves predict package/object)
  • REDEEM_LEAD_MS redeem positions expiring within this (default 10m)
  • CRYSTALLIZE_CADENCE_MS off-chain pacing for crystallize (default 24h)