On March 5, 2026, an attacker turned 135 BRO tokens into 567 million — then drained $2.7 million in SolvBTC from Solv Protocol's BitcoinReserveOffering vault. The weapon: a callback-driven double-mint vulnerability hiding in the intersection of ERC-3525 Semi-Fungible Tokens and ERC-721's onERC721Received hook.
This wasn't a novel attack class. It was reentrancy wearing a new costume. And it slipped past audits because the dangerous interaction wasn't in a single contract — it lived in the gap between two token standards.
The Architecture That Broke
ERC-3525 is a Semi-Fungible Token standard built on top of ERC-721. Every ERC-3525 token is also an ERC-721 token, which means safe transfers trigger the onERC721Received() callback on the receiving contract. This is by design — it's how smart contracts confirm they can handle incoming NFTs.
Solv Protocol's BRO vault accepted ERC-3525 deposits. The mint flow looked something like this:
User calls mint(tokenId, amount)
└─> Contract calls doSafeTransferIn(tokenId)
└─> ERC-3525 safe transfer triggers onERC721Received()
└─> Callback executes _mint() → BRO tokens created ①
└─> Transfer completes
└─> mint() continues and calls _mint() again → BRO tokens created ②
The _mint() function fired twice for every single deposit: once inside the callback during token ingestion, and once when the outer mint() function resumed. Both mints used the same parameters. Double the tokens, same cost.
The Attack Execution
The attacker's playbook was elegant in its simplicity:
Step 1: Seed capital. Start with 135 BRO tokens.
Step 2: Token cycling. Burn BRO tokens through the reserve contract to receive GOEFS tokens, then re-deposit with an NFT to trigger the double-mint.
Step 3: Compounding. Repeat the mint transaction 22 times within a single transaction. Because the entire exploit ran atomically, the exchange rate never updated — each iteration doubled the output at the same price.
Step 4: Extraction. Swap ~165 million BRO → SolvBTC → WBTC → WETH → 1,211 ETH. Route through RailGun to obscure the trail.
The remaining ~402 million BRO tokens sat in the attacker's wallet at 0xa407...0d6e, presumably waiting for liquidity to absorb them.
Why This Slipped Past Review
This bug is sneaky because it doesn't look like classic reentrancy. There's no external call to an attacker-controlled contract. There's no ETH transfer with a fallback function. The callback is a standard-mandated hook that every ERC-721-compliant receiver must implement.
The vulnerability pattern:
// DANGEROUS: Mint logic split across callback and caller
function mint(uint256 tokenId, uint256 amount) external {
// This triggers onERC721Received → which calls _mint()
doSafeTransferIn(msg.sender, tokenId);
// This calls _mint() AGAIN with the same parameters
_mint(msg.sender, calculateBRO(amount));
}
function onERC721Received(...) external returns (bytes4) {
// First mint happens here during token transfer
_mint(msg.sender, calculateBRO(amount));
return IERC721Receiver.onERC721Received.selector;
}
Auditors looking for reentrancy check for:
- External calls to untrusted addresses ✓ (not present here)
- State changes after external calls ✓ (state was changed, but the "external call" was a standard callback)
- Missing reentrancy guards ✓ (would've helped, but the callback wasn't flagged as risky)
The real issue: the mint logic was duplicated across two execution paths that both fire during the same transaction. This is a business logic flaw, not a textbook reentrancy — which is exactly why OWASP's Smart Contract Top 10 2026 elevated Business Logic Vulnerabilities to #2.
The Defensive Playbook
1. Never Split State-Changing Logic Across Callbacks
If a function changes state AND triggers a callback that also changes state, you have a double-execution risk. The fix:
function mint(uint256 tokenId, uint256 amount) external {
// Calculate BEFORE the transfer
uint256 broAmount = calculateBRO(amount);
// Transfer the token (triggers callback)
doSafeTransferIn(msg.sender, tokenId);
// Mint ONLY here, not in the callback
_mint(msg.sender, broAmount);
}
function onERC721Received(...) external returns (bytes4) {
// Callback does NOTHING except confirm receipt
return IERC721Receiver.onERC721Received.selector;
}
2. Use Reentrancy Guards on ALL Callback-Triggering Functions
Even when callbacks are "standard," treat them as external calls:
function mint(uint256 tokenId, uint256 amount) external nonReentrant {
doSafeTransferIn(msg.sender, tokenId);
_mint(msg.sender, calculateBRO(amount));
}
3. Audit the Standards Stack, Not Just Your Code
When your contract accepts tokens from complex standards (ERC-3525, ERC-1155, ERC-777), map every callback that could fire during a transfer. Build a call graph that includes standard-mandated hooks:
Your function call
└─> safeTransferFrom
└─> onERC721Received (ERC-721)
└─> onERC1155Received (ERC-1155)
└─> tokensReceived (ERC-777)
└─> onTransferReceived (ERC-1363)
Each of these callbacks is a potential re-entry point.
4. Fuzz the Callback Paths
Static analyzers like Slither and Aderyn can catch simple reentrancy, but callback-driven double-execution often requires dynamic analysis. Use Echidna or Foundry's fuzz testing to verify:
// Foundry invariant test
function invariant_noDoubleMint() public {
// Total minted should equal total deposited × exchange rate
assertEq(
bro.totalSupply(),
vault.totalDeposited() * vault.exchangeRate()
);
}
The Bigger Picture: March 2026's Business Logic Theme
Solv Protocol wasn't alone. The first two weeks of March 2026 saw a pattern:
| Exploit | Loss | Root Cause |
|---|---|---|
| Solv Protocol BRO | $2.7M | ERC-3525 callback double-mint |
| sDOLA LlamaLend | $240K | ERC-4626 donate() price inflation |
| MoltEVM (Base) | $127K | Flawed access control in mint |
| LEDS (BNB Chain) | $64K | Multiple deflationary mechanisms without access control |
Three of these four were business logic bugs — correct code at the instruction level, broken at the design level. They passed syntax checks, type checks, and basic vulnerability scans. They failed because the interaction between components was never tested.
Key Takeaways
ERC-3525 inherits ERC-721's callback surface. If your contract accepts ERC-3525 tokens via safe transfer, you MUST account for
onERC721Received()as a re-entry vector."Standard compliance" ≠ "safe." The callback wasn't a bug in ERC-3525 or ERC-721. It was a correct implementation of both standards that created a dangerous interaction.
Business logic bugs are the #2 threat in 2026. The OWASP Smart Contract Top 10 moved them up because they cause massive losses and evade automated detection.
Single-transaction atomicity doesn't protect you. The attacker exploited the same exchange rate 22 times because all iterations happened in one block. Time-weighted checks and per-block limits would have capped the damage.
Runtime monitoring catches what audits miss. Solv Protocol has since appointed Fuzzland as its runtime Risk Guardian. On-chain monitoring for anomalous mint volumes would have flagged 567 million tokens appearing from 135 in a single transaction.
This analysis is part of the Smart Contract Security Deep Dives series. Follow for weekly breakdowns of real exploits and the defensive patterns they teach.
Top comments (0)