tl;dr

Enzyme lets people run investment funds on-chain. those funds can earn token rewards distributed through Merkl.

three live funds had valid rewards but could not collect them. Merkl delivered the correct reward by exchanging a wrapper token for the real token. Enzyme then tried to transfer the wrapper, which was already gone, and reverted the entire claim.

i reproduced the failure on a pinned copy of the live blockchain across all three funds. the rewards were available and the claims were valid; the integration between the two systems made them impossible to collect.


the assumption that broke

Enzyme funds interact with Aave V3 through an external-position library. that library also exposes an action for claiming rewards distributed through Merkl.

the claim logic accepts an incremental amount from the fund, adds Merkl's previously claimed amount to reconstruct the cumulative proof amount, then submits the proof to the distributor:

for (uint256 i; i < tokensLength; i++) {
    users[i] = address(this);
    totalAmounts[i] = amounts[i]
        + MERKL_DISTRIBUTOR.claimed(address(this), tokens[i]);
}

MERKL_DISTRIBUTOR.claim(users, tokens, totalAmounts, proofs);

after the distributor returns, Enzyme forwards each reward to the vault:

for (uint256 i; i < tokensLength; i++) {
    IERC20(tokens[i]).safeTransfer(msg.sender, amounts[i]);
}

the second loop assumes that tokens[i] now exists in the external position's balance. for a normal ERC-20 reward, that is true. for the production reward i found, it was not.


the proof token was not the reward

the Merkl proof named this token:

0x2c63f9da936624Ac7313b972251D340260A4bF08

it is a PullTokenWrapper. its token() function resolves to a different asset:

0x6533afac2E7BCCB20dca161449A13A32D391fb00

that second token is the Aave aToken actually delivered to the recipient. when Merkl processes the claim, the wrapper's transfer hook consumes the wrapper and moves the underlying aToken instead.

the external position therefore ends the distributor call in this state:

wrapper balance:  0
aToken balance:   previous balance + claimed amount

Enzyme does not inspect that balance change. it continues with the proof token and calls safeTransfer() on the wrapper. the external position owns no wrapper, so the transfer reverts with:

ERC20: transfer amount exceeds balance

because the claim and transfer happen in one transaction, the revert also rolls back the successful wrapper conversion, the aToken delivery and Merkl's claimed accounting. retrying produces the same result.

the parser repeated the assumption. it reported the proof wrapper as the asset the vault would receive, even though Enzyme's ValueInterpreter rejected that wrapper and supported the delivered aToken.


proving it on production state

i reproduced the complete path on a Foundry fork of Arbitrum block 497632109. the test used deployed Enzyme and Merkl code and the state at that block; it deployed no mocks and modified no production storage.

the call followed the real fund path:

fund owner
  -> ComptrollerProxy
  -> ExternalPositionManager
  -> AaveV3DebtPositionLib
  -> Merkl Distributor

three active external positions held valid allocations for the same wrapper. for each fund, the test calculated the incremental claimable amount from Merkl's cumulative proof, called the production ClaimMerklRewards action and observed the same wrapper-balance revert.

after every attempt, the assertions confirmed that Merkl's claimed amount, the position balances and the vault balances were unchanged.

confirmed funds:          3
claimable aArbARB:        947.484946207508412496
Enzyme valuation:         96.407604 USDC
successful fund claims:   0

a separate diagnostic control called the distributor as the external position. it consumed the wrapper and increased the aToken balance by exactly the expected amount, proving that the failure happened after Merkl delivered the reward.

a read-only inventory found the same wrapper-to-aToken mismatch across additional active Arbitrum positions. the post relies on the three pinned allocations because they remain deterministic and reproducible.


there was no fund-controlled bypass

a frozen claim matters only if the fund can not recover the reward through another configured path.

the fork reconstructed each vault's owner, accessor and asset managers, then checked the relevant Merkl permissions. none of the owners, vaults, comptrollers or managers had a token operator, wildcard operator or claim recipient configured for the affected positions. direct claims by the fund owners reverted with NotWhitelisted().

the external-position proxy could not originate an arbitrary call either. from the fund's perspective, the deployed Aave V3 action was the only available claim path, and that path deterministically reverted.

recovery required protocol-level intervention. Enzyme governance could replace the library and parser used by existing type-0 positions, or Merkl governance could authorize an operator for a position. a fork control demonstrated the second route and recovered the reward through Enzyme's normal Sweep -> AddCollateral -> RemoveCollateral flow.


fixing the token identity

the claim path should treat the proof token and delivered asset as separate values.

for a supported wrapper, the library can resolve token(), record the underlying balance before the claim and transfer the exact balance delta afterwards. the parser must report that same underlying asset to the vault. wrappers with unknown delivery semantics should be rejected before the distributor is called.

proof token -> resolve wrapper -> delivered asset
                              -> balance before
claim Merkl
                              -> balance after
                              -> transfer delta to vault

this removes the assumption that an external protocol's accounting token must also be the asset arriving on-chain.

the report was confirmed and paid with 1,000 USDC.

the confirmed 1,000 USDC bounty payment


what to take away from this

the vulnerable code was not an obviously unsafe transfer or an unchecked permission. both components behaved as designed: Merkl honored the wrapper, and Enzyme forwarded the token named by the proof. the bug existed in the assumption between them.

wrapper tokens, vault shares and callback-driven assets routinely separate identity from delivery. integrations should verify what balance actually changed instead of treating a proof, quote or function argument as evidence of what was received.