DEV Community

ohmygod
ohmygod

Posted on

The Solv Protocol Double-Mint Exploit: How an ERC-3525 Callback Turned 135 Tokens Into 567 Million

The Solv Protocol Double-Mint Exploit: How an ERC-3525 Callback Turned 135 Tokens Into 567 Million

On March 5th, 2026, someone deposited 135 BRO tokens into Solv Protocol's Bitcoin Reserve Offering vault. Twenty-two loops later, they walked out with 567 million BRO tokens — worth approximately $2.73 million in SolvBTC. The entire attack happened in a single transaction.

The vulnerability? A classic reentrancy bug hiding behind the complexity of token standard inheritance. The contract minted tokens twice for every deposit — once inside a callback, once after it returned. Nobody noticed because the code never received an audit.

This is the anatomy of how it happened, why ERC-3525's relationship with ERC-721 creates a footgun that every DeFi developer needs to understand, and how to detect this pattern before your protocol becomes the next headline on Rekt News.

The Setup: What Solv Protocol Does

Solv Protocol operates Bitcoin reserve vaults. Users deposit collateral and receive BRO (Bitcoin Reserve Offering) tokens representing their position. These BRO tokens can later be burned to redeem the underlying SolvBTC — which maintains a 1:1 exchange rate with BTC.

The protocol uses ERC-3525, a semi-fungible token standard. Think of it as a hybrid between ERC-721 (unique NFTs) and ERC-20 (fungible tokens). Each ERC-3525 token has a unique ID like an NFT, but also carries a numeric value that can be split, merged, and transferred in fractions. It's elegant for representing yield-bearing vault positions.

But ERC-3525 is built on top of ERC-721. And ERC-721 has a well-known feature: the onERC721Received callback.

That callback is what blew the vault wide open.

The Vulnerability: Two Mints, One Deposit

Here's how the BRO vault's mint() function was supposed to work:

  1. User calls mint() with their collateral (GOEFS tokens + an ERC-3525 NFT)
  2. Contract calls doSafeTransferIn() to ingest the NFT
  3. Contract calculates how many BRO tokens to mint based on the exchange rate
  4. Contract mints BRO tokens to the user
  5. Done

Here's what actually happened:

  1. User calls mint() with collateral
  2. Contract calls doSafeTransferIn() to transfer the ERC-3525 NFT
  3. Because ERC-3525 inherits ERC-721, the safe transfer triggers onERC721Received() callback on the recipient
  4. Inside the callback: the contract's logic mints BRO tokens to the user (Mint #1)
  5. Callback returns, execution continues in mint()
  6. mint() proceeds to mint BRO tokens again using the same exchange rate (Mint #2)
  7. User receives double the tokens they should have

The critical flaw: the contract updated its internal state (minted tokens) inside the callback before the outer mint() function completed its own minting logic. Neither mint operation knew about the other.

The Pseudocode

function mint(uint256 nftId, uint256 goefs) external {
    // Step 1: Transfer NFT from user
    // This triggers onERC721Received → which calls _mint() internally
    doSafeTransferIn(msg.sender, nftId);  // ← MINT #1 happens inside here

    // Step 2: Calculate and mint BRO tokens
    uint256 broAmount = calculateMint(goefs, exchangeRate);
    _mint(msg.sender, broAmount);  // ← MINT #2 happens here

    // User now has 2x the tokens they should
}
Enter fullscreen mode Exit fullscreen mode

This is a self-reentrancy — the contract doesn't call an external untrusted contract. It calls back into itself through the ERC-721 callback chain. Traditional reentrancy guards that protect against external reentrance wouldn't catch this because the "reentrant" call originates from the token standard's own transfer mechanism.

The Attack: 22 Loops, 567 Million Tokens

The attacker's execution was surgical:

  1. Start: Burn 135 BRO tokens through the reserve contract, receiving 0.000031102085070226 GOEFS tokens based on the exchange rate
  2. Exploit: Call mint() sending those GOEFS tokens along with NFT ID 4932 — triggering the double-mint
  3. Loop: Repeat 22 times within a single transaction

Because everything happened atomically in one transaction, the exchange rate never changed between iterations. Each loop doubled the effective output. After 22 iterations:

  • Input: 135 BRO tokens
  • Output: ~567,000,000 BRO tokens (a 4.2 million x multiplier)

The attacker then:

  • Swapped 165M BRO → SolvBTC via the BRO-SolvBTC exchange
  • Swapped SolvBTC → WBTC → WETH → ETH via Uniswap V3
  • Netted approximately 1,211 ETH (~$2.73M)
  • Deposited the ETH into RailGun (privacy protocol)

The remaining ~402M BRO tokens still sit in the attacker's EOA: 0xa407fe273db74184898cb56d2cb685615e1c0d6e.

Why This Wasn't Caught

Three compounding failures:

1. No Audit on the Exploited Contract

Solv Protocol's GitHub proudly lists 5 security auditors. But the BitcoinReserveOffering contract that was exploited was never included in any audit scope. The audits were real — they just didn't cover the contract that got hacked.

This is a pattern we see repeatedly in DeFi: protocols get audited, then deploy new contracts between audit cycles. The marketing says "audited by X, Y, Z" — but the contract handling your money might not be one of them.

2. Bug Bounty Blind Spot

Solv's bug bounty program on HackenProof explicitly covered only Web2 infrastructure and Solana contracts. The Ethereum smart contracts — including the exploited BRO vault — were completely out of scope. A white hat who found this vulnerability had no financial incentive (and no legal protection) to report it.

3. Token Standard Inheritance Complexity

ERC-3525 inheriting ERC-721 creates a non-obvious execution flow. Developers writing minting logic might not realize that a "safe transfer" of an ERC-3525 token triggers the same callback chain as an ERC-721 transfer. The reentrancy vector isn't visible from reading the minting function alone — you need to trace through the token standard's transfer implementation.

Detection: How to Find This Pattern

Static Analysis Flags

Look for any function that:

  1. Calls safeTransferFrom() or doSafeTransferIn() on an ERC-721 or ERC-3525 token
  2. Performs a state-changing operation (like minting) after the transfer call
  3. The transfer's callback also triggers state changes
# Semgrep-style pattern (conceptual)
rules:
  - id: erc721-callback-reentrancy
    patterns:
      - pattern: |
          $CONTRACT.safeTransferFrom(...);
          ...
          _mint(...);
    message: "Potential reentrancy: _mint() after safeTransferFrom(). 
              The ERC-721 callback may trigger state changes before 
              this mint executes."
Enter fullscreen mode Exit fullscreen mode

Foundry Fuzz Test

function test_doubleMintReentrancy() public {
    // Deploy a malicious receiver that tracks mint callbacks
    MaliciousReceiver receiver = new MaliciousReceiver(address(broVault));

    // Give receiver some initial BRO tokens
    deal(address(broToken), address(receiver), 100e18);

    // Execute mint through the receiver
    uint256 balanceBefore = broToken.balanceOf(address(receiver));
    receiver.triggerMint(nftId, goefsAmount);
    uint256 balanceAfter = broToken.balanceOf(address(receiver));

    // If balance increased by more than expected, we have a double-mint
    uint256 expectedMint = calculateExpectedMint(goefsAmount);
    assertEq(balanceAfter - balanceBefore, expectedMint, 
             "DOUBLE MINT DETECTED: received more tokens than expected");
}
Enter fullscreen mode Exit fullscreen mode

Manual Code Review Checklist

When auditing contracts that use ERC-3525 or ERC-721:

  • [ ] Map all safeTransferFrom / safeTransfer calls
  • [ ] Identify what happens inside onERC721Received callbacks
  • [ ] Check if any state modifications (mints, balance updates) occur both inside callbacks AND after the transfer returns
  • [ ] Verify reentrancy guards cover the entire function, not just external calls
  • [ ] Test with a contract that implements onERC721Received to re-enter

The Broader Pattern: Callback-Driven Reentrancy

The Solv exploit belongs to a growing class of callback-driven reentrancy attacks that differ from the traditional model:

Pattern Classic Reentrancy Callback Reentrancy
Trigger Untrusted external call Token standard callback
Source address.call{value:...}("") onERC721Received, tokensReceived, etc.
Defense Check-Effects-Interactions Insufficient — callback is within the interaction
Detection ReentrancyGuard Requires tracing through token standard internals
Examples The DAO (2016) Solv (2026), various ERC-777 exploits

Other token standards with mandatory callbacks that create similar risks:

  • ERC-777: tokensReceived() hook — caused multiple exploits on Uniswap V1
  • ERC-1155: onERC1155Received() and onERC1155BatchReceived()
  • ERC-3525: Inherits ERC-721's onERC721Received() plus its own value transfer hooks

Mitigation Strategies

For Protocol Developers

1. Effects Before Interactions — Even for "Safe" Transfers

function mint(uint256 nftId, uint256 goefs) external nonReentrant {
    // Calculate FIRST
    uint256 broAmount = calculateMint(goefs, exchangeRate);

    // Update state BEFORE any transfer
    _mint(msg.sender, broAmount);

    // Transfer LAST (callback can't double-mint now)
    doSafeTransferIn(msg.sender, nftId);
}
Enter fullscreen mode Exit fullscreen mode

2. Use OpenZeppelin's ReentrancyGuard on ALL minting functions

The nonReentrant modifier prevents re-entrance into the function. Even if the callback tries to call mint() again, it'll revert.

3. Separate callback logic from minting logic

Never mint tokens inside onERC721Received. The callback should only handle transfer validation — not business logic.

For Auditors

  • Always trace token standard inheritance chains. If a contract uses ERC-3525, audit it as if it uses ERC-721 too — because it does.
  • Test with adversarial receivers. Deploy test contracts that implement onERC721Received with reentrancy attempts.
  • Check audit scope coverage. The most dangerous contracts are the ones that launched between audit cycles.

For Protocol Users

  • Check if specific contracts are in audit scope — not just whether the protocol "has been audited"
  • Verify bug bounty coverage includes the contracts you're interacting with
  • Watch for contracts deployed after the last audit date

Timeline

Date Event
Pre-March 2026 BitcoinReserveOffering contract deployed without audit
March 5, 2026 Attacker executes 22-loop double-mint in single tx
March 5, 2026 DefimonAlerts posts technical breakdown on X
March 5, 2026 Solv Protocol acknowledges exploit, offers 10% bounty
March 5, 2026 Attacker converts to ETH, deposits into RailGun
March 5, 2026 No funds returned; 402M BRO still in attacker's wallet

Key Takeaways

  1. Token standard inheritance creates hidden attack surface. ERC-3525 looks different from ERC-721, but under the hood it carries all of ERC-721's callback risks.

  2. "Audited protocol" ≠ "audited contract." Solv had 5 auditors. The exploited contract wasn't in any of their scopes.

  3. Bug bounty scope matters. If your EVM contracts aren't covered by your bounty program, you're telling white hats to look elsewhere and black hats to look harder.

  4. Self-reentrancy is harder to spot than external reentrancy. The callback comes from within the token standard's own transfer mechanism — not from an untrusted external address.

  5. Single-transaction atomicity is the attacker's best friend. Looping 22 times at a fixed exchange rate within one transaction turned a 2x multiplication into a 4.2 million x amplification.


The Solv exploit is a reminder that reentrancy didn't die with The DAO. It evolved. As token standards get more complex — layering callbacks on callbacks — the attack surface doesn't shrink. It branches. The defense hasn't changed in a decade: update your state before you make external calls. But "external call" now includes things your own token standard does behind your back.

Tags: #security #blockchain #solidity #defi #smartcontracts #web3 #ethereum

Top comments (0)