Skip to content

automark-protocol/runner

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Automark Runner

Run your vault strategy off-chain. Decide when to mint, redeem, or rebalance, and let the runner sign and submit the transactions for you.

This repo is a runtime you clone and run — not a library you install. It ships with the engine plus a single starter plugin, my-strategy, registered and ready to dry-run. You edit its decide() (or add your own plugin under src/plugins/), point it at your vault, and run.

What this is

You write a plugin: a TypeScript function that looks at your vault's current state and returns the actions you want to take. The agent handles the rest — scheduling, signing as your operator_address, submitting to Sui, retrying on transient errors, logging.

You focus on strategy logic. Everything else is plumbing.

How a plugin looks

import type { StrategyPlugin } from "../../engine/runtime-core";
import { Market } from "@automark/sdk/market";

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

  return {
    name: "my-strategy",
    vaultId,
    triggers: [{ kind: "cron", everySeconds: 60 }],

    async decide(ctx) {
      const { isFrozen, maxSinglePosition, exposureHeadroom } = ctx.vault;

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

      // Pick the NEAREST active BTC market whose expiry is still > 5 min away.
      // `pick: "nearest"` is the default — pass `pick: "furthest"` if you want
      // the longest-dated instead. See "Markets and live prices" for more.
      const btc = await Market.find({
        asset: "BTC",
        expiryAfterMs: ctx.now + 5 * 60 * 1000,
        client: ctx.suiClient,
      });

      // Read live spot + forward price + SVI sigma
      const p = await btc.price();
      ctx.logger.info("market", { spot: p.spotUsd, forward: p.forwardUsd });

      // Your decision logic — replace with your real signal
      if (p.forwardUsd <= p.spotUsd) {
        return [{ kind: "noop", reason: "no edge" }];
      }

      // Size trade against the smaller of (per-position cap, total headroom),
      // then take half for a safety margin
      const ceiling =
        maxSinglePosition < exposureHeadroom ? maxSinglePosition : exposureHeadroom;
      const quantity = ceiling / 2n;
      if (quantity === 0n) {
        return [{ kind: "noop", reason: "no headroom" }];
      }

      // Place strike 3% above the forward (snap-to-grid handled by helper)
      const strike = btc.strikeAbove(p.forwardRaw, { pctBps: 300 });

      return [
        {
          kind: "vault.mintBinary",
          params: { marketId: btc.id, strike, isUp: true, quantity },
        },
      ];
    },
  };
}

That is the whole contract. No transaction building, no signing, no retry logic, no logger setup. The decide function takes context, returns intentions.

The import path ../../engine/runtime-core is relative because the runtime is vendored inside this repo under src/engine/. A plugin lives at src/plugins/<name>/index.ts, so it reaches the runtime types two levels up.

Quickstart

1. Clone and install

git clone https://github.com/automark-protocol/automark-runner.git
cd automark-runner
pnpm install        # or: npm install

2. Add your plugin to the registry

Scaffolded with npx create-automark-runner? A starter plugin named my-strategy is already registered and listed in RUNNER_PLUGINS — skip to step 3 and just edit its decide() in src/plugins/my-strategy/.

Open src/plugins/index.ts and add your factory:

import createMyStrategy from "./my-strategy";

export const PLUGINS: Record<string, PluginFactory> = {
  "my-strategy": createMyStrategy,                  // ← add this line
};

This is the only runtime file you edit. Everything else (loader, runtime, scheduler, executor) is infrastructure — don't touch it. When you pull updates to the agent later, those files take updates; this registry stays yours.

3. Set environment variables

# Your operator keypair (the one registered as operator_address on the vault)
OPERATOR_PRIVATE_KEY=suiprivkey1...

# Which plugins to load (comma-separated)
RUNNER_PLUGINS=my-strategy

# Per-plugin config — convention: PLUGIN_<NAME_UPPER_SNAKE>_<KEY>
PLUGIN_MY_STRATEGY_VAULT_ID=0x...

# Optional
SUI_NETWORK=testnet  # testnet | mainnet | devnet  (default: testnet)
LOG_LEVEL=info       # debug | info | warn | error (default: info)

That is it. Sui RPC URL, Predict package, and shared object IDs are resolved automatically from SUI_NETWORK using the Mysten public fullnode and the addresses pinned in the SDK. See "Overriding defaults" below if you need a custom fullnode or a forked deployment.

4. Run

# Run as a daemon (cron triggers fire continuously)
pnpm start

# Or fire one tick of a specific plugin (useful for debugging)
pnpm trigger my-strategy

See the full CLI reference below for dry-run, plugins, inspect, and show-config.

Plugin API

StrategyPlugin

Field Type Required Notes
name string Unique. Used in logs and state scoping.
vaultId string The vault this plugin operates. One plugin, one vault.
triggers Trigger[] When decide() is invoked. Multiple triggers allowed.
decide(ctx) Promise<Action[]> Your strategy. Returns declarative intents.
onExecuted(ctx, result) Promise<void> Optional. Runs after every outcome (submitted/noop/failed). Use for audit log, cooldown-on-success, telemetry.
onError(ctx, err) Promise<void> Optional. Runs only on failed outcomes. Use for dedicated error handling without branching result.outcome.

Both lifecycle hooks (onExecuted, onError) are independent and isolated — exceptions are caught + logged, never impact the next tick or the scheduler's healthy/unhealthy state. When both are defined and the tick fails, order is onExecutedonError.

Trigger

{ kind: "cron"; everySeconds: number }  // run every N seconds
{ kind: "manual" }                       // only via `pnpm trigger <name>`
{ kind: "event"; topic: string }         // reserved for future use

A plugin can have multiple triggers. Same decide() runs for each.

PluginContext

{
  vault: Vault;          // fresh state from the SDK — see field reference below
  suiClient: SuiClient;  // shared client; pass to Market.find({client}), etc.
  now: number;           // Date.now() at trigger time (use this, NOT Date.now())
  state: PluginStateAPI; // KV scoped to your plugin (get/set/setWithTTL/delete/has/keys)
  logger: Logger;        // structured JSON logger, pre-bound with plugin/vault fields
}

ctx.vault is a snapshot of the vault state, fresh at the trigger moment. Every field is a readonly getter — you can destructure it however you want:

async decide(ctx) {
  const {
    paused, strategyPaused,           // is the vault frozen?
    nav, navPerShare,                 // current valuation
    quoteBalance,                     // free quote (not deployed to PM)
    aggregateExposure,                // sum of open position values
    pendingDepositsTotal,             // queued deposits awaiting processing
    pendingWithdrawalSharesTotal,     // queued withdrawals
    accruedFeesQuote,                 // fees waiting for crystallize
    trackedMarketKeys,                // open binary positions
    trackedRangeKeys,                 // open range positions
    lastSnapshotTs,                   // last NAV snapshot
  } = ctx.vault;

  if (paused || strategyPaused) {
    return [{ kind: "noop", reason: "vault frozen" }];
  }
  // ...
}

Vault field reference

All risk caps and fees are in basis points (bps) — the DeFi standard. 1 bps = 0.01%, so 500 bps = 5% and 10_000 bps = 100%. Matches max_position_bps etc. in the vault Move source verbatim, so abort codes like EPositionTooLarge correlate directly with the value you read here.

Question Field Type
Is the vault frozen? paused, strategyPaused boolean
What is the NAV? nav, navPerShare bigint
How much free capital? quoteBalance bigint (in quote units, e.g. dUSDC × 1e6)
How much is locked up in positions? aggregateExposure bigint
Are there pending deposits/withdrawals? pendingDepositsTotal, pendingWithdrawalSharesTotal bigint
What positions are open? trackedMarketKeys, trackedRangeKeys arrays
What was the all-time-high NAV/share? highWaterMark bigint
When was the last NAV refresh? lastSnapshotTs bigint (ms since epoch)
When were fees last crystallized? lastCrystallizationTs bigint
Which Predict Manager is this vault using? predictManagerId string | null
Which strategy module is authorized? authorizedWitnessType string
What ops can the strategy invoke? strategyPermissions bigint (bitfield)
What are the snapshot/cooldown intervals? navParams { snapshot_interval_seconds, deposit_cooldown_seconds, ... }
What are the risk caps? riskParams { max_position_bps, max_aggregate_exposure_bps, max_drawdown_bps, max_share_concentration_bps }
What is the fee schedule? feeParams { performance_fee_bps, management_fee_bps_annual, entry_fee_bps, exit_fee_bps, platform_split_bps }
Which ops is my strategy allowed to call? canMintBinary · canMintRange · canRedeemBinary · canRedeemRange · canPmDeposit · canPmWithdraw · canSupplyPlp · canWithdrawPlp boolean (derived from strategyPermissions bitfield)
Which permission names are granted? grantedPermissions PermissionName[] (e.g. ["MINT_BINARY", "PM_DEPOSIT"]) — useful for logs

Convenience helpers (derived from the fields above)

Question Helper Type
Is the vault frozen for any reason? isFrozen boolean (paused || strategyPaused)
Largest single position I can open right now? maxSinglePosition bigint (nav × max_position_bps / 10_000)
Largest total exposure the vault allows? maxAggregateExposure bigint (nav × max_aggregate_exposure_bps / 10_000)
How much more exposure before hitting the aggregate cap? exposureHeadroom bigint (clamped >= 0n)

Each helper is a one-liner that computes the value from the fields above. They exist because that math repeats in every plugin and getting it wrong trips EPositionTooLarge / EExposureLimitExceeded on chain.

const { isFrozen, maxSinglePosition, exposureHeadroom } = ctx.vault;

if (isFrozen) return [{ kind: "noop", reason: "frozen" }];

// Trade up to whichever cap binds first, with a safety margin
const ceiling = maxSinglePosition < exposureHeadroom
  ? maxSinglePosition
  : exposureHeadroom;
const quantity = ceiling / 2n;

For the raw on-chain struct (advanced — every field), see ctx.vault.rawState.

Markets and live prices

Every mintBinary / mintRange action targets a market. A market is uniquely identified by (asset, expiry) — there are usually multiple active per asset (BTC weekly, BTC monthly, etc.). Use Market from @automark/sdk/market to find them and read live prices:

import { Market } from "@automark/sdk/market";

Find which market to trade:

import { Market } from "@automark/sdk/market";
import { days, hours, minutes } from "@automark/sdk/duration";

// Pick one market by asset + expiry filter + strategy
const btc = await Market.find({
  asset: "BTC",
  expiryAfterMs: ctx.now + hours(1),   // at least 1h of life
  pick: "nearest",                      // or "furthest" (default: "nearest")
  client: ctx.suiClient,                // needed for market.price() later
});

// Or list everything for an asset and pick yourself
const all = await Market.list({ asset: "BTC", client: ctx.suiClient });
// → sorted by expiry ascending; each entry has { id, asset, expiresAtMs, minStrike, tickSize, ... }

// Look up by id (useful for refreshing tickSize/expiry on a market you already track)
const known = await Market.byId("0x...", { client: ctx.suiClient });

Filter by expiry window (no manual .filter() needed):

// Everything BTC expiring in the next 7 days
const week = await Market.list({
  asset: "BTC",
  expiringWithinMs: days(7),
  client: ctx.suiClient,
});

// BTC markets expiring in the next 15-30 min window
const veryShort = await Market.list({
  asset: "BTC",
  expiryAfterMs: ctx.now + minutes(15),
  expiringWithinMs: minutes(30),
  client: ctx.suiClient,
});

// Absolute range — markets expiring between two fixed timestamps
const window = await Market.list({
  asset: "BTC",
  expiringBetweenMs: [fromMs, toMs],
  client: ctx.suiClient,
});

// find() takes the same time filters
const nearestUpToOneHour = await Market.find({
  asset: "BTC",
  expiringWithinMs: hours(1),
  client: ctx.suiClient,
});

The three time filters compose:

Filter Semantics
expiryAfterMs: T Floor — only markets with expiresAtMs > T
expiringWithinMs: D Ceiling relative to nowexpiresAtMs ≤ Date.now() + D
expiringBetweenMs: [a, b] Absolute range — expiresAtMs ∈ [a, b]. Mutually exclusive with expiringWithinMs (pick one)

Combine expiryAfterMs + expiringWithinMs to express "between N minutes and M hours from now" without writing two .filter() calls.

Duration helpers (@automark/sdk/duration):

seconds(30)    // 30_000
minutes(15)    // 900_000
hours(2)       // 7_200_000
days(7)        // 604_800_000
hours(1) + minutes(30)   // 5_400_000 (composable arithmetic)

Use anywhere you'd otherwise write 7 * 24 * 3600 * 1000 — cooldowns, TTL, time filters, retry windows. Fewer typos, more intent.

Read live prices from the market (one read-only RPC, ~200-400ms, no gas):

const p = await btc.price();

p.spotUsd       // 67_421.5         — live spot, USD with 4 decimals
p.forwardUsd    // 67_530.2         — live forward (probabilistic center of SVI)
p.spotRaw       // 67421500000000n  — same as spotUsd but bigint, u64 1e9 scale
p.forwardRaw    // 67530200000000n
p.sviSigmaRaw   // 850000000n       — SVI sigma raw; volatility parameter
p.atMs          // read timestamp

Caller decides whether to cache (most strategies want fresh state every tick).

Picking a strike without touching the grid math. The market exposes 4 helpers that accept either USD numbers (67_500) or raw bigint (67_500_000_000_000n), snap to the grid, and clamp to [minStrike, maxStrike]:

btc.strikeNear(67_500);                            // builder saw $67,500 on a chart
btc.strikeNear(p.spotRaw);                         // ATM (at-the-money)
btc.strikeAbove(p.forwardRaw, { pctBps: 300 });    // 3% above forward
btc.strikeBelow(p.spotUsd,    { pctBps: 500 });    // 5% below spot (USD input ok)

// Probabilistic strike — k σ from forward, scaled by √(time-to-expiry)
btc.strikeAtSigma(p, { k: 1, direction: "up", atMs: ctx.now });

pctBps is basis points (300 = 3%, 500 = 5%). Same convention as the risk caps — see the bps note in the field reference above.

The forward price is what the market treats as "neutral" — the probabilistic center of the SVI curve. Strikes priced relative to forward align with how the market quotes options. Use forwardRaw for arithmetic that needs precision, forwardUsd for logs and display.

strikeAtSigma adapts to time and volatility: a 1σ strike is closer to forward on a short-dated market and further out on a long-dated one, without you doing the math.

Action[] — what you can return

{ kind: "noop"; reason: string }

// Mint / redeem (binary + range) — strategy permission required
{ kind: "vault.mintBinary";   params: { marketId, strike, isUp, quantity, skipAutoFund? } }
{ kind: "vault.mintRange";    params: { marketId, lowerStrike, higherStrike, quantity, skipAutoFund? } }
{ kind: "vault.redeemBinary"; params: { marketId, strike, isUp, quantity } }
{ kind: "vault.redeemRange";  params: { marketId, lowerStrike, higherStrike, quantity } }

// PM custody (mint companion — runtime auto-funds before mint by default)
{ kind: "vault.pmDeposit";    params: { amount } }
{ kind: "vault.pmWithdraw";   params: { amount } }

// PLP — supply / withdraw to the Predict Liquidity Pool
{ kind: "vault.supplyPlp";    params: { amount } }
{ kind: "vault.withdrawPlp";  params: { sharesAmount } }   // PLP shares, NOT quote

// Permissionless — anyone can call (no strategy permission needed)
{ kind: "vault.refreshNavSnapshot" }
{ kind: "vault.crystallizeFees" }

Multiple actions in one return get bundled into a single atomic transaction. For example, fund the predict manager and open a position in one tx:

return [
  { kind: "vault.pmDeposit", params: { amount: 500_000_000n } },
  {
    kind: "vault.mintBinary",
    params: { marketId, strike, isUp: true, quantity: 100_000_000n },
  },
];

State (optional)

If your strategy needs memory across ticks (last_trade_ts, counters, etc.), use ctx.state. KV interface — get / set / setWithTTL / delete / has / keys:

async decide(ctx) {
  const lastTradeMs = (await ctx.state.get<number>("lastTradeMs")) ?? 0;

  if (ctx.now - lastTradeMs < 3600_000) {
    return [{ kind: "noop", reason: "cooldown" }];
  }

  await ctx.state.set("lastTradeMs", ctx.now);
  return [/* your action */];
}

State scoping: each plugin has its own KV namespace (keyed by plugin.name). Default backend is in-memory and lost on restart — see Persistent state below to swap in SQLite/Postgres.

Auto-expiring entries (setWithTTL). For bans, cooldowns, dedupe windows, or any key that should not accumulate forever, use setWithTTL(key, value, ttlMs). The key behaves as missing after the TTL elapses — no cleanup pass needed:

// Ban this strike for 10 min — auto-expires
await ctx.state.setWithTTL(`ban:${marketId}:${strike}`, { reason: "EInvalidStrike" }, 10 * 60_000);

// Later, check presence — `has()` returns false automatically once TTL is over
if (await ctx.state.has(`ban:${marketId}:${strike}`)) {
  return [{ kind: "noop", reason: "still banned" }];
}

This works in the in-memory backend out of the box (lazy expiration on read). Custom backends (Drizzle/Postgres/SQLite) implement setWithTTL via an expires_at column.

Persistent state (when in-memory is not enough)

By default, ctx.state is in-memory only — lost on restart. Fine for stateless strategies, but most real plugins need to remember something across ticks (last trade timestamp, cooldown counters, custom schedules).

Recommended setup: Drizzle ORM. Cross-DB (Postgres, SQLite, MySQL, libSQL), TypeScript-first, no codegen step.

Where it lives: put your adapter in src/plugins/state.ts (or any file in src/plugins/) and export const stateFactory = ... from src/plugins/index.ts. The engine picks it up automatically — you never touch the engine files.

Quick taste with SQLite (zero infra):

// src/plugins/state.ts (new file)
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { sqliteTable, text, primaryKey } from "drizzle-orm/sqlite-core";
import { and, eq } from "drizzle-orm";
import type { PluginStateAPI } from "../engine/runtime-core";

const sqlite = new Database("./operator-state.db");
sqlite.exec(`CREATE TABLE IF NOT EXISTS plugin_state (
  plugin_name TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
  PRIMARY KEY (plugin_name, key))`);
const db = drizzle(sqlite);
const ps = sqliteTable("plugin_state", {
  pluginName: text("plugin_name").notNull(),
  key: text("key").notNull(),
  value: text("value").notNull(),
}, (t) => ({ pk: primaryKey({ columns: [t.pluginName, t.key] }) }));

export class DrizzleStateAPI implements PluginStateAPI {
  constructor(private pluginName: string) {}
  async get<T>(key: string) {
    const r = await db.select().from(ps)
      .where(and(eq(ps.pluginName, this.pluginName), eq(ps.key, key))).get();
    return r ? JSON.parse(r.value) as T : null;
  }
  async getOrDefault<T>(key: string, fallback: T) {
    return (await this.get<T>(key)) ?? fallback;
  }
  async set<T>(key: string, value: T) {
    await db.insert(ps).values({ pluginName: this.pluginName, key, value: JSON.stringify(value) })
      .onConflictDoUpdate({ target: [ps.pluginName, ps.key], set: { value: JSON.stringify(value) } });
  }
  async setWithTTL<T>(key: string, value: T, _ttlMs: number) {
    // Add an `expires_at` column + filter on read for real TTL support.
    await this.set(key, value);
  }
  async delete(key: string) {
    await db.delete(ps).where(and(eq(ps.pluginName, this.pluginName), eq(ps.key, key)));
  }
  async has(key: string) {
    const r = await db.select({ k: ps.key }).from(ps)
      .where(and(eq(ps.pluginName, this.pluginName), eq(ps.key, key))).get();
    return !!r;
  }
  async keys() {
    const rows = await db.select({ k: ps.key }).from(ps)
      .where(eq(ps.pluginName, this.pluginName)).all();
    return rows.map((r) => r.k);
  }
}
// src/plugins/index.ts — set the stateFactory
import { DrizzleStateAPI } from "./state";

export const stateFactory: (pluginName: string) => PluginStateAPI =
  (pluginName) => new DrizzleStateAPI(pluginName);

Done. Swap better-sqlite3 for pg and the schema for pgTable(... jsonb) to move to Postgres — same query API, same adapter shape.

What PluginStateAPI is NOT for

ctx.state is for the plugin's own memory — small, key-value, written and read every tick. It is not the right tool for:

  • Time series (historical prices, candles, signals) — define a richer Drizzle schema and query it directly in decide().
  • External feeds (social sentiment, on-chain analytics) — call your own pipeline from the plugin.
  • Cross-plugin coordination — each PluginStateAPI is scoped per plugin name; spin up a service if plugins truly need to share.

Reacting to outcomes (onExecuted hook)

decide() is one-way — it returns intent. The optional onExecuted hook closes the loop: the runtime calls it after trying to submit your actions, passing the outcome (digest, on-chain events, gas, errors). Use it to record trades, set cooldowns on success only, or schedule follow-ups.

async onExecuted(ctx, result) {
  if (result.outcome !== "submitted") return;

  // Persist every trade for audit, keyed by digest
  for (const ev of result.events ?? []) {
    if (ev.type.endsWith("::vault::VaultMintBinary")) {
      await ctx.state.set(`trade:${result.digest}`, ev.parsedJson);
    }
  }
}

What's in result:

Field When set What
outcome always "submitted" (chain ok) · "noop" (nothing to send) · "failed" (client error or Move abort)
actions always Echo of what decide() returned (for correlation)
digest tx reached chain Sui tx digest (correlate with indexer / Suiscan)
effectsStatus tx reached chain "success" or "failure"
events submitted All events the tx emitted (vault events + Predict events)
gasUsed submitted Total gas in MIST (computation + storage − rebate)
error failed Error message (client error string or Move abort code description)

Key contract guarantees:

  • Dry-runs skip the hook. The runtime never calls onExecuted (or onError) when the tick was a simulation — most hooks mutate state and a sim firing them would corrupt cooldowns / audit ledgers / ban TTLs.
  • Hook exceptions are caught and logged, never rethrown. Bad code here does not impact the next tick.
  • Hook is informational — the scheduler decides healthy/unhealthy from the outcome before the hook runs. The hook cannot swallow a failure.

Reacting to failures specifically (onError hook)

onExecuted runs for every outcome. If you only care about failures and don't want to branch on result.outcome everywhere, define onError instead — it fires only on failed ticks with a focused payload:

async onError(ctx, err) {
  if (err.phase === "client") {
    // RPC dropped / signer rejected / preflight tripped — transient
    ctx.logger.warn("transient error", { error: err.error });
    return;
  }
  // on-chain abort — has digest, has Move abort code in error string
  if (err.error.includes("EInvalidStrike")) {
    await ctx.state.set("strikeBanUntil", ctx.now + minutes(5));
  }
}

ErrorContext payload:

Field Type Notes
actions Action[] What you returned from decide() — correlate which action failed
phase "client" | "on-chain" client = RPC/signing/preflight failed; on-chain = Move abort
digest string? Set when phase === "on-chain"
error string Move abort code names (EPositionTooLarge, etc.) leak through

When both hooks are defined and the tick failed, onExecuted runs first, then onError. They share ctx (state writes from onExecuted are visible to onError).

Position lifecycle (ctx.lifecycle.closures)

Auto-populated per tick from on-chain events of the previous tick's submitted tx. No wiring needed — the data is just there, ready for the plugin to read, log, aggregate, or ship to attribution analytics.

async decide(ctx) {
  // Closures since the LAST tick — delta semantics. Empty on most ticks.
  for (const c of ctx.lifecycle.closures) {
    ctx.logger.info("closure", {
      kind: c.kind,                // "redeem" | "liquidation"
      marketId: c.marketId,
      quantityClosed: c.quantityClosed.toString(),
      quotePayout: c.quotePayout.toString(),
      remainingQuantity: c.remainingQuantity.toString(),   // 0 = fully closed
      pnlBps: c.pnlBps,            // undefined when entry mint wasn't tracked
    });
  }
  // ... rest of decide
}

What's covered:

  • Both partial and full closures. Move emits VaultRedeem* regardless of remaining_quantity; the tracker surfaces both. Discriminate with remainingQuantity === 0n if needed.
  • Both early exits and post-settle closes. Same Action (vault.redeemBinary) handles both; the closure event reports quotePayout as whatever the contract paid (live ask price pre-settle, settlement payout post-settle).
  • Forced liquidations during LP withdrawal flows (PositionLiquidated event) surface with kind: "liquidation", no PnL paired.
  • VWAP across multiple mints on the same key — the second mint updates the running average, the close uses VWAP × quantity for cost basis.

The tracker is in-memory only — opens registered before a process restart can't contribute PnL when their close happens later. The on-chain data is reconstructable from an indexer if forensics is needed.

Metrics & noop-streak detection

The runtime tracks per-plugin outcome counters (ticks, noop, submitted, failed) and a consecutive-noop streak. When a plugin runs the threshold of noops without ever acting (default 100), the runtime emits a single warn log — typically the first sign of "plugin is alive but stuck" (signal source is dead, cooldown never expires, condition never met). The warn is one-shot per streak — once any non-noop outcome happens, the streak resets and the next threshold crossing warns again.

Counters surface in /health (see below) and via runtime.getMetrics():

[
  {
    plugin: "my-strategy",
    counters: {
      ticks: 240, noop: 235, submitted: 4, failed: 1,
      consecutiveNoops: 120, lastTickMs: 1700_000_000_000,
    },
  },
]

Tune via Runner:

  • noopWarnThreshold (default 100, 0 disables warn — counters still tracked)

Vault cache (cross-plugin RPC dedup)

When multiple plugins target the same vault and tick in the same window, the runtime deduplicates the Vault.fetch RPC — first plugin fetches, others hit the cache. TTL is short by default (2s) and the cache is invalidated automatically after every submitted (non-dry-run) tx that touched that vault (state changed, next read must be fresh).

Scenario Vault.fetch RPCs
5 plugins on different vaults, same tick 5
5 plugins on the same vault, same tick, all read-only 1
5 plugins on the same vault, one submits a tx, others tick after 2 (first + post-submit refetch)
Anything in dry-run mode doesn't invalidate (sim didn't change chain)
Tick after TTL window fresh fetch

Tune via the vaultCacheTtlMs option on Runner (default 2000, set 0 to disable). The cache is a generic TTLCache<K, V> from the runtime core — you can reuse it in your plugins for other per-vault computed values that should survive a few ticks.

Market-level caching (Market.find, market.price()) is not done automatically — those are called inside decide() by the plugin. If you have many plugins repeatedly calling Market.find for the same asset in the same tick, wrap Market.find with a local TTLCache of your own.

Healthcheck HTTP endpoint

The daemon starts a tiny HTTP server alongside the runtime so K8s / Fly / Railway / your monitoring can probe it:

Path Status Body
GET /ready always 200 { ok, pid } — liveness probe (is the process up?)
GET /health 200 if all plugins healthy, 503 if any unhealthy { ok, plugins: [{name, state, metrics}], pid, uptimeSec } — readiness probe (is work happening?)

Port via HEALTH_PORT env (default 3030, set to 0 to disable). Binds 127.0.0.1 by default — set HEALTH_HOST=0.0.0.0 when the probe comes from outside the host (Kubernetes, Fly, Railway).

curl http://localhost:3030/health
# → { "ok": true, "plugins": [{ "name": "my-strategy", "state": "healthy",
#       "metrics": { "ticks": 240, "noop": 235, "submitted": 4, "failed": 1,
#         "consecutiveNoops": 120, "lastTickMs": 1700000000000 } }],
#     "pid": 12345, "uptimeSec": 600 }

A plugin that throws (or whose action aborts on-chain) is marked unhealthy by the scheduler; the next /health poll returns 503. The plugin auto-retries after 5 skipped ticks; until then, state: unhealthy.

Logging

ctx.logger emits structured JSON, one line per event. Pre-bound with your plugin name and vault id so you do not need to repeat them.

ctx.logger.info("decision", { signal: "long", strength: 0.8 });
ctx.logger.warn("oracle drift detected", { driftBps: 120 });

Levels: debug, info, warn, error. Control via LOG_LEVEL env var.

Auto-fund for mints (no more manual pmDeposit)

A mint on Predict pulls quote from the PredictManager. Without enough PM balance, the tx aborts. The runtime handles this for you by default: before submitting a batch with mints, it estimates the total cost via DevInspect, reads the PM's current balance, and prepends a pmDeposit sized to cover the gap (plus a 2% buffer).

The result: your plugin can just return:

return [
  {
    kind: "vault.mintBinary",
    params: { marketId, strike, isUp: true, quantity: 100_000_000n },
  },
];

And the runtime takes care of funding. No pmDeposit in your code, no manual cost math, no thinking about PM balance.

Auto-fund is skipped when:

  • autoFundMints: false in RunnerOptions (disable globally)
  • The batch already contains a manual vault.pmDeposit (you intentionally manage funding — runtime defers, no quote, no balance read)
  • The mint has skipAutoFund: true in its params (per-mint opt-out)
  • The action is not a mint (redeem/PLP/refresh don't trigger funding)

Important to know:

  • Latency: adds 1–2 DevInspect RPCs per tick that contains mints (~200–500 ms typical). Fine for cron ≥ 30s.
  • PM_DEPOSIT permission required: your vault must grant it. The preflight rejects with a clear local message if not.
  • Redeem doesn't auto-pmWithdraw: redeem proceeds land in PM. Append { kind: "vault.pmWithdraw", params: { amount } } manually when you want them back in the vault.
  • Large market impact: above roughly 5% of AUM the 2% buffer can under-fund. Tune autoFundBufferBps or opt out and pre-deposit yourself.

Tune via Runner options:

new Runner({
  ...,
  autoFundMints: true,        // default — set false to disable globally
  autoFundBufferBps: 200,     // default — 2% slippage buffer
});

Permission preflight (defense in depth)

The runtime always checks, before submitting, whether your vault grants each Action in your batch. If strategyPermissions lacks a bit, the batch is rejected locally — no gas, no chain mutation, no half-applied tx.

Error: executor: action "vault.mintBinary" not permitted by vault 0x...
       (strategy_permissions lacks MINT_BINARY). Granted: PM_DEPOSIT, PM_WITHDRAW.

The vault primitive also enforces permissions on-chain — this is defense in depth, not the security guarantee. The on-chain check is the real one; the local preflight is for fast, gas-free, informative feedback.

This guard runs whether you call ctx.vault.can* or not. What can* lets you do is adjust your decision ahead of time so a doomed Action doesn't even leave decide() — useful when you have a valid fallback to return instead:

if (!ctx.vault.canMintBinary) {
  return [{ kind: "noop", reason: "vault doesn't grant MINT_BINARY — skipping" }];
}

Permission map:

Action Permission bit required
vault.mintBinary MINT_BINARY
vault.mintRange MINT_RANGE
vault.redeemBinary REDEEM_BINARY
vault.redeemRange REDEEM_RANGE
vault.pmDeposit PM_DEPOSIT (also required for auto-fund)
vault.pmWithdraw PM_WITHDRAW
vault.supplyPlp SUPPLY_PLP
vault.withdrawPlp WITHDRAW_PLP
vault.refreshNavSnapshot none (permissionless)
vault.crystallizeFees none (permissionless)
noop none

Safety — what the vault guarantees

You can write simple, direct strategies because the vault primitive on Sui enforces hard limits on every operation, regardless of what your plugin asks for. Your action can:

  • Never exceed the position size cap (max_position_bps × NAV)
  • Never exceed the aggregate exposure cap
  • Never trigger during pause (vault or strategy)
  • Never bypass the permissions bitfield set at vault creation
  • Never drain capital (the operator key signs operations, but cannot custody — your wallet leaking means at worst trades within the existing risk caps, not capital loss)

You do not need to re-implement these checks in your plugin. If your plugin returns an action that violates a cap, the transaction reverts on-chain with a clear error code. Read the error, adjust, move on.

Idempotency tips

Two triggers can fire simultaneously (cron + manual, or overlapping ticks under load). Your decide() may run more than once for the same logical "decision moment". Two patterns to handle this:

  1. State guard. Record last_action_ts and skip if too recent:

    const last = (await ctx.state.get<number>("lastActionMs")) ?? 0;
    if (ctx.now - last < MIN_INTERVAL_MS) return [{ kind: "noop", reason: "too soon" }];
  2. Read fresh state. ctx.vault is always a fresh fetch. Use it to detect "did the previous tick already act?" — for example, check ctx.vault.aggregateExposure to see if your position is already open.

The runtime filters noop actions automatically — they get logged at debug level and no transaction is submitted.

CLI reference

Commands are pnpm scripts in package.json. Run them with pnpm <script>.

Command What it does Needs env?
start Start the daemon — registers plugins, opens the HTTP healthcheck, ticks cron triggers forever. SIGINT/SIGTERM clean shutdown (waits for the in-flight tick). Full (RPC + signer + plugin vars)
trigger <plugin> Fire ONE tick of one plugin. Real submission. Useful for ops/debug. Full
dry-run <plugin> Same as trigger but simulates via DevInspect — no signature, no gas, no chain mutation. Prints the decision it took. Full (signer needed to set sender, but never signs)
plugins List every plugin in the registry with its vault id + triggers. Shows ❌ for plugins that can't instantiate (missing per-plugin env vars). None — pure registry introspection
inspect <plugin> Read-only snapshot: wallet balance, vault state, granted permissions, plugin's persisted ctx.state keys. Use to answer "why isn't my plugin acting?" or "what state has it accumulated?". Full
show-config Print the resolved config (network, RPC URL, Predict refs, state backend) without secrets. Verifies env wiring before daemon start. RUNNER_PLUGINS (so it knows what to load)
typecheck tsc --noEmit over the whole app. None
test Node test runner over the engine tests. None

Naming note: start (not run), trigger (not exec), show-config (not config) and plugins (not list) because npm and pnpm have builtin commands with those names that would shadow the script — bare pnpm run lists scripts instead of starting anything, and npm exec <name> reaches for the npm registry. The verbose names are intentional — they always work.

Typical workflows

pnpm show-config            # 1. verify env is wired before starting
pnpm plugins                # 2. see what plugins this build registers
pnpm dry-run my-strategy    # 3. dry-run a new plugin before going live
pnpm inspect my-strategy    # 4. plugin not acting? inspect what it sees now
pnpm trigger my-strategy    # 5. force a single real tick for ops debugging
pnpm start                  # 6. start the daemon

Debugging

Dry-run a plugin (no gas, no chain mutation)

pnpm dry-run my-strategy

Runs decide() once, then simulates the resulting transaction via devInspectTransactionBlock — no signature, no gas, no on-chain state change. You see exactly what would happen (events that would emit, gas estimate, Move abort if any), without burning anything.

Hooks are skipped on dry-runs, and a dry-run failure does not mark the plugin unhealthy — a simulation that would have aborted is feedback, not a production incident. The command prints the decision it took (actions, noop reasons, gas estimate) regardless of LOG_LEVEL; for anything deeper, log inside decide().

Trace what is happening

LOG_LEVEL=debug pnpm start

Every tick, decision reason, action submitted, and transaction digest gets logged.

Common errors

Error Likely cause
PLUGIN_<NAME>_VAULT_ID not set Set the per-plugin env var
loader: plugin "X" not registered Add the factory to src/plugins/index.ts
executor: action "X" not permitted by vault Y (strategy_permissions lacks Z) Local preflight rejection — your plugin returned an Action the vault doesn't grant. Check vault.canMintBinary etc. before returning. No gas was spent.
EFairPriceAlreadySettled Strike is degenerate for the oracle — pick another strike
EPositionTooLarge Quantity exceeds max_position_bps of NAV
EExposureLimitExceeded Aggregate exposure cap hit
ETradingPaused Predict-wide pause; wait it out

Overriding defaults

The agent pulls sensible defaults from @automark/sdk/network based on SUI_NETWORK. Override any field via env var when you need a custom endpoint or a forked deployment:

SUI_RPC_URL=https://your-quicknode-url   # custom fullnode (latency, rate-limit)
PREDICT_PACKAGE_ID=0x...                  # forked or unreleased Predict
PREDICT_OBJECT_ID=0x...                   # forked Predict shared object

Anything you leave unset falls back to the network defaults. The full list of network-pinned constants lives in @automark/sdk/network.

Project layout

src/
├── plugins/                # ← YOUR ZONE  (edit freely)
│   ├── index.ts            # registry + optional stateFactory
│   └── my-strategy/        # your plugin (one folder per plugin)
│       └── index.ts
│
└── engine/                 # ← ENGINE  (don't touch)
    ├── bin/                # CLI entrypoints (run, exec, dry-run, ...)
    ├── runtime-core/       # vendored execution core (scheduler, signer, executor)
    ├── __tests__/          # engine tests
    ├── config.ts           # env var loader
    ├── loader.ts           # name → factory resolver
    ├── runtime.ts          # orchestrator (Scheduler + Executor)
    └── index.ts            # barrel

Two zones, two intents:

Zone What it does Edit? What survives updates?
plugins/ Your strategies + optional persistent state backend Yes — only place you change Your files
engine/ Scheduler, executor, signer, RPC client, lifecycle No — pull updates straight in Always upgrades cleanly

Add a new plugin file in plugins/, add one line in plugins/index.ts, done. Everything else is infrastructure — leave it alone and pulling new versions of the agent is a no-conflict merge.

Reference plugins (roadmap)

Beyond the starter, src/plugins/ stays deliberately empty so you build from your own thesis. A numbered set of reference plugins is on the way: a learning curve from "hello world" to a production-shaped plugin, each introducing one or two new patterns on top of the previous.

Planned curve:

Tier What it covers
Basics (01–05) Minimum shape → read ctx.vaultMarket.find + live price → fetch an external signal → first real mintBinary
Intermediate (06–10) Size against risk caps → cooldown via ctx.state → combined signal+sized+cooldown → multi-source consensus → onExecuted cooldown-on-success
Advanced (11–15) Multi-action atomic tx → failure-aware strike bans → multi-trigger (cron + event) → conviction-weighted sizing + audit ledger → full pipeline
Extra (16) Plugin as a folder with a co-located Drizzle DB (moving-average over a price-history table)

Until they land, the inline snippets throughout this README cover every pattern: decide() shape, market discovery, strike helpers, state, hooks, lifecycle closures, and multi-action batches.

License

MIT.

About

Run your vault strategy off-chain. Decide when to mint, redeem, or rebalance, and let the runner sign and submit the transactions for you.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors