Appearance
Policy engine
Two cooperating pieces: the policy toolkit that canonicalises, hashes and validates a policy document, and the trigger engine that decides whether a policy fires this tick.
| Piece | File | Responsibility |
|---|---|---|
| Policy toolkit | bz-backend/utils/policy.js | Canonicalise, hash, verify signature, validate structure |
| Frontend mirror | defi-dex/src/utils/autopilotPolicy.ts | Byte-for-byte identical, so the FE computes the same hash |
| Trigger engine | bz-backend/services/autopilotEngine.js | Pure decision layer — one trade intent per agent per tick |
| Price context | bz-backend/services/autopilotPrices.js | CoinMarketCap batch → Redis rolling series |
| Chain sync | bz-backend/services/autopilotSync.js | Mirror on-chain AgentAccount into Mongo |
| Orchestration | bz-backend/services/autopilotCron.js | Build context, gate on safety, route, submit, notify |
Canonicalisation and hashing
The policy is the user's signed source of truth. Its hash lives on-chain, so serialisation must be deterministic.
js
function sortDeep(value) {
if (Array.isArray(value)) return value.map(sortDeep) // order preserved
if (value && typeof value === 'object') {
const out = {}
for (const key of Object.keys(value).sort()) out[key] = sortDeep(value[key])
return out
}
return value
}
canonicalizePolicy(p) → JSON.stringify(sortDeep(p))
hashPolicy(p) → keccak256(toUtf8Bytes(canonicalizePolicy(p)))Three rules: recursively sorted object keys, no whitespace, arrays in author order. Array order is preserved on purpose — strategy precedence depends on it.
Addresses are lowercased during validation so checksum casing cannot change the hash.
The frontend mirror must stay byte-identical
defi-dex/src/utils/autopilotPolicy.ts mirrors both policy.js and middleware/walletAuth.js. If they drift, the frontend computes a hash the backend rejects and every creation fails with no obvious cause. Change them in the same commit, always.
Signature binding
js
policySignMessage(policyHash, agentId, chainId)
// → `Blazpay Autopilot policy ${policyHash} for agent ${agentId} on chain ${chainId}`agentId is the string 'new' before creation. Verification:
js
verifyPolicySignature({ policyHash, agentId, chainId, wallet, signature })
// ethers.utils.verifyMessage(message, signature).toLowerCase() === wallet.toLowerCase()Including agentId and chainId in the message prevents replaying a signature onto a different agent or a different chain.
Validation
validatePolicy(input) returns { ok: true, policy } with addresses lowercased, or { ok: false, errors: string[] }. It checks structure and sanity only — not prices or balances, which are runtime concerns of the engine and the contract.
Global limits
js
const MAX_ALLOW_TOKENS = 20
const MAX_STRATEGIES = 10
const MAX_EXPIRY_DAYS = 180
export const STRATEGY_TYPES = ['dca','dip_buy','take_profit','stop_loss','rebalance']| Field | Rule |
|---|---|
version | === 1 |
wallet | Valid address |
chainId | Positive integer |
budgetToken | Valid address, or AddressZero for native |
limits.maxPerTradeUsd | > 0 |
limits.maxPerDayUsd | >= maxPerTradeUsd |
limits.maxTradesPerDay | Non-negative integer if present |
limits.expiry | Future unix ts, and within 180 days |
allowTokens | Non-empty array, ≤ 20, no duplicates, must include budgetToken |
safety.minTokenScore | 0–100 if present |
slippageBps | Integer 10–300 if present |
strategies | Non-empty array, ≤ 10, unique ids, valid type |
| Every token a strategy touches | Must appear in allowTokens |
That last rule is checked by collecting fromToken, toToken, buyToken, positionToken and every targetWeights key from each strategy and testing them against the allow-list — so a strategy cannot smuggle in an unlisted token.
Per-strategy rules
| Type | Fields and rules |
|---|---|
dca | fromToken, toToken addresses; amount a positive numeric string; cadenceSeconds integer >= 3600 |
dip_buy | buyToken address; dropPct in (0, 90]; windowHours integer 1–168; amount positive numeric string; optional cooldownHours positive integer |
take_profit | positionToken address; targetPct > 0; sellPortionBps integer 1–10000 |
stop_loss | positionToken address; dropPct > 0; sellPortionBps integer 1–10000 |
rebalance | targetWeights map address→bps that must sum to exactly 10000; bandBps integer >= 50 |
Amounts are strings, not numbers
isPosNumStr requires /^\d+(\.\d+)?$/. That avoids float precision drift between the frontend's hash input and the backend's, which would break the hash for large or high-decimal amounts.
The trigger engine
autopilotEngine.js is a pure decision layer: no I/O, no LLM. The cron builds the market context; the engine returns at most one trade intent per agent per tick.
js
evaluateAgent(doc, ctx) → TradeIntent | nullContext shape
js
ctx = {
nowMs,
prices: { addrLower: usdPrice }, // ADDRESS-keyed
tokenMeta: Map<addrLower, { symbol, decimals }>,
maxInWindow: async (address, hours) => usd, // rolling-series high
}Prices are keyed by address, not symbol
Symbols are not unique per chain — there are many tokens called "USDC". Every lookup goes through priceForAddr(ctx, addr) on a lowercased address.
Evaluation order
js
for (const strategy of policy.strategies) {
const intent = evaluate<Type>(doc, strategy, ctx, nowMs)
if (intent) return intent // first firing strategy wins
}
return nullPolicy order decides. The wizard and the AI drafter should place protective strategies (stop_loss, take_profit) first, so a protective rule pre-empts an accumulation rule on the same tick.
Two pre-checks short-circuit before any strategy runs: no strategies, or policy.limits.expiry already passed.
dca
js
const lastMs = lastFillMs(doc, strategy.id)
const dueMs = lastMs ? lastMs + strategy.cadenceSeconds * 1000 : 0
if (nowMs < dueMs) return nullThe first cycle fires immediately on activation, matching the DCA contract's behaviour where the first buy is due at depositTime. Then every cadenceSeconds.
A failed attempt does not advance lastFillAt
So a missed cycle retries on the next tick rather than being skipped. That is deliberate: a transient aggregator failure should not cost the user a scheduled buy.
dip_buy
Buys amount of budgetToken worth of buyToken when its price is at least dropPct below its rolling high over windowHours, respecting cooldownHours.
The rolling high comes from ctx.maxInWindow(address, hours), backed by the Redis series that autopilotPrices.js maintains from batched CoinMarketCap reads.
take_profit and stop_loss
Both compare current price against a VWAP of the agent's own fills — not against a global average entry, and not against the policy creation price. That means the trigger reflects what this agent actually paid.
js
const TP_SL_REFIRE_SECONDS = 3600The anti-hammer guard is load-bearing
Once a partial sell fires, the same strategy will not re-fire for an hour. Without it, a price wobbling around the threshold triggers a laddered sell on every 60-second tick — which is a fee-and-slippage machine, not a strategy.
sellPortionBps makes these partial by default: 2500 bps sells a quarter of the position.
rebalance
Compares current weights against targetWeights and acts when drift exceeds bandBps. Rebalancing is pairwise — one over-weight token sold into one under-weight token per trade — rather than a single multi-leg transaction.
Per-tick orchestration
autopilotCron.js:
Guards outside the engine
| Guard | Where | Behaviour |
|---|---|---|
| Policy hash mismatch | Cron | Refuses to act — the DB policy is not the one the user signed |
| Safety score | Cron | Trusted majors skipped; unscored tokens block, fail-closed |
| Route quality | Execution engine | Calldata guard + on-chain probe |
| Hard limits | Contract | maxPerTrade, maxPerDay, maxTradesPerDay, expiry, token allow-list, router allow-list |
| Per-agent serialisation | Engine | At most one intent per agent per tick |
Containing LLM hallucination
The AI path never produces a signable policy directly:
The model is deliberately not allowed to emit addresses. A hallucinated token name resolves to nothing; a malformed policy fails validation. v1/services/autopilot.service.js → handleAutopilot's read-and-guide sub-actions (status, pause, resume, close, help) never auto-execute.
Testing
| Suite | Command | Count |
|---|---|---|
| Policy toolkit | node scripts/test-policy.js | — |
| Wallet auth | node scripts/test-wallet-auth.js | — |
| Trigger engine | node scripts/test-autopilot-engine.js | 13 passing |
| Contract | npx hardhat test test/autopilot.test.cjs | 61 passing |
autopilotEngine.js exports _internals = { evaluateDca, evaluateRebalance, TP_SL_REFIRE_SECONDS, NATIVE } specifically so the test script can reach the private evaluators.
Known limits
dip_buydepends on the Redis rolling series. A Redis flush loses history, andmaxInWindowthen returns a low high, suppressing dips until the window refills.- Prices come from one source (CoinMarketCap). No cross-check.
- The engine returns one intent per tick per agent, so an agent with both a stop-loss and a rebalance due acts on the first only.
rebalanceis pairwise, so a badly drifted three-token portfolio needs several ticks to converge.- Policy hash verification is off-chain. The contract stores the hash but does not compare it per trade — the engine does. A compromised backend could act on a different policy while the on-chain hash stayed stale, which is why
updatePolicyrequires the owner's signature.