Appearance
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`)
breakValidation rules to follow
| Rule | Why |
|---|---|
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 addrField | And the existing generic check then confirms it is in allowTokens |
| Bound every window and percentage | windowHours ≤ 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 array | The 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)
breakEvaluator rules
| Rule | Why |
|---|---|
| Pure function, no I/O | The 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 liberally | Missing price, missing history, inside cooldown → null. Never guess |
Always populate trigger | It is the audit record: "fired because rule X with snapshot Y". Every trade must be explainable |
| Address-keyed prices | priceForAddr(ctx, addr) lowercases. Symbols are not unique per chain |
| Include an anti-hammer guard | See 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 orderAlso run:
bash
node scripts/test-policy.js # validation + hashing
node scripts/test-wallet-auth.js # digest contractWhen a contract change is needed
Only if the trigger needs a new on-chain guarantee. Examples:
| Need | Contract change |
|---|---|
| A per-strategy spend cap enforced on-chain | New Limits field — a storage change, so a UUPS upgrade with layout validation |
An oracle-based minOut floor | Substantial; also closes residual audit finding H-1 |
Metering non-budgetToken outflows in USD | Needs 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 firesVerifying 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.