DEV Community

ohmygod
ohmygod

Posted on

ERC-3525 Meets Reentrancy: How a Token Standard Interaction Turned 135 Tokens Into 567 Million in the Solv Protocol Exploit

TL;DR

In March 2026, an attacker exploited a double-minting reentrancy in Solv Protocol's BitcoinReserveOffering (BRO) vault, inflating 135 BRO tokens into 567 million — extracting ~$2.7M worth of SolvBTC. The vulnerability stems from a subtle interaction between ERC-3525's semi-fungible token standard and the onERC721Received callback inherited from ERC-721. This isn't your typical reentrancy — it's a masterclass in how token standard composition creates unexpected attack surfaces.


Background: What Is Solv Protocol?

Solv Protocol is a Bitcoin reserve infrastructure that lets users convert BTC into SolvBTC — a wrapped representation that can be deployed across DeFi for staking, lending, and borrowing. The BitcoinReserveOffering (BRO) mechanism allows users to mint BRO tokens by depositing collateral through an NFT-based flow built on the ERC-3525 standard.

ERC-3525 is the Semi-Fungible Token standard — it extends ERC-721 (each token has a unique ID) while adding a value property (tokens within the same "slot" are fungible). Think of it as an NFT that also carries a balance.

The critical detail: ERC-3525 tokens are also ERC-721 tokens. They inherit all ERC-721 behavior, including safe transfer callbacks.

The Vulnerability: Two Mints, One Transaction

The exploit targets the mint() function in BitcoinReserveOffering.sol. Here's the conceptual flow:

mint(nftId, goefs_amount):
    1. Transfer ERC-3525 NFT from user → contract (doSafeTransferIn)
       └─ NFT arrives at contract
       └─ ERC-721 mandates: call onERC721Received() on recipient
           └─ Inside callback: _mint() executes → FIRST MINT ✓
    2. Back in mint(): _mint() executes again → SECOND MINT ✓

    Result: User gets DOUBLE the tokens they should receive
Enter fullscreen mode Exit fullscreen mode

The root cause is deceptively simple:

  1. The mint() function calls doSafeTransferIn() to ingest an ERC-3525 NFT
  2. Because ERC-3525 inherits ERC-721, the safe transfer triggers onERC721Received() on the receiving contract
  3. Inside this callback, the contract's logic mints BRO tokens for the depositor
  4. After the callback completes, execution returns to mint(), which calls _mint() again
  5. Same exchange rate, same parameters — double the tokens

This is a cross-function reentrancy via a token standard callback. The contract didn't violate checks-effects-interactions within a single function — the reentrancy vector was the NFT transfer callback itself.

The Attack Execution

The attacker executed this with surgical precision:

Step 1: Seed the attack

  • Start with 135 BRO tokens
  • Burn them through the reserve contract, receiving 0.000031102085070226 GOEFS tokens

Step 2: Trigger the double-mint loop

  • Call mint() with the GOEFS tokens and NFT ID 4932
  • Each call doubles the BRO output due to the callback reentrancy
  • Repeat 22 times within a single transaction

Step 3: Extract value

  • 135 BRO → 567,000,000 BRO (22 iterations of doubling)
  • Convert 165M BRO → SolvBTC via the BRO-SolvBTC exchange
  • SolvBTC → WBTC → WETH → ETH via Uniswap V3
  • Total extracted: ~1,211 ETH (~$2.7M)
  • Remaining ~402M BRO left in attacker's EOA

Step 4: Cover tracks

  • 1,211 ETH deposited into RailGun (privacy protocol)

The entire exploit ran in a single transaction. Because the exchange rate is constant within a block, each iteration yielded the exact same doubling effect.

Why This Reentrancy Is Different

Most developers learn reentrancy through the DAO hack pattern: a withdraw() function that sends ETH before updating balances, allowing recursive calls. The standard defense — Checks-Effects-Interactions (CEI) — would have the function update state before making external calls.

The Solv exploit is subtler for three reasons:

1. The External Call Is Hidden in a Token Transfer

The doSafeTransferIn() looks like a simple token ingestion. But because ERC-3525 inherits ERC-721's safe transfer mechanism, it triggers a callback that re-enters the contract's minting logic. This is not an obvious external call.

2. The Reentrancy Spans Two Functions

The callback doesn't re-enter mint() — it triggers onERC721Received(), which independently calls _mint(). Then mint() also calls _mint(). Two different entry points, same state corruption.

3. Token Standard Composition Creates the Vector

ERC-3525 alone doesn't have this problem. ERC-721 alone doesn't have this problem. The vulnerability emerges from their composition — ERC-3525's semi-fungible semantics built atop ERC-721's callback infrastructure.

How to Detect This Class of Vulnerability

Static Analysis Flags

  • Any contract that receives ERC-721/ERC-3525 tokens AND has minting logic in callbacks
  • onERC721Received() implementations that modify token supply or balances
  • Functions that perform state-changing operations both before and after safeTransferFrom calls

Fuzzing Approach

Using a tool like Echidna or Foundry's fuzzer, you'd want to:

// Invariant: total minted should equal expected mint amount
function invariant_noDoubleMint() public {
    uint256 expectedMint = calculateExpectedMint(depositAmount);
    uint256 actualMint = bro.balanceOf(attacker);
    assert(actualMint <= expectedMint);
}
Enter fullscreen mode Exit fullscreen mode

Manual Audit Checklist

  • [ ] Map ALL external calls in the mint/deposit flow, including implicit callbacks
  • [ ] Trace token standard inheritance chains (ERC-3525 → ERC-721 → callbacks)
  • [ ] Verify that onERC721Received and similar hooks don't duplicate state changes
  • [ ] Check if reentrancy guards cover the entire mint flow, not just individual functions
  • [ ] Test with malicious receiver contracts that exploit callbacks

Defensive Patterns

1. Reentrancy Guard on the Full Flow

// Guard must cover BOTH the transfer AND the post-transfer mint
function mint(uint256 nftId, uint256 amount) external nonReentrant {
    doSafeTransferIn(nftId);
    _mint(msg.sender, calculateAmount(amount));
    // onERC721Received cannot re-enter because of nonReentrant
}
Enter fullscreen mode Exit fullscreen mode

2. Mint Only After Transfer (CEI Pattern)

function mint(uint256 nftId, uint256 amount) external {
    // Effects: record the deposit first
    deposits[msg.sender] += amount;

    // Interactions: transfer NFT (callback can't mint because _mint checks deposits)
    doSafeTransferIn(nftId);

    // Final mint based on recorded deposit
    _mint(msg.sender, deposits[msg.sender]);
    deposits[msg.sender] = 0;
}
Enter fullscreen mode Exit fullscreen mode

3. Don't Mint in Callbacks

The simplest fix: onERC721Received() should ONLY return the selector, never perform state-changing operations:

function onERC721Received(
    address, address, uint256, bytes calldata
) external pure returns (bytes4) {
    return this.onERC721Received.selector;
    // No minting, no state changes, no business logic
}
Enter fullscreen mode Exit fullscreen mode

The Bigger Picture: Token Standard Composability Risks

This exploit is part of a growing trend. As DeFi protocols compose multiple token standards (ERC-20 + ERC-721 + ERC-3525 + ERC-4626, etc.), the interaction surface multiplies. Each standard brings its own callback mechanisms:

Standard Callback Risk
ERC-721 onERC721Received Reentrancy via safe transfer
ERC-1155 onERC1155Received Batch reentrancy
ERC-3525 Inherits ERC-721 callbacks Double-mint via callback + caller
ERC-777 tokensReceived Classic hook reentrancy
ERC-4626 Deposit/withdraw hooks Share inflation

The lesson: When you build on composed token standards, you're inheriting all the callback surfaces of every standard in the chain. Your threat model must account for inherited behavior, not just the standard you intended to use.

Timeline

  • March 2026: Attacker executes exploit, draining ~$2.7M from BRO vault
  • Same day: Solv Protocol acknowledges the hack, confirms <10 users affected
  • Same day: 10% white-hat bounty offered to attacker
  • Ongoing: Solv working with Hypernative Labs, SlowMist, and CertiK on remediation
  • Funds status: 1,211 ETH moved to RailGun; 402M BRO still in attacker EOA

Key Takeaways

  1. Reentrancy isn't solved — it just wears new costumes. Token standard callbacks are the new attack vector.
  2. Audit the inheritance chain — if you use ERC-3525, you must audit ERC-721 behavior too.
  3. nonReentrant on every state-changing entry point — not just the obvious ones.
  4. Never put business logic in token receiver callbacks — they should be minimal acknowledgment functions.
  5. Single-transaction exploits are the norm — constant exchange rates within a block enable exponential inflation loops.

This analysis is based on public post-mortems from Halborn, QuillAudits, and Solv Protocol's official communications. The attacker's address and transaction hashes are available on Etherscan for independent verification.


Previous in this series:

Top comments (0)