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)