Skip to content

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.cjs61 passing

ChainProxyImplementation
Arbitrum (42161)0x689AaEc7E3012d84226e7c5F4DEeFdC01C86d23e0x7bF6f14c53BB18BC5D5cd34c9A81c8fb658bB168
Robinhood (4663)0x4dE7CD522f1715b2a48F3ad6612924841d450A0F0xa75A5bEE…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

FunctionAccessPurpose
initialize(...)initializer
createAgent(...)anyoneCreate an agent with budget token, limits, allowed tokens and policy hash
deposit(...)anyoneFund an agent
withdraw(...)agent ownerPartial withdrawal
withdrawAllAndClose(...)agent ownerFull exit
setActive(...)agent ownerPause / resume
updatePolicy(...)agent ownerNew policy hash
executeTrade(...)executorOne trade
batchExecuteTrades(...)executorMany, with failure isolation
_executeTradeGuarded(...)onlySelfThe isolation mechanism — see below
getAgent, getBalance, getAgentTokens, isTokenAllowed, getAgentBalancesview
setExecutor(address)owner
setFeeBps(uint16)owner
setRouter(address, bool) / setRouters(address[], bool)ownerRouter allow-list
withdrawEarnings(...)ownerSweep platformEarnings
pause() / unpause()owner
_authorizeUpgradeowner

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 / EarningsWithdrawn

TradeExecuted 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:

IDSeverityIssueStatus
C-1CriticalArbitrary targetContract → executor could drain all pooled fundsFixed — router allow-list
H-1HighOnly budgetToken outflows meteredAccepted, documented
M-1MediumDebited amountIn, not measured spend → stranded surplusFixed
M-2MediumOne bad token bricked withdrawAllAndCloseFixed — 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.json

Known 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.cjs to 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 updatePolicy requires the owner's signature.

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