Skip to content

Security: winsznx/botspend

Security

security.md

Security model

← README · Architecture · Adversarial Testing

BOTSpend's safety rests on two independent fences and a set of invariants that keep them independent. This document states the guarantees, the deliberate tradeoffs, and the known v1 limitation — honestly.


Guarantees

  1. An off-policy action can't be broadcast. The agent account holds zero BOT, so every action needs sponsorship. The paymaster's off-chain signer only signs UserOps whose calldata is execute(dest = vault) from a registered account. No signature → no gas → the op never enters a bundle.
  2. Value only moves inside policy. For any sponsored call, BOTSpendVault.executeSpend checks active/expiry, token allowlist, target allowlist, per-tx cap, rolling-24h daily cap, and actionId dedup before the transfer (checks-effects-interactions).
  3. Every decision is on-chain. Blocked actions emit AgentActionBlocked(agent, target, token, amount, reason) and return false — they never revert. Approved actions emit AgentActionApproved + ReceiptIssued. The dashboard and any auditor reconstruct the full history from events.
  4. The agent holds nothing. Owner EOA balance, SimpleAccount native balance, and SimpleAccount EntryPoint deposit are all 0 — verified live in the acceptance run.

The no-revert-on-policy design

Policy violations emit + return false; they do not revert. A revert would erase the event and the audit trail. Reverts are reserved for genuine safety: NotOwner(), Reentrancy(), and NativeTransferFailed() (native-transfer atomicity). The tradeoff — a caller could ignore the returned approved bool — is closed by the agent client branching on it, and by the fact that a blocked action moves nothing regardless.

Contract-layer safety

  • Reentrancy: hand-rolled nonReentrant guard on executeSpend; state (usedAction, spentToday) updates before the external transfer/call. A malicious token that re-enters hits the guard and the nested call reverts — proven by an explicit attacker test.
  • CEI: effects precede interactions; a failed transfer reverts the whole op (nothing partial).
  • Owner controls: setAgentPolicy, allowlists, and revokeAgent (hard off-switch) are onlyOwner.

The storage-free paymaster invariant (Fence 1 stays stake-free)

The on-chain paymaster validation reads only immutables (verifyingSigner, VAULT), performs an ECDSA recover, decodes calldata, and returns an empty context. It performs no SLOAD/SSTORE, no external calls, no BALANCE.

Why this matters under ERC-7562 (bundler simulation rules):

  • No non-sender storage access → the paymaster is not a "staked entity" → the bundler's checkStake is never reached → no stake required (a funded EntryPoint deposit is enough; deposit is withdrawable with no delay, unlike stake).
  • Empty context → postOp is skipped → the EREP-050 unstaked-paymaster rule is sidestepped.

All spend policy lives off-chain in the signer, precisely so the on-chain path can stay storage-free. Any on-chain mapping, mutable signer, or nonce would void the stake exemption.

getHash must be byte-identical

The off-chain signer's getHash must match the on-chain getHash exactly, or the recovered signer never equals verifyingSigner and every op is silently rejected (SIG_VALIDATION_FAILED). Our getHash/parsePaymasterAndData are reproduced verbatim from eth-infinitism VerifyingPaymaster v0.7.0 and pinned by a differential fuzz (Solidity-vs-reference) and a TS-vs-on-chain equivalence test — see adversarialtesting.md.

Two distinct digests are never crossed: the signer signs the paymaster getHash; the account owner signs the EntryPoint userOpHash.

F6 — the paymaster pays for blocked actions

Because Fence 1 gates destination and Fence 2 gates policy, an over-cap call to the vault is sponsored, lands on-chain, and is then blocked — and the paymaster pays gas for that blocked action. This is intentional and bounded: the sponsor's exposure is gas for value-less vault calls, the deposit is sized for it, and the blocked artifact is a feature (a permanent record of the fence working). When the deposit empties, runs pause cleanly with AA31 rather than crashing.

Key management

  • The verifyingSigner and agent-owner private keys live only in server env (the /api/sponsor route handler runs on the Node runtime). They are never NEXT_PUBLIC_, never shipped to the client, and never committed. The client bundle was grep-verified to contain zero key material and none of the server-only signer/send code.
  • The public /api/sponsor endpoint is bounded by the signer (only execute→vault from the demo agent) and rate-limited (token bucket + min gap) so it can't be spammed to drain the deposit. It validates input at the trust boundary and refuses cleanly (structured JSON) — never throws, never partial-submits.
  • Repo hygiene: internal/ (PRD + keys.json) and Foundry cache/ (which holds forge-script "sensitive values") are gitignored. No private key is committed.

Known v1 limitation (stated plainly)

Invariant "the agent account holds nothing" is not on-chain-enforceable. Anyone can call EntryPoint.depositTo(agentAccount) (permissionless), and a funded SimpleAccount's unrestricted execute could then self-sponsor a call to any target — bypassing Fence 1. v1 enforces the invariant operationally (never fund/deposit the account; asserted 0/0 before the gasless run).

The real fix (v2): RestrictedAgentAccount — a purpose-built account whose execute path is constrained on-chain to only call vault.executeSpend(...) on the configured vault (rejecting any other dest/selector) and which disables addDeposit. Then even a funded account cannot go off-scope, and Fence 1 no longer depends on balance state or the paymaster. This closes the self-sponsor bypass structurally.

Responsible disclosure

This is a hackathon testnet deployment (BOT Chain 968) using a MockUSD test asset — no real funds at risk. Issues can be raised on the repository.

There aren't any published security advisories