Reproduced Exploit
Tapioca DAO — Incorrect math means removeAssetFromSGL will never work once SGL has accrued interest
1. A user asks the Magnetar periphery helper to withdraw a specific amount of the underlying asset from a Singularity market via removeAssetFromSGL. 2. Magnetar converts that amount into raw YieldBox shares with
Chain
Other
Category
logic
Date
Feb 2024
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 32318-h-07-incorrect-math-means-dataremoveandrepaydataremoveassetf. Standalone Foundry PoC and full write-up: 32318-h-07-incorrect-math-means-dataremoveandrepaydataremoveassetf_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/logic/argument-type-confusion · vuln/availability/liveness-brick · vuln/accounting/rebase-scaling
Reproduction: a self-contained Foundry PoC that compiles & runs in an isolated project with only
forge-std— no fork, no RPC, noanvil_state. Full trace: output.txt. PoC: test/32318-h-07-incorrect-math-means-dataremoveandrepaydataremoveassetf.sol.
Key info#
| Impact | HIGH — an ordinary, well-covered withdrawal through the Magnetar periphery helper permanently reverts the instant the Singularity market has accrued any interest at all |
| Protocol | Tapioca DAO — an omnichain money market; Singularity is its lending market, YieldBox its shared token-share vault, Magnetar a periphery helper that batches user actions |
| Vulnerable code | MagnetarOptionModule.removeAssetFromSGL — converts a withdrawal AMOUNT into raw YieldBox shares via toShare, then passes those shares into Singularity.removeAsset()'s fraction parameter, which SGLCommon._removeAsset scales by the pool's ENTIRE value (idle + lent out) |
| Bug class | Argument-type confusion: raw YieldBox shares passed where a totalAsset.base-denominated fraction is expected |
| Finding | Code4rena — Tapioca, 2024-02 · #32318 · reporter KIntern_NA |
| Report | code4rena.com/reports/2024-02-tapioca |
| Source | AuditVault |
| Status | Audit finding — caught in review (not exploited on-chain). Tapioca confirmed the bug and shipped a fix PR. Reproduced here as a standalone local PoC. |
| Compiler | ^0.8.24 (PoC) |
This is an audit finding, not a historical on-chain incident. The PoC
keeps the three blamed lines verbatim — the toShare conversion and the
removeAsset call in MagnetarOptionModule, and the fraction-scaling formula
in SGLCommon._removeAsset — so the "will never work" outcome becomes a
measurable, asserted revert.
TL;DR#
- A user asks the Magnetar periphery helper to withdraw a specific amount
of the underlying asset from a Singularity market via
removeAssetFromSGL. - Magnetar converts that amount into raw YieldBox shares with
yieldBox.toShare(assetId, removeAmount, false). - It then passes those raw shares straight into
Singularity.removeAsset(user, to, share)'s third parameter — which Singularity's_removeAssettreats as a fraction oftotalAsset.base(its own internal LP-accounting unit), not as YieldBox shares. _removeAssetscales that "fraction" byallShare = totalAsset.elastic + yieldBox.toShare(totalBorrow.elastic)— the pool's entire value, including everything currently lent out to borrowers.- The two quantities only coincide while
totalAsset.elastic == totalAsset.base, i.e. before any interest-bearing borrow exists. The instant the market has any outstanding borrow interest, the resultingsharefigure exceeds the pool's real idle balance and the subtraction underflows — reverting the entire withdrawal. - Every withdrawal through this path permanently fails once the market has accrued any interest at all — exactly the report's title.
The vulnerable code#
The Magnetar conversion and mis-plumbed call (verbatim,
MagnetarOptionModule.sol):
uint256 share = yieldBox_.toShare(_assetId, _removeAmount, false); // L153
singularity_.removeAsset(data.user, removeAssetTo, share); // L158-159
Singularity's fraction-scaling formula, which the caller above never
satisfies once interest has accrued (verbatim, SGLCommon.sol L199-216):
function _removeAsset(address from, address to, uint256 fraction) internal returns (uint256 share) {
if (totalAsset.base == 0) {
return 0;
}
Rebase memory _totalAsset = totalAsset;
uint256 allShare = _totalAsset.elastic + yieldBox.toShare(assetId, totalBorrow.elastic, false);
share = (fraction * allShare) / _totalAsset.base; // @> fraction must be base-denominated, NOT raw shares
}
Root cause#
fraction is Singularity's own internal accounting unit — a share of the
pool's total claim (idle liquidity plus everything currently out on loan),
scaled against totalAsset.base (the LP-fraction supply). Raw YieldBox
shares of a withdrawal amount are a completely different unit that happens to
numerically coincide with a base-denominated fraction only in the special
case where the pool has never had any borrow activity. Magnetar's helper
computes the wrong unit and hands it directly to a function whose scaling
factor grows monotonically with every unit of interest the market accrues.
Preconditions#
- The Singularity market has any outstanding borrow (
totalBorrow.elastic > 0) — i.e. it has accrued any interest at all, which is the normal steady state of a functioning lending market. - A user calls
removeAssetFromSGLfor any amount that, once mis-scaled byallShare / totalAsset.base, exceeds the pool's actual idle YieldBox balance.
Attack walkthrough#
From output.txt:
- A depositor puts 2000 units into the Singularity market via
addAsset(totalAsset.elastic == totalAsset.base == 2000). - The market accrues interest from normal borrowing activity
(
totalBorrow.elasticgrows to 3000). - The depositor asks Magnetar to withdraw 1000 units — only half of their own stake, comfortably covered by both their SGL balance and the market's real liquidity.
- Magnetar computes
share = toShare(1000) = 1000and passes it asfractionintoremoveAsset. Singularity computesallShare = 2000 + 3000 = 5000andshare = 1000 * 5000 / 2000 = 2500— 2.5x more than the pool's actual idle balance (2000). totalAsset.elastic -= 2500underflows against the actual idle balance of 2000 and reverts. HARM: the withdrawal fails completely, even though the request was entirely reasonable and well within the user's means.- A control call directly against Singularity, using the correctly-scaled
fraction the report's suggested fix (
getFractionForAmount) would produce, succeeds and returns exactly the requested amount — proving the market itself is solvent and functional. The bug is purely Magnetar's argument-type confusion.
Diagrams#
Impact#
- Any withdrawal through
removeAssetFromSGLreverts once the market has accrued any interest at all — which is the normal, expected state of a functioning lending market, not an edge case. - This is a liveness brick on a core user action (withdrawing deposited assets), not a fund-extraction bug: users cannot get their own money out through this path at all, for as long as the market has any outstanding interest.
- Because the mis-scaling amplifies with the size of outstanding borrow relative to idle liquidity, the failure gets more certain, not less, the longer the market operates normally — the bug does not self-resolve.
Sources#
- AuditVault finding: https://github.com/Auditware/AuditVault/blob/main/findings/32318-h-07-incorrect-math-means-dataremoveandrepaydataremoveassetf.md
- Original report: https://code4rena.com/reports/2024-02-tapioca
- Reduced-source provenance: blamed lines quoted directly from the AuditVault
finding, which cites
github.com/Tapioca-DAO/tapioca-periphcommit2ddbcb1cde03b548e13421b2dba66435d2ac8eb5,contracts/Magnetar/modules/MagnetarOptionModule.sol(L153, L158-159), andgithub.com/Tapioca-DAO/Tapioca-barcommitc2031ac2e2667ac8f9ac48eaedae3dd52abef559,contracts/markets/singularity/SGLCommon.sol(L199-216).
Taxonomy (AuditVault)#
- genome: precision-loss, specific-token-type, direct-drain, integer-bounds
- sector: governance, lending, token
- severity: high
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 32318-h-07-incorrect-math-means-dataremoveandrepaydataremoveassetf_exp (evm-hack-registry mirror).
- AuditVault finding: 32318-h-07-incorrect-math-means-dataremoveandrepaydataremoveassetf.
Alerts & third-party analyses
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.