Skip to content

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.

PieceFileResponsibility
Policy toolkitbz-backend/utils/policy.jsCanonicalise, hash, verify signature, validate structure
Frontend mirrordefi-dex/src/utils/autopilotPolicy.tsByte-for-byte identical, so the FE computes the same hash
Trigger enginebz-backend/services/autopilotEngine.jsPure decision layer — one trade intent per agent per tick
Price contextbz-backend/services/autopilotPrices.jsCoinMarketCap batch → Redis rolling series
Chain syncbz-backend/services/autopilotSync.jsMirror on-chain AgentAccount into Mongo
Orchestrationbz-backend/services/autopilotCron.jsBuild 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']
FieldRule
version=== 1
walletValid address
chainIdPositive integer
budgetTokenValid address, or AddressZero for native
limits.maxPerTradeUsd> 0
limits.maxPerDayUsd>= maxPerTradeUsd
limits.maxTradesPerDayNon-negative integer if present
limits.expiryFuture unix ts, and within 180 days
allowTokensNon-empty array, ≤ 20, no duplicates, must include budgetToken
safety.minTokenScore0–100 if present
slippageBpsInteger 10–300 if present
strategiesNon-empty array, ≤ 10, unique ids, valid type
Every token a strategy touchesMust 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

TypeFields and rules
dcafromToken, toToken addresses; amount a positive numeric string; cadenceSeconds integer >= 3600
dip_buybuyToken address; dropPct in (0, 90]; windowHours integer 1–168; amount positive numeric string; optional cooldownHours positive integer
take_profitpositionToken address; targetPct > 0; sellPortionBps integer 1–10000
stop_losspositionToken address; dropPct > 0; sellPortionBps integer 1–10000
rebalancetargetWeights 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 | null

Context 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 null

Policy 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 null

The 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 = 3600

The 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

GuardWhereBehaviour
Policy hash mismatchCronRefuses to act — the DB policy is not the one the user signed
Safety scoreCronTrusted majors skipped; unscored tokens block, fail-closed
Route qualityExecution engineCalldata guard + on-chain probe
Hard limitsContractmaxPerTrade, maxPerDay, maxTradesPerDay, expiry, token allow-list, router allow-list
Per-agent serialisationEngineAt 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

SuiteCommandCount
Policy toolkitnode scripts/test-policy.js
Wallet authnode scripts/test-wallet-auth.js
Trigger enginenode scripts/test-autopilot-engine.js13 passing
Contractnpx hardhat test test/autopilot.test.cjs61 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_buy depends on the Redis rolling series. A Redis flush loses history, and maxInWindow then 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.
  • rebalance is 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 updatePolicy requires the owner's signature.

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