AntFleet

Anatomy · 57e5c9ae-2

FloatVault.withdraw ignores strategy’s actualOut and attempts to transfer requested amount regardless of received funds

highbugclosed in 3c10efc
repo bf0d040b·PR #3·reviewed 1 month ago·closed 1 month ago

The vulnerable code

contracts/src/FloatVault.sol:91-118

91 }
92
93 // Agent withdraws USDC from the vault instantly
94 function withdraw(uint256 amount) external nonReentrant {
95 require(amount > 0, "Amount must be > 0");
96 require(deposits[msg.sender] >= amount, "Insufficient deposit");
97
98 deposits[msg.sender] -= amount;
99 totalDeposits -= amount;
100
101 // Update totalDeposited for all strategies
102 uint256 count = strategyCount;
103 for (uint256 i = 1; i <= count; i++) {
104 if (strategies[i].totalDeposited >= amount) {
105 strategies[i].totalDeposited -= amount;
106 } else {
107 strategies[i].totalDeposited = 0;
108 }
109 }
110
111 // Pull from active strategy
112 if (activeStrategyId != 0) {
113 address activeStrategy = strategies[activeStrategyId].strategy;
114 IStrategy(activeStrategy).withdraw(amount);
115 }
116
117 usdc.safeTransfer(msg.sender, amount);
118

The reasoning

Opus

FloatVault.totalDeposited per-strategy accounting is unbounded O(strategyCount) and corrupts shadow tracking

mediumbughigh
  • contracts/src/FloatVault.sol:70-96
  • contracts/src/FloatVault.sol:100-128
park() and withdraw() both loop over every registered strategy (including retired/shadow) to update totalDeposited. (1) Gas grows linearly with strategyCount — putting an effective cap on how many strategies the AI may register before park/withdraw exceeds the block gas limit, but the README implies many strategies will be tried. (2) On withdraw, when a shadow strategy's totalDeposited < amount, it is silently zeroed rather than decremented proportionally, so shadow totals drift below real deposits and lose accuracy. (3) registerStrategy initializes totalDeposited to current totalDeposits, so newer shadows start at the post-park value, and on a subsequent withdraw of that amount the older shadows would also need to lose that amount, causing inconsistent tracking across strategies. Scoring upstream relies on these values per the consolidator description.

Recommendation

Use a single aggregate variable or compute totalDeposited on demand. If per-strategy historical tracking is needed, write it at registration time and update only the active strategy.

GPT-5

FloatVault.withdraw ignores strategy’s actualOut and attempts to transfer requested amount regardless of received funds

highbughigh
  • contracts/src/FloatVault.sol:91-118
If a strategy cannot return the full requested amount (e.g., a mock or real strategy under stress/rounding), IStrategy.withdraw will return a smaller actualOut. The vault ignores this value, then attempts to transfer ‘amount’ to the caller, which can revert due to insufficient balance or mis-account the vault’s funds.

Recommendation

Use the return value from IStrategy(activeStrategy).withdraw(amount) and transfer only actualOut to the caller. Consider reverting if actualOut < amount and the vault can’t make up the difference from its own balance. Update accounting consistently with the actualOut value.

The agreement

Both frontier models flagged this within the same line range. AntFleet's unanimous gate fired — the finding posted on the PR. Closed in 3c10efc.

The fix

91 }
92
93 // Agent withdraws USDC from the vault instantly
94 function withdraw(uint256 amount) external nonReentrant {
95 require(amount > 0, "Amount must be > 0");
96 require(deposits[msg.sender] >= amount, "Insufficient deposit");
97
98 deposits[msg.sender] -= amount;
99 totalDeposits -= amount;
100
101 // Update totalDeposited for all strategies
102 uint256 count = strategyCount;
103 for (uint256 i = 1; i <= count; i++) {
104 if (strategies[i].totalDeposited >= amount) {
105 strategies[i].totalDeposited -= amount;
106 } else {
107 strategies[i].totalDeposited = 0;
108 }
109 }
110
111 // Pull from active strategy
112 if (activeStrategyId != 0) {
113 address activeStrategy = strategies[activeStrategyId].strategy;
114 IStrategy(activeStrategy).withdraw(amount);
115 }
116
117 usdc.safeTransfer(msg.sender, amount);
118

Closure

Closed 1 month ago

SHA: 3c10efc6038bc5ab182e8b192224745b99bcf729

View closure receipt on GitHub →

Tweet thread template

tweet 1 of 8199 / 280

Two frontier models reviewed PR #3 on bf0d040b. Both found this bug: high bug: FloatVault.withdraw ignores strategy’s actualOut and attempts to transfer requested amount regardless of received funds

tweet 2 of 8121 / 280

The vulnerable code (contracts/src/FloatVault.sol:91-118): (full snippet at https://www.antfleet.dev/anatomy/57e5c9ae-2)

tweet 3 of 8280 / 280

What Opus saw: "park() and withdraw() both loop over every registered strategy (including retired/shadow) to update totalDeposited. (1) Gas grows linearly with strategyCount — putting an effective cap on how many strategies the AI may register before park/withdraw exceeds the b…

tweet 4 of 8280 / 280

What GPT-5 saw: "If a strategy cannot return the full requested amount (e.g., a mock or real strategy under stress/rounding), IStrategy.withdraw will return a smaller actualOut. The vault ignores this value, then attempts to transfer ‘amount’ to the caller, which can revert due…

tweet 5 of 897 / 280

Both flagged the same line range. AntFleet's unanimous gate fired — the finding posted on the PR.

tweet 6 of 893 / 280

The fix landed in commit 3c10efc: (view diff at https://www.antfleet.dev/anatomy/57e5c9ae-2)

tweet 7 of 881 / 280

AntFleet reviews every PR with two frontier models. Only unanimous findings post.

tweet 8 of 877 / 280

Full anatomy + reasoning + diffs: https://www.antfleet.dev/anatomy/57e5c9ae-2

Paste into X composer one tweet at a time. X has no multi-tweet intent API.