Skip to content

security: add buyToken whitelist to prevent malicious token swaps#20

Open
senti23 wants to merge 2 commits intoCreator-Bid:mainfrom
senti23:security/buytoken-whitelist
Open

security: add buyToken whitelist to prevent malicious token swaps#20
senti23 wants to merge 2 commits intoCreator-Bid:mainfrom
senti23:security/buytoken-whitelist

Conversation

@senti23
Copy link

@senti23 senti23 commented Feb 21, 2026

PR Title

security: add buyToken whitelist to prevent malicious token swaps


What

We discovered that Clawlett's AI agent could swap Safe funds into any arbitrary token — including honeypots, ruggable tokens, or attacker-controlled contracts. The only guardrail was the AI prompt, which is trivially bypassable.

This PR adds on-chain token whitelisting that the agent physically cannot bypass. Two fixes work together to close all unvalidated swap paths:

Fix 1: ZodiacHelpersV3.sol — output token validation in aeroExecute()

cowPreSign() already had a whitelist check from the initial V3 build. However, an independent audit found that aeroExecute() validated command types and recipient addresses but did NOT validate the output token — the same class of vulnerability the whitelist was designed to prevent. This fix adds _extractOutputToken() + _isWhitelisted() to aeroExecute(), covering V3 packed paths, V2 Route[] arrays, and skipping WRAP/UNWRAP. 6 new tests.

Fix 2: initialize.js — removed direct Aerodrome Router permission

The old initialize.js granted allowTarget(AeroUniversalRouter, Send) — meaning the agent could call the router directly, skipping aeroExecute() entirely. Now all Aerodrome calls must route through ZodiacHelpers.

Why both are needed:

Scenario | Without Fix 1 | Without Fix 2 -- | -- | -- Attack vector | Agent calls aeroExecute() with malicious output token — no check, passes through | Agent calls Aerodrome Router directly — bypasses aeroExecute() entirely Result | Whitelist meaningless for Aerodrome path | Fix 1 meaningless since agent skips it

Files Changed

  • contracts/src/WhitelistRegistry.sol — new: standalone token registry
  • contracts/src/ZodiacHelpersV3.sol — new: whitelist-enforced helpers with cowPreSign() + aeroExecute() output token checks
  • contracts/test/ZodiacHelpersV3.t.sol — new: 21 fork tests against live Base mainnet
  • contracts/script/DeployV3.s.sol — new: Foundry deployment script
  • clawlett/scripts/manage-whitelist.js — new: CLI tool for whitelist management with --execute flag
  • clawlett/scripts/initialize.js — updated: removed direct Aerodrome Router permission, only ZodiacHelpers gets access

Setup for Other Users

# View current whitelist
node scripts/manage-whitelist.js --show

Add a token

node scripts/manage-whitelist.js --add 0xTOKEN_ADDRESS --execute

Remove a token

node scripts/manage-whitelist.js --remove 0xTOKEN_ADDRESS --execute

Initial setup (permission swap from old helpers to V3)

node scripts/manage-whitelist.js --setup 0xREGISTRY_ADDRESS 0xHELPERS_ADDRESS --execute

# PR Title
security: add buyToken whitelist to prevent malicious token swaps


What

We discovered that Clawlett's AI agent could swap Safe funds into any arbitrary token — including honeypots, ruggable tokens, or attacker-controlled contracts. The only guardrail was the AI prompt, which is trivially bypassable.

This PR adds on-chain token whitelisting that the agent physically cannot bypass. Two fixes work together to close all unvalidated swap paths:

Fix 1: ZodiacHelpersV3.sol — output token validation in aeroExecute()

cowPreSign() already had a whitelist check from the initial V3 build. However, an independent audit found that aeroExecute() validated command types and recipient addresses but did NOT validate the output token — the same class of vulnerability the whitelist was designed to prevent. This fix adds _extractOutputToken() + _isWhitelisted() to aeroExecute(), covering V3 packed paths, V2 Route[] arrays, and skipping WRAP/UNWRAP. 6 new tests.

Fix 2: initialize.js — removed direct Aerodrome Router permission

The old initialize.js granted allowTarget(AeroUniversalRouter, Send) — meaning the agent could call the router directly, skipping aeroExecute() entirely. Now all Aerodrome calls must route through ZodiacHelpers.

Why both are needed:

Scenario Without Fix 1 Without Fix 2
Attack vector Agent calls aeroExecute() with malicious output token — no check, passes through Agent calls Aerodrome Router directly — bypasses aeroExecute() entirely
Result Whitelist meaningless for Aerodrome path Fix 1 meaningless since agent skips it

Both together: all Aerodrome swaps must go through aeroExecute() → which validates the output token against the WhitelistRegistry.

Note: For existing live deployments, the direct router access was already revoked on-chain. Fix 2 specifically protects new wallets initialized going forward.

New contracts:

New tooling:

  • manage-whitelist.js — CLI tool to add/remove tokens, execute permission swaps, and manage the whitelist directly from terminal with --execute flag (signs + submits Safe txs, no web UI needed)

Why

The original ZodiacHelpers contract enforced receiver restrictions (funds always land in the Safe) but did not restrict which token the agent could swap into. ZodiacHelpersV3 added a whitelist check to cowPreSign() (CoW Protocol path), but left the Aerodrome path (aeroExecute()) unprotected. A compromised agent could still:

  1. Call aeroExecute() to swap Safe USDC → attacker-controlled token (funds land in Safe ✅, but token is worthless)
  2. Drain the liquidity pool from the other side
  3. Safe is left holding a worthless token

Additionally, initialize.js granted direct allowTarget access to the Aerodrome Router, allowing the agent to bypass aeroExecute() entirely.

Note: The codebase already applies this exact whitelist pattern for Trenches factory contracts via FactoryNotWhitelisted(). This PR extends the same approach to swap output tokens — same logic, different parameter.

Attack Surface (Before Fix → After Fix)

Swap Path Receiver Locked? buyToken Restricted? Status
cowPreSign (CoW Protocol) ✅ to Safe ✅ Already whitelisted Protected (existing)
aeroExecute (Aerodrome Universal Router) ✅ to Safe/Router ❌ → ✅ Whitelisted Fixed (Fix 1)
Direct Aerodrome Router (allowTarget) ❌ None ❌ None Fixed (Fix 2) — permission removed
Trenches functions ✅ Factory whitelist N/A Already safe

How It Works

WhitelistRegistry.sol:

  • mapping(address => bool) + address[] for enumeration
  • onlyOwner (the Safe) can addToken / removeToken / addTokens
  • Events emitted for tracking

ZodiacHelpersV3.sol:

  • _isWhitelisted() calls IWhitelistRegistry(WHITELIST_REGISTRY).isWhitelisted(token) via STATICCALL
  • WHITELIST_REGISTRY stored as immutable (in bytecode, not storage) — safe for delegatecall context
  • cowPreSign() reverts if order.buyToken is not whitelisted (existing, unchanged)
  • aeroExecute() (new) extracts the output token from each swap command and checks the whitelist:
    • V3_SWAP_EXACT_IN (0): last 20 bytes of packed path
    • V3_SWAP_EXACT_OUT (1): first 20 bytes of packed path (reversed direction)
    • V2_SWAP_EXACT_IN/OUT (8, 9): routes[last].to from Aerodrome's Route[] struct
    • WRAP_ETH (11) / UNWRAP_WETH (12): skipped (only involve ETH/WETH)

initialize.js:

  • Removed scopeTarget(AeroUniversalRouter) + allowTarget(AeroUniversalRouter, Send)
  • Agent can now only interact with ZodiacHelpers (via allowTarget(ZodiacHelpers, Both))
  • All Aerodrome swaps are forced through aeroExecute() which enforces the whitelist

Why V3 over V2:

  • V2 used a hardcoded whitelist — adding one token required full redeployment + permission swap + config updates
  • V3 reads from a separate registry — adding a token is one Safe signature via CLI

Independent Audit

An independent agent audit was performed before submission. Key finding: aeroExecute() validated command types and recipient addresses but did NOT validate output tokens — the same class of vulnerability the whitelist was designed to prevent. This was fixed and verified with 6 additional tests.

Testing

  • 21 fork tests against live Base mainnet (all passing)
  • Tests cover: whitelist enforcement on cowPreSign, aeroExecute (V2/V3 exact in/out), multi-hop route extraction, WRAP/UNWRAP bypass prevention, registry add/remove/onlyOwner/enumeration/batch
  • Live validation: added cbETH to whitelist via CLI → swapped USDC → cbETH successfully
  • Tested whitelisted swap (USDC → WETH) ✅
  • Tested non-whitelisted swap → reverts ✅

Key test cases for aeroExecute:

# Test Result
15 V3 EXACT_IN to non-whitelisted token → reverts
16 V3 EXACT_IN to whitelisted token → passes
17 V2 EXACT_IN to non-whitelisted token → reverts
18 V3 EXACT_OUT to non-whitelisted token → reverts
19 WRAP_ETH → no false positive (skips whitelist)
20 V2 multi-hop WETH→USDC→malicious → reverts (checks LAST route)

Deployed Contracts (Base Mainnet)

Contract Address
WhitelistRegistry [0x7c956833DaaCAC47317c96627d24A2021d1B95E5](https://basescan.org/address/0x7c956833DaaCAC47317c96627d24A2021d1B95E5)
ZodiacHelpersV3 [0xc2B310D652A7793C19f9f6D73cF7bF5D0B10B9Ab](https://basescan.org/address/0xc2B310D652A7793C19f9f6D73cF7bF5D0B10B9Ab)

Files Changed

  • contracts/src/WhitelistRegistry.sol — new: standalone token registry
  • contracts/src/ZodiacHelpersV3.sol — new: whitelist-enforced helpers with cowPreSign() + aeroExecute() output token checks
  • contracts/test/ZodiacHelpersV3.t.sol — new: 21 fork tests against live Base mainnet
  • contracts/script/DeployV3.s.sol — new: Foundry deployment script
  • clawlett/scripts/manage-whitelist.js — new: CLI tool for whitelist management with --execute flag
  • clawlett/scripts/initialize.js — updated: removed direct Aerodrome Router permission, only ZodiacHelpers gets access

Setup for Other Users

# View current whitelist
node scripts/manage-whitelist.js --show

# Add a token
node scripts/manage-whitelist.js --add 0xTOKEN_ADDRESS --execute

# Remove a token  
node scripts/manage-whitelist.js --remove 0xTOKEN_ADDRESS --execute

# Initial setup (permission swap from old helpers to V3)
node scripts/manage-whitelist.js --setup 0xREGISTRY_ADDRESS 0xHELPERS_ADDRESS --execute
```## Known Limitation

The on-chain whitelist may drift from the token list in `tokens.js` as tokens are added/removed over time. Operators should periodically reconcile using `manage-whitelist.js --show` and `--add`/`--remove` as needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant