Solv Protocol BitcoinReserveOffering ERC-3525 onERC721Received() double-mint / reserve drain (2026-03-05)
- Name: Solv Protocol BitcoinReserveOffering ERC-3525
onERC721Received()double-mint / reserve drain - Date: 2026-03-05
- Protocol: Solv Protocol
BitcoinReserveOffering/BRO-SOLV-20MAY2026 - Chain: Ethereum
- Category: ERC-3525 callback accounting failure / duplicate mint / safe-transfer callback reentrancy variant / issuance-redemption solvency asymmetry
- Loss:
38.047405138108322251 SolvBTC, about$2.7Mto$2.73Mat the time; the exploit also left roughly402.17M BROof illicitly minted residual supply in attacker hands at transaction end - Summary: Solv’s
BitcoinReserveOfferingwrapper let one full-value ERC-3525 mint count the same position twice. In proxy0x014e6f6ba7a9f4c9a51a0aa3189b5c0a21006869(implementation0x15f7c1ac69f0c102e4f390e45306bd917f21cfcf),mint(uint256 sftId_, uint256 amount_)usedsafeTransferFrom()whenamount_ == balanceOf(sftId_). That transfer invokedonERC721Received()on the BRO wrapper before the outermint()had finished. The callback immediately computedvalue = sftValue * exchangeRate / 10**decimals()and called_mint(from_, value). When control returned, the outermint()executed its own_mint(msg.sender, value)for the same transferred ERC-3525 position. The attacker loopedburn -> full-balance mint22times in one transaction, turning135.364250355343316230 BROinto567,758,681.358134582535027962 BRO, then sold165,592,064.418235422750668661 BROinto Solv’s BRO/SolvBTC exchange for38.047405138108322251 SolvBTCand unwound that into1211.054376965512754134 ETH. - Trigger / entry point:
- The vulnerable full-balance branch in
mint(uint256,uint256)calledERC3525TransferHelper.doSafeTransferIn(...), which is just ERC-721-stylesafeTransferFrom(from, address(this), tokenId)on the wrapped ERC-3525 position. - Because ERC-3525 inherits ERC-721 transfer semantics, that full-token transfer invoked
onERC721Received(...)on the BRO contract itself before the outermint()finished. onERC721Received(...)treated every incoming wrapped position as a fresh mintable deposit and immediately called_mint(from_, value).- The outer
mint()then resumed and called_mint(msg.sender, value)again for the same position value.
- The vulnerable full-balance branch in
- Root cause:
- Solv split one economic event across two live code paths that each believed they were responsible for issuance: the ERC-721 receiver callback and the outer
mint()frame. nonReentrantonmint()did not help because the callback did not re-entermint(); it executed a separate issuance path insideonERC721Received().- The missing invariant was simple:
safeTransferFrom()on the full-balance branch needed a context flag or different flow proving that the incoming ERC-3525 position was already being accounted for by the still-live outermint()call. - Economically, the exploit only paid because downstream Solv infrastructure still honored the counterfeit BRO as redeemable inventory through the BRO/SolvBTC exchange.
- Solv split one economic event across two live code paths that each believed they were responsible for issuance: the ERC-721 receiver callback and the outer
- Exploit path:
- The attacker EOA
0xa407fe273db74184898cb56d2cb685615e1c0d6edeployed helper contracts0xb32d389901f963e7c87168724fbdcc3a9db20dc9and0x6aa78a9b245cc56377b21401b517ec8c03a40f03and seeded the attack with135.364250355343316230 BRO. - The looper contract repeatedly called
burn(amount, 0)on BRO, which returned an ERC-3525 position token to the attacker side. - Each round reused wrapped SFT token id
4932, approved the BRO contract for the token’s full value, and calledmint(4932, fullBalance). - Because
amount_ == sftBalance, BRO took the full-balance branch and executedsafeTransferFrom(looper, BRO, 4932). - That transfer triggered
onERC721Received(...)on BRO, and the callback minted BRO once based on the received SFT value. - The outer
mint()then resumed and minted the same BRO amount a second time. - The attacker repeated this
22times in one transaction while the exchange rate remained stable enough for the loop to compound from135.364250355343316230 BROto567,758,681.358134582535027962 BRO. - After inflation, the attacker approved the BRO/SolvBTC exchange at
0x1E6101728fD9920465dfA1562c5e371850103da2and sold165,592,064.418235422750668661 BROfor38.047405138108322251 SolvBTCtaken from treasury address0xfb2dc2428b6c2fb149a3e6d658fdf979cc0afef9. - They then routed
SolvBTC -> WBTC -> WETH, unwrapped to ETH, sent1211.054376965512754134 ETHto the attacker EOA, and also transferred the remaining402,166,616.939899159784359301 BROback to attacker control.
- The attacker EOA
- What the attacker needed to be true:
- The exploit had to reach the
amount_ == sftBalancemint branch, because the partial-value branch did not usesafeTransferFrom()and therefore did not open the callback path. onERC721Received(...)had to mint unconditionally for inbound wrapped positions, without proving whether the receipt was already being accounted for elsewhere.- The outer
mint()had to continue and mint again after the callback returned. - The protocol needed a liquid monetization rail, specifically the BRO/SolvBTC exchange inventory, that would treat freshly minted BRO as good collateral / payment.
- No pause, per-tx issuance cap, callback context guard, or exchange-side sanity check could interrupt the
burn -> double mint -> redeem into reserve inventoryloop quickly enough.
- The exploit had to reach the
- Technical anatomy worth remembering:
- This is not the usual
attacker-controlled recipient contractNFT-callback exploit. The decisive callback ran on the protocol’s own BRO wrapper after it used ERC-721 safe-transfer semantics on an ERC-3525 position. - The bug was therefore less
external attacker code re-enteredand morethe protocol kept two issuance surfaces live for one transfer event. - The exploit specifically depended on the full-value mint path. Partial SFT splits did not trigger the same callback-driven duplicate issuance.
nonReentrantaround one function is not enough if another callback-bearing function can mint against the same unfinished economic event.- The incident is worth preserving beside other callback bugs because the payout rail came after issuance: the protocol first printed unbacked BRO, then its own reserve-exchange inventory honored those claims for real
SolvBTC.
- This is not the usual
- Representative transactions and addresses:
- Main exploit transaction:
0x44e637c7d85190d376a52d89ca75f2d208089bb02b7c4708ad2aaae3a97a958d - BRO beacon proxy:
0x014e6f6ba7a9f4c9a51a0aa3189b5c0a21006869 - BRO implementation (
BitcoinReserveOffering):0x15f7c1ac69f0c102e4f390e45306bd917f21cfcf - Wrapped ERC-3525 SFT proxy:
0x982d50f8557d57b748733a3fc3d55aef40c46756 - Reused attacker SFT token id:
4932 - Attacker EOA:
0xa407fe273db74184898cb56d2cb685615e1c0d6e - Attacker main contract:
0xb32d389901f963e7c87168724fbdcc3a9db20dc9 - Burn/mint looper contract:
0x6aa78a9b245cc56377b21401b517ec8c03a40f03 - BRO/SolvBTC exchange:
0x1E6101728fD9920465dfA1562c5e371850103da2 - Treasury drained for SolvBTC inventory:
0xfb2dc2428b6c2fb149a3e6d658fdf979cc0afef9
- Main exploit transaction:
- Response / mitigation:
- Solv publicly described the incident as a limited BRO-vault exploit affecting fewer than ten users and said the impacted amount would be covered.
- The team credited monitoring partners for alerting, offered a
10%white-hat bounty, and said unaffected vaults remained secure. - Public postmortems converged on the direct code lesson: callback-bearing mint flows need one clearly owned issuance boundary, and
onERC721Received()must not independently mint when the surrounding transfer is already part of a still-live mint path. - The incident also drew scrutiny because the exploited contract appears to have gone live outside Solv’s published audit scope and outside the active bounty coverage highlighted by public commentators.
Pattern tags
- issuance-redemption-solvency-asymmetry
- safe-transfer-receiver-callback-reentrancy
- The protocol’s own
onERC721Received()callback became a second live issuance authority for the same ERC-3525 deposit, so one legitimate reserve-offering position transfer minted BRO twice nonReentrantonmint()did not protect the economic event because the duplicate issuance path lived in a sibling callback surface rather than in direct re-entry intomint()itself- The realized loss still depended on a downstream money-good rail: counterfeit BRO only mattered because Solv’s BRO/SolvBTC exchange inventory continued honoring it as redeemable value
Why the exploit worked
- Solv accidentally gave one deposit two mint authorities. The outer
mint(uint256,uint256)frame already intended to convert the transferred ERC-3525 position into BRO, but the full-balance branch reached that goal viasafeTransferFrom(), which invokedonERC721Received()before the outer function had finished accounting for the same position. - The callback then behaved like an independent deposit handler instead of a subordinate transfer acknowledgment. It computed
valuefrom the received SFT and immediately called_mint(from_, value), even though the surroundingmint()flow was still live and still planning to mint against that exact same transfer. - The decisive failure is therefore cleaner than generic
reentrancy: the protocol did not need attacker-authored callback code to get confused. It used a callback-bearing token-standard path on itself and forgot to prove whether the incoming position had already been claimed by the unfinished outer issuance frame. nonReentrantonmint()was almost beside the point because no one needed to callmint()twice recursively. The protocol already had two distinct issuance surfaces — the externalmint()path and the receiver-hook path — both willing to certify the same economic event.- The drain only became realized because the counterfeit BRO was still money-good elsewhere in the Solv stack. After the attacker compounded the duplicate issuance loop, the BRO/SolvBTC exchange and treasury inventory still honored those fabricated claims for real
SolvBTC, and that reserve-backed asset could then be unwound intoWBTCandETH.
Detection and audit pivots
- For every token-standard callback surface (
onERC721Received, ERC-1155 receivers, ERC-777 hooks, ERC-3525 transfer callbacks), ask whether the callback is allowed to publish new accounting truth or whether it should only acknowledge receipt inside another still-live business flow. - Review full-balance and partial-balance branches separately. Solv is a good reminder that a vulnerability can exist only on the
transfer whole positionbranch because that branch alone changes token-standard semantics and opens a callback path. - Treat sibling issuance surfaces as one solvency object. A reentrancy guard on one function is not enough if a callback, helper, or receiver hook can still mint against the same unfinished deposit without sharing one consumed-state marker.
- Trace the post-mint monetization rail as part of the exploit path. Duplicate issuance is dangerous because some downstream pool, exchange, treasury, or redemption helper still treats the resulting claim as spendable truth.
- Add explicit
one deposit -> one mint authorityinvariants in tests: after any callback-bearing transfer path, assert that the economic event was consumed exactly once even if the outer function resumes normally after the receiver hook returns.
Failure-prevention checklist
- Choose one accounting owner for every callback-bearing inbound transfer. If the outer
mint()frame owns issuance for a full-balance ERC-3525 transfer,onERC721Received()should validate or route only; if the receiver hook owns it, the outer frame must mark the transfer as delegated and never mint afterward. - Write the
this transfer is already being accounted forstate before anysafeTransferFrom()that can invoke a receiver hook. Callback-stage markers, delegated-transfer ids, or consumed-event flags should land before delivery, not after the hook returns. - Keep full-balance and partial-balance branches inside one accounting design. If only the full-position branch changes token-standard semantics, the code and tests should still prove it cannot mint, credit, or settle value under a second softer invariant.
- Make protocol-self receiver hooks economically inert by default. A first-party
onERC721Received()should not independently publish mintable or redeemable inventory unless the broader state machine has explicitly handed it that authority. - Guard the shared authority boundary, not just the top-level verb.
nonReentrantonmint()is weaker than an invariant that no sibling callback, helper, or receiver path may recognize the same inbound position twice. - Bind newly printed claims to slower downstream realization. If fresh BRO, receipt tokens, or wrappers can still be counterfeit until the outer flow finalizes, reserve exchanges, treasury pools, and redemption helpers should not treat them as immediately money-good inventory.
- Add issuance and reserve-outflow brakes sized for callback failure: per-tx mint caps, per-block mint velocity caps, exchange-outflow throttles, and fast pause paths that can stop a duplicate-issuance loop before one transaction drains reserve inventory.
- Test
burn -> full-balance mint -> callback -> outer-frame resume -> exchange redemptionas one joined exploit surface. The right invariant is not merelycallback cannot re-enter this function, butone inbound position transfer can create at most one spendable claim and at most one downstream reserve entitlement.
Audit prompts
- On the full-balance
safeTransferFrom()branch, who exactly owns accounting for the inbound ERC-3525 position: the outermint()frame,onERC721Received(), or both? - What stored marker proves that an inbound wrapped position is already being consumed by the still-live outer frame and therefore must not be treated as a fresh mintable deposit inside the receiver hook?
- Do full-balance and partial-balance mint branches differ in token-standard behavior, callback reachability, or issuance ownership, and has that branch divergence been reviewed as a solvency boundary rather than a transfer-detail difference?
- If the receiver hook runs on a protocol-owned contract, what prevents it from publishing spendable inventory, updating balances, or emitting user-visible issuance before the outer accounting path has finished?
- Could any sibling helper, burn path, or exchange path still monetize freshly minted BRO before the protocol has proved that the underlying ERC-3525 event was consumed exactly once?
- If a reviewer swaps
safeTransferFrom()for a non-callback transfer in the full-balance branch, does the exploit disappear? If yes, what exact state should already have been final before callback delivery was allowed? - Where do reserve exchanges, treasury inventories, or downstream wrappers assume BRO is already money-good, and what would stop them from honoring duplicate issuance during the same transaction?
- What explicit invariant proves
one inbound SFT position transfer -> one BRO mint event -> one downstream reserve claim, even when the callback and the outer frame both execute successfully?
Lessons that should already be standard
- Protocol-self receiver hooks are still callback trust boundaries. First-party code can duplicate accounting just as effectively as attacker-controlled recipient code.
nonReentranton one public verb is not a complete defense when a sibling receiver hook can still mint, credit, or settle the same unfinished economic event.- Full-balance and partial-balance token paths can have different security meaning even when they look like minor transfer-shape variations. If only one branch uses
safeTransferFrom(), only that branch may carry the duplicate-accounting bug. - One deposit should have one mint authority. If the outer frame and the receiver hook both think they own issuance for the same inbound transfer, the protocol has already split its solvency object.
- Callback bugs are still reserve bugs when downstream inventory honors the counterfeit output. Solv only became a realized theft because BRO/SolvBTC inventory and treasury reserves kept treating duplicate-minted BRO as redeemable value.
- Standard token-delivery convenience is not separate from balance-sheet design. Once a callback-bearing transfer sits on the issuance path, receiver semantics become part of the reserve-backed claim boundary.
Why this incident matters
- It is a sharp ERC-3525 / ERC-721 callback cousin to older NFT callback bugs, but with a cleaner accounting lesson: the protocol itself used safe-transfer receipt as a second mint authority for the same position.
- It also preserves an important
issue cheaply, redeem against real reservespath: the double mint alone created fake BRO, but the realized theft required Solv’s own reserve exchange to honor those fake claims for realSolvBTC. - The note is especially useful because the exploit was brutally repeatable and highly legible onchain:
22callback-driven double-mint loops, one reserve exchange, one treasury drain.
Internal linkages
- Core recurring pattern: issuance-redemption-solvency-asymmetry.
- Callflow family: reentrancy.
- Narrower callback-bearing token-standard cousin: safe-transfer-receiver-callback-reentrancy — this is a self-callback variant where the protocol’s own receiver hook, rather than attacker-controlled recipient logic, became the duplicate-issuance path.
- Positive-design companion: receiver-hook-staged-delivery-and-single-accounting-ownership — the sharper healthy-side question is why
mint()andonERC721Received()were both still allowed to own accounting for the same inbound ERC-3525 position transfer. - Reserve-boundedness companion: reserve-backed-claim-truth-security-object — the loss only became real because downstream Solv inventory still treated freshly minted BRO as a valid reserve-backed claim on
SolvBTC. - Standards-surface companion: entrypoint-equivalence-under-shared-standards — use this when the sharper review question is how outer-frame issuance and callback-bearing standard receive paths should still collapse onto one backing event and one accounting owner.
- Useful contrast: meta-pool-mpeth-erc4626-mint-without-asset-transfer-unbacked-mint-pool-drain-2025-06-17 — both incidents printed unbacked claims on the issuance side and only became realized losses when a second reserve / liquidity surface still honored those claims for real assets.
Control surface
-
The decisive leverage sat in the full-balance
mint()branch, the callback semantics ofsafeTransferFrom()on wrapped ERC-3525 positions, the lack of athis transfer is already being accounted forcontext flag insideonERC721Received(), and the existence of a reserve exchange that still priced BRO as money-good after issuance had already gone off the rails. -
Solv did not lose funds because ERC-3525 is inherently unsafe. It lost funds because one transferred position became two minted claims, and the protocol’s own downstream inventory honored the duplicate claim as real.
-
Sources:
- https://rekt.news/solv-rekt
- https://blog.verichains.io/p/solv-protocol-hack-analysis
- https://www.quillaudits.com/blog/hack-analysis/solv-protocol-exploit
- https://github.com/DarkNavySecurity/web3-exploit-analysis/blob/main/reports/solv-bro-double-mint/solv-bro-double-mint.md
- https://etherscan.io/tx/0x44e637c7d85190d376a52d89ca75f2d208089bb02b7c4708ad2aaae3a97a958d
- https://etherscan.io/address/0x014e6f6ba7a9f4c9a51a0aa3189b5c0a21006869
- https://eips.ethereum.org/EIPS/eip-3525
-
Last reviewed: 2026-07-25 UTC