Skip to content

Add an Autopilot trigger

Adding a strategy type to the Autopilot policy. This touches a hash-sensitive schema and a byte-for-byte frontend mirror, so the order matters more than usual.

Architecture: policy engine, Autopilot vault.

What you are adding

A strategy type — a rule the engine evaluates each tick to decide whether to trade. The five existing ones are dca, dip_buy, take_profit, stop_loss and rebalance.

Most triggers need no contract change

The contract enforces limits (spend caps, token allow-list, expiry, router allow-list). It knows nothing about strategies — it only receives a TradeParams struct. So a new trigger is a backend-plus-frontend change unless it needs a new on-chain guarantee.

Step 1 — extend STRATEGY_TYPES

bz-backend/utils/policy.js

js
export const STRATEGY_TYPES = [
  'dca', 'dip_buy', 'take_profit', 'stop_loss', 'rebalance',
  'my_trigger',
]

Step 2 — add validation

Same file, in validatePolicy's strategy switch:

js
case 'my_trigger':
  addrField('watchToken')
  if (!isPosNumStr(st.amount))
    errors.push(`${tag}.amount must be a positive numeric string`)
  if (!(Number(st.thresholdPct) > 0 && Number(st.thresholdPct) <= 100))
    errors.push(`${tag}.thresholdPct must be 0-100`)
  if (!isPosInt(st.windowHours) || st.windowHours > 168)
    errors.push(`${tag}.windowHours must be 1-168`)
  break

Validation rules to follow

RuleWhy
Amounts are positive numeric strings (isPosNumStr)Float precision drift between the FE's hash input and the BE's would change the keccak256, so the signature would not verify
Every token field goes through addrFieldAnd the existing generic check then confirms it is in allowTokens
Bound every window and percentagewindowHours ≤ 168 (one week) and cadenceSeconds ≥ 3600 exist so a policy cannot ask for a 1-second cadence or an unbounded lookback
Add token fields to the touched arrayThe allow-list cross-check at the end of the loop collects fromToken, toToken, buyToken, positionToken and targetWeights keys — a new field name must be added there or it escapes the check

Add your token field to the touched collection

js
const touched = [st.fromToken, st.toToken, st.buyToken, st.positionToken,
  st.watchToken,                                    // ← add it
  ...(st.targetWeights ? Object.keys(st.targetWeights) : [])].filter(Boolean)

Without it, a strategy can reference a token the user never allow-listed. The contract would still reject the trade (TokenNotAllowed), but the user would get a signable policy that can never execute — a worse failure than a validation error.

Step 3 — normalise addresses

Still in validatePolicy, in the normalisation block at the end:

js
for (const f of ['fromToken', 'toToken', 'buyToken', 'positionToken', 'watchToken'])
  if (c[f]) c[f] = c[f].toLowerCase()

Lowercasing is part of the hash contract

Checksum casing must not change the keccak256. Miss this and a policy signed from a checksummed address produces a different hash than the same policy signed from a lowercase one.

Step 4 — write the evaluator

bz-backend/services/autopilotEngine.js

js
function evaluateMyTrigger(doc, strategy, ctx, nowMs) {
  const price = priceForAddr(ctx, strategy.watchToken)
  if (!price) return null                          // no price → do not act

  const lastMs = lastFillMs(doc, strategy.id)
  const cooldownMs = (strategy.cooldownHours ?? 1) * 3600_000
  if (lastMs && nowMs - lastMs < cooldownMs) return null

  const high = await ctx.maxInWindow(strategy.watchToken, strategy.windowHours)
  if (!high) return null

  const changePct = ((price - high) / high) * 100
  if (Math.abs(changePct) < strategy.thresholdPct) return null

  return intentFor(doc, strategy, {
    fromToken: doc.policy.budgetToken,
    toToken:   strategy.watchToken,
    amountHuman: strategy.amount,
    isBuy: true,
    trigger: {
      rule: `my_trigger ${strategy.thresholdPct}% over ${strategy.windowHours}h`,
      price, high, changePct,
    },
  })
}

Then add the case:

js
case 'my_trigger':
  intent = await evaluateMyTrigger(doc, strategy, ctx, nowMs)
  break

Evaluator rules

RuleWhy
Pure function, no I/OThe cron builds ctx (prices, holdings, maxInWindow) and does execution, safety and routing. Keeping the evaluator pure is what makes it unit-testable without a chain
Return null liberallyMissing price, missing history, inside cooldown → null. Never guess
Always populate triggerIt is the audit record: "fired because rule X with snapshot Y". Every trade must be explainable
Address-keyed pricespriceForAddr(ctx, addr) lowercases. Symbols are not unique per chain
Include an anti-hammer guardSee below

An anti-hammer guard is not optional for sell triggers

take_profit and stop_loss use TP_SL_REFIRE_SECONDS = 3600. Without it, a price wobbling around the threshold triggers a laddered sell on every 60-second tick — a fee-and-slippage machine, not a strategy.

For buy triggers, cooldownHours serves the same purpose.

Precedence is policy order, and the first firing strategy wins

So where your trigger sits in the array matters. Protective strategies belong first. If your trigger is protective, say so in the wizard's ordering.

Step 5 — mirror it on the frontend

defi-dex/src/utils/autopilotPolicy.ts must stay byte-identical

It is a deliberate byte-for-byte mirror of bz-backend/utils/policy.js plus middleware/walletAuth.js. If they drift, the frontend computes a hash the backend rejects and every Autopilot creation fails with no obvious cause.

Change both in the same commit. Copy the validation block verbatim; do not "improve" it on one side.

Step 6 — extend the wizard

defi-dex/src/component/defi/autopilot/ — the creation wizard needs:

  • A form for your strategy's fields.
  • Sensible defaults and inline bounds matching the validator.
  • Correct ordering relative to the other strategies.

Also extend the type definitions in defi-dex/src/types/.

Step 7 — extend the AI drafter

bz-backend/v1/services/autopilotPolicy.service.js → buildPolicyDraft

The drafting prompt needs to know your strategy type exists, and what fields it takes.

The LLM must emit symbols and numbers only

It resolves nothing itself — the backend maps symbols to addresses and then runs validatePolicy. That is the whole containment mechanism: an LLM cannot produce a signable policy. Do not add a path where the model emits an address.

Step 8 — test

bz-backend/scripts/test-autopilot-engine.js — 13 tests currently passing.

js
// The engine exports its private evaluators for exactly this purpose:
export const _internals = { evaluateDca, evaluateRebalance, TP_SL_REFIRE_SECONDS, NATIVE }

Add evaluateMyTrigger to _internals and write cases for:

□ Fires when the condition is met
□ Does not fire below the threshold
□ Does not fire inside the cooldown
□ Returns null when the price is missing
□ Returns null when maxInWindow returns nothing
□ Populates `trigger` with the snapshot
□ Loses to an earlier strategy in policy order

Also run:

bash
node scripts/test-policy.js        # validation + hashing
node scripts/test-wallet-auth.js   # digest contract

When a contract change is needed

Only if the trigger needs a new on-chain guarantee. Examples:

NeedContract change
A per-strategy spend cap enforced on-chainNew Limits field — a storage change, so a UUPS upgrade with layout validation
An oracle-based minOut floorSubstantial; also closes residual audit finding H-1
Metering non-budgetToken outflows in USDNeeds a price oracle — deliberately avoided today

A contract change is a much bigger job

Storage layout must be validated (upgrades.validateUpgrade), the ABI re-exported to both repos, tests extended (test/autopilot.test.cjs, 61 passing), and the proxy upgraded. See contracts & upgrades.

Most triggers do not need one. Check before assuming.

Full checklist

□ policy.js: STRATEGY_TYPES extended
□ policy.js: validation case added, with bounds
□ policy.js: token field added to the `touched` array
□ policy.js: token field added to address normalisation
□ autopilotEngine.js: evaluator written (pure, returns null liberally)
□ autopilotEngine.js: case added to the switch
□ autopilotEngine.js: evaluator exported in _internals
□ Anti-hammer / cooldown guard present
□ `trigger` snapshot populated
□ autopilotPolicy.ts: mirrored BYTE-FOR-BYTE
□ Wizard form + defaults + ordering
□ Frontend types extended
□ buildPolicyDraft prompt extended (symbols + numbers only)
□ test-autopilot-engine.js cases added, all passing
□ test-policy.js still passing
□ A policy with the new strategy hashes identically on FE and BE
□ End-to-end: draft → sign → create → the trigger fires

Verifying the hash contract

The single most valuable check. Compute the hash of the same policy on both sides:

js
// backend
import { hashPolicy } from './utils/policy.js'
console.log(hashPolicy(policy))
ts
// frontend, in a browser console
import { hashPolicy } from './utils/autopilotPolicy'
console.log(hashPolicy(policy))

They must be identical strings. If they are not, the mirror has drifted and nothing else you test matters.

Internal + developer documentation. Usage figures are point-in-time snapshots — re-verify before publishing them.