Appearance
Autopilot vault
The contract that makes autonomous trading trustworthy. A user deposits a budget and signs a policy; the executor may trade — but only inside limits the contract enforces, only tokens the user allow-listed, and only through routers the owner curated.
Contract: BlazpayAutopilotV1 · Pattern: UUPS
Source: bz-backend/contract-deployment/contracts/autopilot/{BlazpayAutopilotV1,AutopilotTypes}.sol
Tests: contract-deployment/test/autopilot.test.cjs — 61 passing
| Chain | Proxy | Implementation |
|---|---|---|
| Arbitrum (42161) | 0x689AaEc7E3012d84226e7c5F4DEeFdC01C86d23e | 0x7bF6f14c53BB18BC5D5cd34c9A81c8fb658bB168 |
| Robinhood (4663) | 0x4dE7CD522f1715b2a48F3ad6612924841d450A0F | 0xa75A5bEE…16d7D |
| Arbitrum Sepolia (421614) | 0x7cA5A09E59e8d84e678854184F67D8aF505EfF9f | — |
Owner and deployer 0x2Ed05570214f6C0F7612B580aB37C163076e0162. Executor 0x5935745431D1385ae1a9CE2644fb5f5d1f140459. feeBps 20.
Types
solidity
struct Limits {
uint128 maxPerTrade; // budgetToken units, single buy
uint128 maxPerDay; // budgetToken units, rolling 24h
uint64 expiry; // executeTrade reverts at/after
uint16 maxTradesPerDay; // 0 = unlimited
}
struct AgentAccount {
address owner; // only they withdraw / edit
address budgetToken; // address(0) == native
bool active; // owner pause switch
bytes32 policyHash; // keccak256 of the canonical policy JSON
Limits limits;
uint128 spentToday;
uint64 dayStart;
uint16 tradesToday;
}
struct TradeParams {
uint256 agentId;
address fromToken; // address(0) == native
address toToken;
uint256 amountIn;
uint256 minOut; // revert if measured output < this
address targetContract; // aggregator router to .call
address spender; // approval target, may differ
bytes data; // router calldata
}Limits is packed into two slots. Agent ids are global, from nextAgentId — unlike DCA's per-wallet positionId.
Function surface
| Function | Access | Purpose |
|---|---|---|
initialize(...) | initializer | |
createAgent(...) | anyone | Create an agent with budget token, limits, allowed tokens and policy hash |
deposit(...) | anyone | Fund an agent |
withdraw(...) | agent owner | Partial withdrawal |
withdrawAllAndClose(...) | agent owner | Full exit |
setActive(...) | agent owner | Pause / resume |
updatePolicy(...) | agent owner | New policy hash |
executeTrade(...) | executor | One trade |
batchExecuteTrades(...) | executor | Many, with failure isolation |
_executeTradeGuarded(...) | onlySelf | The isolation mechanism — see below |
getAgent, getBalance, getAgentTokens, isTokenAllowed, getAgentBalances | view | |
setExecutor(address) | owner | |
setFeeBps(uint16) | owner | |
setRouter(address, bool) / setRouters(address[], bool) | owner | Router allow-list |
withdrawEarnings(...) | owner | Sweep platformEarnings |
pause() / unpause() | owner | |
_authorizeUpgrade | owner |
The trade path
_doTrade is where every guarantee lives. In order:
The router allow-list
solidity
if (!allowedRouter[p.targetContract])
revert RouterNotAllowed(p.targetContract);
if (p.spender != p.targetContract && !allowedRouter[p.spender])
revert RouterNotAllowed(p.spender);This is the security boundary, and it was added after a critical audit finding
C-1 (critical), found 2026-07-25. Before the allow-list, the executor supplied an arbitrary targetContract plus calldata. Because the vault is msg.sender on that call, a compromised executor could point it at any ERC-20 the vault held with transfer(attacker, all) calldata and drain every agent's pooled funds.
The pre-existing data.length > 68 guard did not help — calldata is trivially padded past 68 bytes.
The fix was deployed as a UUPS upgrade to the same proxy address with state preserved, and the new implementation is Arbiscan-verified. Contract tests gained an explicit drain-block case.
The list is fail-safe: until the owner calls setRouters([...], true), allowedRouter is empty and no trade can execute at all. That is why a freshly deployed vault appears broken — see status matrix.
The remaining checks (target != fromToken, target != address(this), data.length > 68) are explicitly labelled defence in depth, no longer the security boundary.
Rolling window
solidity
if (block.timestamp >= uint256(a.dayStart) + WINDOW) {
a.dayStart = uint64(block.timestamp);
a.spentToday = 0;
a.tradesToday = 0;
}dayStart == 0 on the first-ever trade rolls the window to now immediately. It is a rolling 24 hours from first trade, not a calendar day.
Spend metering — and its known gap
solidity
bool isBudgetSpend = (p.fromToken == a.budgetToken);
if (isBudgetSpend) {
if (p.amountIn > a.limits.maxPerTrade) revert ExceedsMaxPerTrade(…);
if (spentToday + p.amountIn > a.limits.maxPerDay) revert ExceedsMaxPerDay(…);
}Only budgetToken outflows (buys) count against the caps. Selling back to budgetToken is risk-reducing and is bounded off-chain, which keeps the contract oracle-free.
This is audit finding H-1, accepted for launch
A token-to-token trade that does not involve budgetToken is not metered by maxPerTrade or maxPerDay — only by maxTradesPerDay. It is bounded by the agent's own holdings and the allow-listed token set, and the funds stay in the agent. Metering it properly would require a price oracle, which is a deliberate scope decision.
Measured output and measured spend
solidity
uint256 received = _selfBalance(p.toToken) - toBefore;
if (received < p.minOut) revert OutputBelowMinimum(received, p.minOut);
uint256 spent = fromBefore - _selfBalance(p.fromToken);Debiting the measured spend rather than the authorised amountIn was audit finding M-1. A router that fills partially, or refunds native, would otherwise strand the difference as unallocated surplus the user could not recover. spent is bounded above by amountIn because of the exact allowance and the native callValue cap.
Fee
solidity
uint256 fee = (received * feeBps) / 10_000; // 20 bps
uint256 net = received - fee;
if (fee > 0) platformEarnings[p.toToken] += fee;Charged on output, per token, accumulated in the vault. Swept with withdrawEarnings.
Allowance hygiene
forceApprove(spender, amountIn) before the call, forceApprove(spender, 0) after. Exact allowance per trade, never lingering.
Failure isolation
solidity
function batchExecuteTrades(TradeParams[] calldata ps) external onlyExecutor {
for (…) {
try this._executeTradeGuarded(ps[i]) { }
catch (bytes memory reason) { emit TradeFailed(ps[i].agentId, reason); }
}
}
function _executeTradeGuarded(TradeParams calldata p) external onlySelf {
_doTrade(p);
}The external onlySelf indirection exists because Solidity's try/catch only works on external calls. One reverting agent emits TradeFailed and the batch continues — a single bad route cannot starve every other agent in the tick.
Resilient close
withdrawAllAndClose uses _externalPayout with try/catch per token, so one broken token cannot brick the exit. That was audit finding M-2: a token whose transfer reverts (blacklist, pause, non-standard behaviour) would otherwise trap the whole position.
Errors
solidity
AgentNotFound(agentId) NotAgentOwner(agentId, caller)
NotAuthorized(caller) AgentInactive(agentId)
PolicyExpired(agentId) InvalidTrade()
TokenNotAllowed(agentId, token) InvalidAmount()
InsufficientAgentBalance(agentId, token)
RouterNotAllowed(router) InvalidTarget(target)
InvalidCalldata() ExceedsMaxPerTrade(a, max)
ExceedsMaxPerDay(a, max) ExceedsMaxTradesPerDay(max)
OutputBelowMinimum(actual, minimum) RouterCallFailed()Events
solidity
AgentCreated(agentId, owner, budgetToken, policyHash)
Deposited / Withdrawn / AgentClosed / AgentActiveSet / PolicyUpdated
TradeExecuted(agentId, fromToken, toToken, amountIn, amountOut /*net*/, fee, policyHash)
TradeFailed(agentId, reason)
TokenAllowed / ExecutorUpdated / FeeUpdated / RouterSet / EarningsWithdrawnTradeExecuted carrying policyHash is deliberate: the on-chain event record proves which policy version authorised each trade.
Security audit summary
Slither ran clean (false positives only). An adversarial review found four issues:
| ID | Severity | Issue | Status |
|---|---|---|---|
| C-1 | Critical | Arbitrary targetContract → executor could drain all pooled funds | Fixed — router allow-list |
| H-1 | High | Only budgetToken outflows metered | Accepted, documented |
| M-1 | Medium | Debited amountIn, not measured spend → stranded surplus | Fixed |
| M-2 | Medium | One bad token bricked withdrawAllAndClose | Fixed — resilient try/catch payout |
Residual, accepted for launch with a platform-held executor: a compromised executor can still force bad-slippage churn on an agent's own positions through allow-listed routers. Bounded by liquidity; funds return to the agent, not the attacker. An oracle-based minOut floor would close it and is a candidate for a future UUPS upgrade.
ABIs
Exported to both consumers after every upgrade (39 functions currently):
bz-backend/utils/abi/autopilotAbi.json
defi-dex/src/constants/abis/autopilot.jsonKnown limits
- Owner EOA is not a multisig, and it holds upgrade authority.
- Routers must be seeded per chain. Robinhood Chain has none allow-listed. Use
contract-deployment/scripts/probe-autopilot-routers.cjsto find which routers deliver in-transaction output there. - Oracle-free by design, which is what leaves H-1 open.
- Base (8453) is configured but not deployed.
- Policy hash is verified off-chain, not on-chain. The contract stores the hash; the engine refuses to act on a mismatch. A compromised backend could in principle act on a different policy while the hash stays stale — which is why
updatePolicyrequires the owner's signature.
Related
- Product and policy detail: Autopilot
- Policy validation and hashing: policy engine
- Route selection: execution engine
- Upgrade procedure: contracts & upgrades