The $2.7M Solv Protocol Exploit and How Olympix Would Have Prevented It
Date: March 2026Chain: Ethereum Mainnet
Amount Lost: ~$2.7M (38.05 SolvBTC)
Attack Vector: Cross-function reentrancy via ERC-721 and ERC-3525 transfer callbacks
Root Cause:onERC721Received and onERC3525Received lack nonReentrant, enabling double-minting through every path in mint()
On March 5, 2026, an attacker turned 135 BRO tokens into approximately 567 million. They did it 22 times. By the end, 38.05 SolvBTC had been drained from Solv Protocol's BitcoinReserveOffering vault and swapped out through Uniswap V3 into 1,211 ETH.
The vulnerability was a missing modifier. Not a novel cryptographic attack. Not an oracle manipulation requiring deep protocol knowledge. A missing nonReentrant on two callback functions that interact directly with the minting logic.
After the exploit, we ran Olympix's BugPocer on the contract. It returned 8 distinct findings covering every attack path through mint(), each with a working Foundry proof-of-concept. If the team had run BugPocer before deployment, this exploit never happens.
What Happened
Solv Protocol's BitcoinReserveOffering contract lets users deposit ERC-3525 Semi-Fungible Tokens (SFTs) in exchange for BRO, an ERC-20 token. The mint() function is protected by OpenZeppelin's nonReentrant modifier. The callback functions it triggers are not.
When a user calls mint() with a full SFT deposit, the contract calls doSafeTransferIn(), which calls safeTransferFrom() on the ERC-3525 token. That external call triggers the onERC721Received() callback on the BitcoinReserveOffering contract itself. Inside that callback, the contract calls _mint() and issues BRO tokens to the depositor. Control returns to mint(), which then calls _mint() again for the same deposit amount.
Two mints. One deposit. Every time.
The attacker ran that loop 22 times. 135 BRO became 567 million. 567 million BRO redeemed for 38.05 SolvBTC. 38.05 SolvBTC swapped for 37.99 WBTC, then 1,211 ETH. $2.7M gone.
The Attack, Step by Step
1. Entering mint().The attacker calls mint() with a full SFT deposit (amount_ == sftBalance). The function carries a nonReentrant guard, which sets the reentrancy lock.
2. The external call. Inside mint(), doSafeTransferIn() calls safeTransferFrom(attacker, BitcoinReserveOffering, sftId). This is an external call to the ERC-3525 token contract, which triggers the onERC721Received() callback on the BitcoinReserveOffering contract.
3. The first mint. onERC721Received() has no nonReentrant modifier. It does not check OpenZeppelin's reentrancy storage flag. It sees from_ = attacker, which is not address(this), so the early-return guard does not fire. It calls _mint(attacker, value). First mint complete.
4. The second mint. onERC721Received() returns. Control is back in mint(). The function calls _mint(msg.sender, value) again with the same exchange rate and same deposit amount. Second mint complete.
5. Repeated 22 times. The attacker looped this sequence 22 times, each time receiving 2x the BRO tokens their deposit entitled them to, until 38.05 SolvBTC was fully drained from the vault.
Root Cause: Cross-Function Reentrancy
The BitcoinReserveOffering contract's stated invariant is explicit: ReentrancyGuardUpgradeable must prevent reentrant calls to mint, burn, onERC721Received, and onERC3525Received.
That invariant is violated. mint() has nonReentrant. onERC721Received and onERC3525Received do not. OpenZeppelin's ReentrancyGuard works by setting a storage flag at the start of the guarded function and clearing it on exit. But the flag is only checked by functions that carry the modifier. A different function with no modifier can be entered freely, even mid-execution of a guarded call.
This is cross-function reentrancy: the attacker does not re-enter mint() directly. They enter onERC721Received() as a side-effect of mint()'s external call, execute _mint() inside it, and let mint() finish its own _mint() call afterward. The reentrancy guard never fires because neither callback checks it.
The full attack surface has three paths, all exploitable:
mint() [nonReentrant] | |-- amount_ == sftBalance (EXPLOITED PATH): | doSafeTransferIn() | safeTransferFrom(attacker, BRO, sftId) | onERC721Received() [NO nonReentrant] | _mint(attacker, value) <-- FIRST MINT | _mint(msg.sender, value) <-- SECOND MINT | |-- amount_ < sftBalance, holdingValueSftId == 0: | doTransferIn() | ERC3525.transferFrom(...) | onERC3525Received() [NO nonReentrant] | _mint(fromSftOwner, value) <-- FIRST MINT | _mint(msg.sender, value) <-- SECOND MINT | |-- amount_ < sftBalance, holdingValueSftId != 0: doTransfer() ERC3525.transferFrom(...) onERC3525Received() [NO nonReentrant] _mint(fromSftOwner, value) <-- FIRST MINT _mint(msg.sender, value) <-- SECOND MINT
All three paths double-mint. The attacker only needed one.
What Olympix Found After the Exploit
After the Solv exploit, we ran BugPocer on the BitcoinReserveOffering contract to see what proactive analysis would have surfaced. It returned 8 severity-ranked findings, all tracing to the same root cause, covering every attack path through mint(). Each finding came with an executable Foundry proof-of-concept. All of this would have been in the team's hands before deployment, if they had run it.
[High] Cross-Function Reentrancy Double-Mint: 8 Findings, 1 Root Cause
BugPocer mapped every route through which the missing nonReentrant on the callback functions produces a double-mint. The primary finding matched the real exploit path exactly.
Finding 1: reentrancy_double_mint via doSafeTransferIn
Covers the amount_ == sftBalance path the attacker used. BugPocer traced the full execution: mint() calls doSafeTransferIn(), which calls safeTransferFrom(), which triggers onERC721Received(). Because from_ is msg.sender and not address(this), the early-return guard does not fire. _mint() runs inside the callback. _mint() runs again in mint(). Two mints, one deposit.
Finding 2: reentrancy_missing_nonreentrant_double_minting via onERC721Received
Documents the missing modifier directly: onERC721Received is a distinct entry point from mint(). OpenZeppelin's reentrancy flag is set by mint() but never consulted by onERC721Received. All three branches of mint() are vulnerable through this path.
Findings 3-7: Cover the remaining ERC-3525 callback paths through doTransferIn and doTransfer, and document why the existing guard inside onERC3525Received fails: it only checks whether the source SFT is owned by the contract, not whether the callback is a side-effect of mint(). During mint(), the source SFT is owned by the attacker, so the guard never fires.
Finding 8: insufficient_internal_transfer_guard
Documents the specific guard bypass mechanism used in the real exploit and explains why the check fromSftOwner == address(this) does not protect against mint()-triggered callbacks.
The Proof of Concept
BugPocer's PoC deploys BitcoinReserveOffering behind an EIP-1167 proxy, initializes it with a mock ERC-3525 SFT, and demonstrates the double-mint directly. An attacker deposits 100e18 SFT value at a 2:1 exchange rate. They should receive 200e18 BRO tokens. They receive 400e18.
function test_doubleMint_via_onERC721Received_callback() public { address attacker = makeAddr("attacker"); mockSft.setToken(SFT_ID, attacker, SLOT, SFT_VALUE);
// FAILS: attacker received 400e18 instead of 200e18 assertEq(attackerBalance, expectedSingleMint, "VULNERABILITY: Double minting detected - attacker received 2x tokens"); }
The test fails. The attacker receives double. That is the exploit, reproduced in a test environment before a single transaction hits mainnet.
Additional Findings
BugPocer also surfaced three medium-severity findings unrelated to the reentrancy path:
A rounding_to_zero issue in burn() where ERC-20 tokens are destroyed before computing the SFT return value. If the result rounds to zero, tokens are burned with no SFT returned to the user. An inverted conversion formula in getValueByShares and getSharesByValue, where the two functions have swapped logic, causing incorrect valuations for any off-chain integration. And two instances of unsafe ERC-721 transfers in burn(), where transferFrom is used instead of safeTransferFrom, meaning SFTs can be permanently locked in contracts that do not implement IERC721Receiver.
None of these were exploited in March. All of them represent live risk.
How Olympix Would Have Prevented This
Reentrancy is one of the most well-documented vulnerability classes in smart contract security. It is also one of the most consistently missed, because it does not always look like reentrancy.
The Solv exploit is cross-function reentrancy. mint() is protected. The callbacks are not. A manual audit reviewing mint() in isolation would see the nonReentrant modifier and move on. The vulnerability only becomes visible when you trace the full execution path through the external call, into the callback, and back through the second _mint().
BugPocer traces those paths automatically. It does not audit mint() in isolation. It maps the entire call graph, identifies every external call that triggers a callback, and checks whether those callbacks respect the reentrancy invariants established elsewhere in the contract. For the Solv contract, it found 8 distinct paths where they do not, proved each one with a working PoC, and would have handed all of it to the development team before deployment.
The contract's own documentation stated the invariant: onERC721Received and onERC3525Received must be protected from reentrancy. BugPocer found the 8 places that invariant was violated. The audit missed them. The attacker found one.
The fix is two lines of code: add nonReentrant to onERC721Received and onERC3525Received. $2.7M is a steep price for two missing modifiers.
The Broader Pattern
Reentrancy has been a known vulnerability class since 2016. The DAO hack. $60 million. Ten years later, reentrancy variants are still draining protocols. Not because developers do not know what reentrancy is, but because cross-function reentrancy does not announce itself. The guarded function looks safe. The unguarded callback does not look like an attack surface until someone traces the full execution path and sees the double-mint.
90% of exploited smart contracts were previously audited. Audits are point-in-time engagements run by humans working through a codebase in a fixed window. They are valuable. They are not sufficient. The Solv contract had a stated invariant, documented in comments, that was violated in 8 distinct ways. A static analysis tool would not catch it. A quick audit might not trace every callback path through every branch of mint().
Deterministic analysis does. BugPocer runs the full execution graph, generates PoCs for every failure mode it finds, and does it before the code ships. That is the gap between "audited" and provably secure.
Don't Wait for the Post-Mortem to Be About You
The Solv Protocol lost $2.7M to two missing modifiers. The vulnerability was present across 8 distinct code paths. BugPocer found all of them, with working exploits, after the fact.
The rich text element allows you to create and format headings, paragraphs, blockquotes, images, and video all in one place instead of having to add and format them individually. Just double-click and easily create content.
A rich text element can be used with static or dynamic content. For static content, just drop it into any page and begin editing. For dynamic content, add a rich text field to any collection and then connect a rich text element to that field in the settings panel. Voila!
Headings, paragraphs, blockquotes, figures, images, and figure captions can all be styled after a class is added to the rich text element using the "When inside of" nested selector system.
Follow-up: Conduct a follow-up review to ensure that the remediation steps were effective and that the smart contract is now secure.
Follow-up: Conduct a follow-up review to ensure that the remediation steps were effective and that the smart contract is now secure.
In Brief
Remitano suffered a $2.7M loss due to a private key compromise.
GAMBL’s recommendation system was exploited.
DAppSocial lost $530K due to a logic vulnerability.
Rocketswap’s private keys were inadvertently deployed on the server.
Hacks
Hacks Analysis
Huobi | Amount Lost: $8M
On September 24th, the Huobi Global exploit on the Ethereum Mainnet resulted in a $8 million loss due to the compromise of private keys. The attacker executed the attack in a single transaction by sending 4,999 ETH to a malicious contract. The attacker then created a second malicious contract and transferred 1,001 ETH to this new contract. Huobi has since confirmed that they have identified the attacker and has extended an offer of a 5% white hat bounty reward if the funds are returned to the exchange.