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.7M to $2.73M at the time; the exploit also left roughly 402.17M BRO of illicitly minted residual supply in attacker hands at transaction end
  • Summary: Solv’s BitcoinReserveOffering wrapper let one full-value ERC-3525 mint count the same position twice. In proxy 0x014e6f6ba7a9f4c9a51a0aa3189b5c0a21006869 (implementation 0x15f7c1ac69f0c102e4f390e45306bd917f21cfcf), mint(uint256 sftId_, uint256 amount_) used safeTransferFrom() when amount_ == balanceOf(sftId_). That transfer invoked onERC721Received() on the BRO wrapper before the outer mint() had finished. The callback immediately computed value = sftValue * exchangeRate / 10**decimals() and called _mint(from_, value). When control returned, the outer mint() executed its own _mint(msg.sender, value) for the same transferred ERC-3525 position. The attacker looped burn -> full-balance mint 22 times in one transaction, turning 135.364250355343316230 BRO into 567,758,681.358134582535027962 BRO, then sold 165,592,064.418235422750668661 BRO into Solv’s BRO/SolvBTC exchange for 38.047405138108322251 SolvBTC and unwound that into 1211.054376965512754134 ETH.
  • Trigger / entry point:
    • The vulnerable full-balance branch in mint(uint256,uint256) called ERC3525TransferHelper.doSafeTransferIn(...), which is just ERC-721-style safeTransferFrom(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 outer mint() 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.
  • 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.
    • nonReentrant on mint() did not help because the callback did not re-enter mint(); it executed a separate issuance path inside onERC721Received().
    • 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 outer mint() call.
    • Economically, the exploit only paid because downstream Solv infrastructure still honored the counterfeit BRO as redeemable inventory through the BRO/SolvBTC exchange.
  • Exploit path:
    1. The attacker EOA 0xa407fe273db74184898cb56d2cb685615e1c0d6e deployed helper contracts 0xb32d389901f963e7c87168724fbdcc3a9db20dc9 and 0x6aa78a9b245cc56377b21401b517ec8c03a40f03 and seeded the attack with 135.364250355343316230 BRO.
    2. The looper contract repeatedly called burn(amount, 0) on BRO, which returned an ERC-3525 position token to the attacker side.
    3. Each round reused wrapped SFT token id 4932, approved the BRO contract for the token’s full value, and called mint(4932, fullBalance).
    4. Because amount_ == sftBalance, BRO took the full-balance branch and executed safeTransferFrom(looper, BRO, 4932).
    5. That transfer triggered onERC721Received(...) on BRO, and the callback minted BRO once based on the received SFT value.
    6. The outer mint() then resumed and minted the same BRO amount a second time.
    7. The attacker repeated this 22 times in one transaction while the exchange rate remained stable enough for the loop to compound from 135.364250355343316230 BRO to 567,758,681.358134582535027962 BRO.
    8. After inflation, the attacker approved the BRO/SolvBTC exchange at 0x1E6101728fD9920465dfA1562c5e371850103da2 and sold 165,592,064.418235422750668661 BRO for 38.047405138108322251 SolvBTC taken from treasury address 0xfb2dc2428b6c2fb149a3e6d658fdf979cc0afef9.
    9. They then routed SolvBTC -> WBTC -> WETH, unwrapped to ETH, sent 1211.054376965512754134 ETH to the attacker EOA, and also transferred the remaining 402,166,616.939899159784359301 BRO back to attacker control.
  • What the attacker needed to be true:
    • The exploit had to reach the amount_ == sftBalance mint branch, because the partial-value branch did not use safeTransferFrom() 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 inventory loop quickly enough.
  • Technical anatomy worth remembering:
    • This is not the usual attacker-controlled recipient contract NFT-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-entered and more the 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.
    • nonReentrant around 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.
  • 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
  • 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
  • nonReentrant on mint() did not protect the economic event because the duplicate issuance path lived in a sibling callback surface rather than in direct re-entry into mint() 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 via safeTransferFrom(), which invoked onERC721Received() 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 value from the received SFT and immediately called _mint(from_, value), even though the surrounding mint() 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.
  • nonReentrant on mint() was almost beside the point because no one needed to call mint() twice recursively. The protocol already had two distinct issuance surfaces — the external mint() 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 into WBTC and ETH.

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 position branch 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 authority invariants 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 for state before any safeTransferFrom() 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. nonReentrant on mint() 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 redemption as one joined exploit surface. The right invariant is not merely callback cannot re-enter this function, but one 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 outer mint() 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.
  • nonReentrant on 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 reserves path: the double mint alone created fake BRO, but the realized theft required Solv’s own reserve exchange to honor those fake claims for real SolvBTC.
  • The note is especially useful because the exploit was brutally repeatable and highly legible onchain: 22 callback-driven double-mint loops, one reserve exchange, one treasury drain.

Internal linkages

Control surface