Solv BRO Callback Double-Mint Drain

On March 5, 2026, Solv Protocol’s Bitcoin Reserve Offering on Ethereum was exploited through a callback-driven logic error in the BRO wrapper at 0x15f7c1ac69f0c102e4f390e45306bd917f21cfcf, accessed through the beacon proxy at 0x014e6f6ba7a9f4c9a51a0aa3189b5c0a21006869. The vulnerable full-value mint() path transfers an entire ERC-3525 position into the wrapper with safeTransferFrom, which triggers onERC721Received(); the callback mints BRO once, and the outer mint() mints the same value again. The attacker repeated a burn -> mint(full balance) cycle 22 times, inflating 135.364250355343316230 BRO into 567,758,681.358134582535027962 BRO, then monetized 165,592,064.418235422750668661 BRO for 38.047405138108322251 SolvBTC and swapped that into 1211.054376965512754134 ETH. A further 402,166,616.939899159784359301 illicitly minted BRO was sent to the attacker EOA at the end of the same transaction.

Root Cause

Vulnerable Contract

  • Name: BitcoinReserveOffering
  • Implementation: 0x15f7c1ac69f0c102e4f390e45306bd917f21cfcf
  • Proxy: beacon proxy 0x014e6f6ba7a9f4c9a51a0aa3189b5c0a21006869
  • Source type: verified Solidity 0.8.20

The proxy delegates to BitcoinReserveOffering, while the ERC-3525 position token lives behind 0x982d50f8557d57b748733a3fc3d55aef40c46756 and its implementation 0x88552269817d23e2d62247287aa9f9913ac3b2f8. The exploit does not require corrupting the ERC-3525 token itself; it only relies on the wrapper mishandling the callback created by a full-token safeTransferFrom.

Vulnerable Function

  • Primary function: mint(uint256,uint256)
  • Selector: 0x1b2ef1ca
  • Callback involved: onERC721Received(address,address,uint256,bytes)
  • Selector: 0x150b7a02

The bug is in the interaction between these two functions. mint() is guarded with nonReentrant, but onERC721Received() is not. The callback does not re-enter mint(); instead it independently calls _mint(from_, value) while the outer mint() call is still in progress, so the reentrancy guard never blocks the duplicate accounting path.

Vulnerable Code

// BitcoinReserveOffering.sol -- verified source
function onERC721Received(address, address from_, uint256 sftId_, bytes calldata)
    external
    virtual
    override
    onlyWrappedSft
    returns (bytes4)
{
    require(wrappedSftSlot == IERC3525(wrappedSftAddress).slotOf(sftId_), "SftWrappedToken: unreceivable slot");
    require(address(this) == IERC3525(wrappedSftAddress).ownerOf(sftId_), "SftWrappedToken: not owned sft id");

    if (from_ == address(this)) {
        return IERC721Receiver.onERC721Received.selector;
    }

    uint256 sftValue = IERC3525(wrappedSftAddress).balanceOf(sftId_);
    require(sftValue > 0, "SftWrappedToken: mint zero not allowed");

    if (holdingValueSftId == 0) {
        holdingValueSftId = sftId_;
    } else {
        ERC3525TransferHelper.doTransfer(wrappedSftAddress, sftId_, holdingValueSftId, sftValue);
        _holdingEmptySftIds.push(sftId_);
    }
    uint256 value = sftValue * exchangeRate / (10 ** decimals());
    _mint(from_, value);
    return IERC721Receiver.onERC721Received.selector;
}

function mint(uint256 sftId_, uint256 amount_) external virtual override nonReentrant {
    require(wrappedSftSlot == IERC3525(wrappedSftAddress).slotOf(sftId_), "SftWrappedToken: slot does not match");
    require(msg.sender == IERC3525(wrappedSftAddress).ownerOf(sftId_), "SftWrappedToken: caller is not sft owner");
    require(amount_ > 0, "SftWrappedToken: mint amount cannot be 0");

    uint256 sftBalance = IERC3525(wrappedSftAddress).balanceOf(sftId_);
    if (amount_ == sftBalance) {
        ERC3525TransferHelper.doSafeTransferIn(wrappedSftAddress, msg.sender, sftId_);
    } else if (amount_ < sftBalance) {
        if (holdingValueSftId == 0) {
            holdingValueSftId = ERC3525TransferHelper.doTransferIn(wrappedSftAddress, sftId_, amount_);
        } else {
            ERC3525TransferHelper.doTransfer(wrappedSftAddress, sftId_, holdingValueSftId, amount_);
        }
    } else {
        revert("SftWrappedToken: mint amount exceeds sft balance");
    }

    uint256 value = amount_ * exchangeRate / (10 ** decimals());
    _mint(msg.sender, value);
}
// ERC3525TransferHelper.sol -- verified source
function doSafeTransferIn(address underlying, address from, uint256 tokenId) internal {
    ERC721Interface token = ERC721Interface(underlying);
    token.safeTransferFrom(from, address(this), tokenId);
}

Why It’s Vulnerable

  • Expected behavior: A full-value mint() should transfer the ERC-3525 position into the wrapper and mint BRO once for the transferred value.
  • Actual behavior: When amount_ == sftBalance, mint() executes doSafeTransferIn(), which performs safeTransferFrom(from, address(this), tokenId). That transfer invokes onERC721Received() on the BRO contract before mint() reaches its own _mint(msg.sender, value).
  • Missing invariant: onERC721Received() has no guard or context flag telling it whether it is handling a standalone incoming NFT or a transfer that is already being accounted for by mint(). It always computes value from the received token and calls _mint(from_, value).
  • Net effect: The callback mints once, the outer mint() mints again, and the same SFT value is credited twice.

The exploit path is specific to the full-value branch. Partial-value mints stay in the amount_ < sftBalance branch and do not hit safeTransferFrom, so they do not trigger onERC721Received().

Attack Execution

High-Level Flow

  1. Attacker EOA 0xa407fe273db74184898cb56d2cb685615e1c0d6e creates AttackerContractMain at 0xb32d389901f963e7c87168724fbdcc3a9db20dc9.
  2. The main contract pulls 135.364250355343316230 BRO from the EOA and deploys AttackerContract_BurnMintLoop at 0x6aa78a9b245cc56377b21401b517ec8c03a40f03.
  3. The main contract approves the looper for the seed BRO amount and calls selector 0x402b7423 with calldata (135364250355343316230, 22).
  4. The looper performs 22 iterations of:
    • burn(amount, 0) on BRO (0xb390c0ab), which moves an ERC-3525 position back to the looper.
    • tokenOfOwnerByIndex(looper, 0) on the SFT contract, which resolves the reusable token id 4932.
    • balanceOf(4932) on the SFT contract, then approve(4932, BRO, balance) so BRO can take the full token value.
    • mint(4932, fullBalance) on BRO (0x1b2ef1ca), which hits the amount_ == sftBalance branch and therefore the vulnerable callback path.
  5. After the loop, the main contract approves the Solv exchange and trades 165,592,064.418235422750668661 BRO for 38.047405138108322251 SolvBTC.
  6. The main contract approves Uniswap V3 router 0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45, executes exactOutput, swaps SolvBTC -> WBTC -> WETH, unwraps 1211.054376965512754134 WETH to ETH, forwards the ETH to the attacker EOA, and finally transfers the remaining 402,166,616.939899159784359301 BRO back to the EOA.

Detailed Call Trace

The sequence below is derived from trace_callTracer.json only.

[depth 0] 0xa407fe27 (attacker EOA)
  -> CREATE 0xb32d3899 (AttackerContractMain)

[depth 1] 0xb32d3899
  -> BRO.transferFrom(EOA, main, 135.364250355343316230 BRO)
  -> CREATE 0x6aa78a9b (burn/mint looper)
  -> BRO.approve(looper, 135.364250355343316230 BRO)
  -> looper.0x402b7423(135364250355343316230, 22)

[depth 2] 0x6aa78a9b, repeated 22 times
  -> BRO.burn(amount, 0)                           selector 0xb390c0ab
  -> SFT.tokenOfOwnerByIndex(looper, 0)            returns tokenId 4932
  -> SFT.balanceOf(4932)                           returns full token value
  -> SFT.approve(4932, BRO, fullBalance)
  -> BRO.mint(4932, fullBalance)                   selector 0x1b2ef1ca
     -> SFT.safeTransferFrom(looper, BRO, 4932)    selector 0x42842e0e
        -> BRO.onERC721Received(...)               selector 0x150b7a02
           -> _mint(looper, value)                 first mint
     -> _mint(looper, value)                       second mint

[depth 1] 0xb32d3899
  -> BRO.exchangeRate()
  -> BRO.approve(exchange, 165,592,064.418235422750668661 BRO)
  -> SolvExchange.0x045d0389(BRO, 165,592,064.418235422750668661)
     -> BRO.transferFrom(main, 0xfb2dc242..., 165,592,064.418235422750668661 BRO)
     -> SolvBTC.transferFrom(0xfb2dc242..., main, 38.047405138108322251 SolvBTC)
  -> SolvBTC.approve(UniswapRouter, 38.047405138108322251)
  -> UniswapRouter.exactOutput(...)
     -> SolvBTC / WBTC pool
     -> WBTC / WETH pool
  -> WETH.withdraw(1211.054376965512754134)
  -> ETH transfer to attacker EOA
  -> BRO.transfer(attacker EOA, 402,166,616.939899159784359301 BRO)

Loop Growth Evidence

The first and last outer loop calls in trace_callTracer.json show the compounding directly:

  • Round 1 burn: BRO.burn(135364250355343316230, 0)
  • Round 1 mint: BRO.mint(4932, 31102085070226)
  • Round 22 burn: BRO.burn(283879408361192468943524352, 0)
  • Round 22 mint: BRO.mint(4932, 65225799909192499201)

Receipt-derived transfer counts line up with the code path:

  • 22 BRO burns from looper to zero address
  • 44 BRO mints from zero address to looper
  • 22 safeTransferFrom calls into the BRO contract
  • 22 onERC721Received callbacks

That is exactly two BRO mint events for every one BRO burn/mint cycle.

Financial Impact

All amounts below come from funds_flow.json.

  • Unauthorized BRO minted from zero address: 567,758,681.358134582535027962 BRO
  • BRO spent to drain the exchange inventory: 165,592,064.418235422750668661 BRO
  • SolvBTC taken from treasury 0xfb2dc2428b6c2fb149a3e6d658fdf979cc0afef9: 38.047405138108322251 SolvBTC
  • Realized output after Uniswap swaps: 1211.054376965512754134 ETH
  • Leftover illicit BRO sent to attacker EOA: 402,166,616.939899159784359301 BRO

The economically realized protocol loss in this transaction is the 38.047405138108322251 SolvBTC transferred out of treasury and converted into ETH. The leftover BRO balance remains unauthorized supply created by the same flaw.

Evidence

  • BitcoinReserveOffering.sol lines 111-158 show the exact duplicate-accounting path: onERC721Received() mints on callback, and mint() later mints again after doSafeTransferIn() finishes.
  • ERC3525TransferHelper.sol lines 33-36 show that doSafeTransferIn() is just safeTransferFrom(from, address(this), tokenId), so the ERC-721 callback is unavoidable on the full-value path.
  • trace_callTracer.json shows 22 outer calls from the looper to BRO selector 0xb390c0ab and 22 outer calls to selector 0x1b2ef1ca; selector verification from cast sig resolves these as burn(uint256,uint256) and mint(uint256,uint256) respectively.
  • trace_callTracer.json also shows 22 safeTransferFrom(address,address,uint256) calls from BRO into the SFT token and 22 onERC721Received callbacks back into BRO.
  • funds_flow.json reconciles exactly: the zero address’s BRO net change is -567,758,681.358134582535027962, which equals the attacker’s 165,592,064.418235422750668661 BRO spent plus 402,166,616.939899159784359301 BRO retained.
  • The top-level exchange trace shows the monetization path without needing exchange source code: the exchange pulls BRO from the attacker main contract and transfers 38.047405138108322251 SolvBTC out of treasury 0xfb2dc2428b6c2fb149a3e6d658fdf979cc0afef9.