Skip to content

[midnight adapter] round net rate computations once - #1213

Open
devin-ai-integration[bot] wants to merge 4 commits into
mainfrom
devin/1792661800-single-rounding-net-rate
Open

devin-ai-integration[bot] wants to merge 4 commits into
mainfrom
devin/1792661800-single-rounding-net-rate

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

MidnightAdapter’s two-stage rounding can lower netRate even on zero-unit buys, recognizing unearned interest early. Compute buy and shortfall-sale rates with a single round-down so zero-unit buys leave the rate unchanged. The final rate rounding on real trades remains; already-deployed adapters are unchanged.

Refs PRO-300. Also fixes Sherlock #24.

Link to Devin session: https://app.devin.ai/sessions/d5ecf0f28e49407492d9770342ea6e17
Open in Devin Desktop: https://app.devin.ai/desktop/session/d5ecf0f28e49407492d9770342ea6e17?variant=devin

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@linear-code

linear-code Bot commented Oct 22, 2026

Copy link
Copy Markdown

PRO-300

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-31T22:20:55.339470Z ade1a5c New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Comment thread src/adapters/MidnightAdapter.sol Resolved
@adhusson

Copy link
Copy Markdown
Contributor

@devin-ai-integration[bot] evaluate how strong the griefing can be without this pr and what we gain with it; use high but realistic rates and tokens that make the case harder (but no lower than 8 decimals for a very high value token and no lower than 6 overall)

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@adhusson Short version: without this PR the griefing is weak. It can't be done for profit and it can only pull interest forward, never take more than the remaining interest. The one place it bites is small positions in an 8-decimal, high-value token. There, a few thousand to tens of thousands of takes can push netRate to 0. What this PR gains is that a 0-unit take of a live adapter buy offer becomes an exact no-op, which removes the only way to repeat the effect without trading.

Method

  • I replayed the old onBuy formula step by step with exact integers, applying a 0-unit buy (boughtNetCredit = paidAssets = 0) at a fixed time to maturity.
  • I measured gas with the real VaultV2 (setUpRealVault), using a contract that loops 0-unit midnight.take calls on an allocator-ratified buy offer.
  • Anyone can do this while such an offer is live: 0 units consume nothing from the group, and no collateral or gate check applies.

Cost per take: about 256k gas for the first take and about 141k gas for each extra take in the same transaction. At 1 gwei with ETH at $4,000 that is about $0.56 per take. On an L2 at 0.005 gwei it is about $0.003.

Effect per 0-unit take on main: amortizedValue is rounded up and then netRate is rounded down. Each take moves not-yet-earned interest into realAssets, roughly the larger of 1 wei and netCredit * ttm / WAD wei (1 wei dominates below about $30M for a 1-year USDC position):

position APR, ttm netRate drop per take moved per take
USDC (6 dec), any size up to $1M, 30d 30% 1 to ~400k units ~1 wei = $1e-6 (up to $2.5e-6 at $1M)
USDC $100M 10%, 365d 1 unit 2,606 wei = $0.0026
WBTC (8 dec, $120k), $1 to $100M 10–30%, 1–30d 5 to 1e9 units 1 wei = $0.0012
WBTC $100M 10%, 365d 1 unit $0.0026

The worst cost-to-effect ratio is about 2:1 on a cheap L2 (WBTC at 1 wei per take), versus about 460:1 on mainnet. The attacker also does not receive the moved value. It only lifts the share price earlier and slows accrual later, so the only way to benefit is to hold shares and exit before maturity, and even then only for your share of the vault. The vault's maxRate further limits how fast the jump shows up in totalAssets.

Driving netRate to 0 on main (gas at 141k per take):

position takes interest pulled forward gas
WBTC $1k, 30% APR, 30d 20,054 $24 (all of it) 2.8B (~$11 on L2, ~$1.1k on L1)
WBTC $1k, 10% APR, 365d 75,758 $91 (all of it) 10.6B
WBTC $100k, 30% APR, 30d 2.0M $2,406 (all of it) 281B
USDC $1k to $1M more than 3M takes leaves the rate mostly intact $3 to $78 more than 420B

Once netRate is 0, any sale below par reverts with ShortfallTooHigh, because there is no remaining interest to absorb a shortfall. That market can then only be exited at par or at maturity. This is a liveness nuisance, but realistically only for dust-sized WBTC-like positions.

What the PR gains

  • 0-unit takes: they leave netRate exactly unchanged (testZeroUnitBuyKeepsNetRate, 2048 fuzz runs; it fails on main).
  • Smallest nonzero takes (2 units for 1 wei): on main they still lower netRate on small positions, by 19 to 22k units per take for $1k positions. With the PR they raise it, because Midnight rounds buyerAssets down in the adapter's favour.
  • What remains with the PR: one rounding-down of netRate per real trade (less than 1 unit, at most about netCredit * ttm / WAD wei of value). Exploiting it requires an actual trade into a live offer priced below the current netRate.

My conclusion is that this is not a meaningful exploit on main; the PR mainly closes the free, unlimited repetition and stops tiny positions from being zeroed. Whether that is worth the formula change is your and @peyha's call.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Verdict: without this PR, anyone can drift netRate down for free by retaking any live adapter buy offer with 0 units (a 0-unit take doesn't consume the offer, so it works until expiry even when the offer is filled). Each take moves about 1 wei of the market's remaining interest into realAssets, or more on large long positions. That's at most a few tenths of a cent per take, so on Base it costs the attacker about as much as it moves (0.2× to 5×), and ~100× more on mainnet. This PR makes 0-unit takes exact no-ops. But when the adapter has a live buy offer below the market's current netRate, tiny real takes (e.g. 100 units) still drift the rate as fast as before on large long positions. That last part comes from the final mulDivDown of netRate, which this PR keeps.

Per-take drift on main (0-unit takes), exact integer replay of the main formulas over 200k takes, positions bought at the listed rate:

Market Remaining interest Pulled per take Takes to reach 0% Pulled / gas cost on Base
USDC (6 dec), $100M, 20%, 1y $16.7M 2,190 wei ($0.0022) 7.6e9 0.5×
USDC, $1B, 20%, 1y $167M 21,900 wei ($0.022) 7.6e9 5×
WBTC (8 dec, $100k), 1,000 BTC, 10%, 1y $9.1M 2.6 sat ($0.0026) 3.5e9 0.6×
WBTC, 1,000 BTC, 10%, 30d $815k 1 sat ($0.001) 8.2e8 0.25×
XAUt (6 dec, ~$4k), 10k oz, 10%, 1y $3.6M 1 wei ($0.004) 9.1e8 1×
  • Pulled per take ≈ max(1 wei, ~0.8–0.9 × the value of one netRate unit), where one unit is worth netCredit·ttm/(WAD + netRate·ttm) wei. The first term comes from the mulDivUp of amortizedValue and the second from the final mulDivDown. The rate itself barely matters: it only sets the total that can be pulled.
  • Gas: 270k per 0-unit take (forge, warm loop, vault with one adapter and one market; more markets cost more). Cost assumes Base at 0.005 gwei and ETH at $3,000, which is $0.004 per take; mainnet at 0.5 gwei is $0.41. The prices are assumptions, not quotes.
  • Checked in forge on main (1e11-unit position at ~19%, 1y): 200 takes lowered netRate by 200 and raised realAssets by 531 wei (2.65 per take). The replay gives 2.6.
  • Draining a market takes 1e9–1e10 takes (2e14–2e15 gas), so the realistic damage is whatever an attacker is willing to burn, times the ratio above. Nothing is lost: the interest is recognized early, realAssets then stays flat until maturity, and maxRate caps how fast totalAssets follows. The attacker only benefits through the share of the vault they hold, so this is griefing, not profit.

With this PR:

  • 0-unit takes: zero drift. The same forge run gives netRate 5936048894 → 5936048894 and realAssets unchanged.
  • Tiny takes on an offer at or above book rate: the rate can't go down. The leak per take is smaller than the trade's own interest, which the attacker owes as debt, so the attacker pays more than they move (100-unit takes at 21% vs 20% book on USDC $100M 1y: 1.33 wei per take on both branches).
  • Tiny takes on an offer below book rate (normal when market rates fall): same drift as main once netCredit·ttm ≳ 1e18. 100-unit takes at 1 point under book give 2,189 wei per take on USDC $100M 1y and 2.52 sat on WBTC 1,000 BTC 1y, on both branches, with ~16 wei of debt for the attacker per take. Below that threshold the PR helps: WBTC 1,000 BTC 30d goes from 1.00 to 0.10 sat per take. The threshold is USDC above ~$32k at 1y or ~$386k at 30d, and WBTC above ~317 BTC at 1y or ~3,860 BTC at 30d.

So the PR closes the free path (no offer at a lower rate needed, nothing owed) and removes the 1-wei rounding term. On large long positions it doesn't bound the drift. That needs finer netRate precision: a per-second rate scaled above WAD shrinks the floor term by the same factor, at the cost of storage bits in MarketData.

Replay script
WAD=10**18; YEAR=365*86400
def V(N,r,t): return N*WAD//(WAD+r*t)                      # realAssets term
def buy_main(N,r,t,b,p):
    N2=N+b; A=-(-(N2-b)*WAD//(WAD+r*t))+p                   # mulDivUp
    return N2,(N2-A)*WAD//(A*t)
def buy_pr(N,r,t,b,p):
    N2=N+b; d=WAD+r*t
    return N2,((N2-b)*(d-WAD)+(b-p)*d)*WAD//(((N2-b)*WAD+p*d)*t)
# 0-unit take: b=p=0; tiny take of u units at offer price px: b=u, p=u*px//WAD
# excess per take = (V(after) - V(before) - sum(p)) / takes

@adhusson

Copy link
Copy Markdown
Contributor

what would be more complex single rounding like here or moving from wad to ray precision

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@adhusson Moving netRate to RAY would be more complex than this PR, and it wouldn't remove the main drift.

Complexity

  • This PR: it rewrites the two netRate formulas in onBuy and onSell and changes nothing else: same storage, interface, events and units.
  • RAY, storage: MarketData.netRate is a uint64. At RAY scale a per-second rate overflows it above about 58% APR (type(uint64).max ≈ 1.84e19 ≈ 0.58e27 / 1 year). Widening it breaks the current packing: netRate (64) + maturity (48) + index (8) + forceSellable (8) + minForceSellPrice (128) fill exactly one slot. So either minForceSellPrice shrinks, or the struct grows to a third slot.
  • RAY, guardrails: minNetBuyRate and maxNetSellRate are uint64 WAD rates packed with maxTtm and noSalesWithShortfall (checked by testStoragePacking). They either move to RAY with the same width problem, or need conversions in the two checks.
  • RAY, interface: the unit of MarketData.netRate and of the Buy/Sell event fields changes for integrators and indexers.
  • RAY, code and tests: WAD becomes RAY in realAssets, onBuy, onSell and both guardrail checks. About 37 netRate references in MidnightAdapterTest would need re-deriving, many with hard-coded values.

Effect: I replayed the old formula (round-up then round-down) at both precisions with the same setup as my previous comment. The table shows value moved into realAssets per 0-unit take:

position WAD (main) RAY this PR
USDC $1k, any APR or ttm 1 wei 1 wei 0
USDC $1M, 10% APR, 365d 26 wei 1 wei 0
USDC $100M, 10% APR, 365d 2,606 wei 1 wei 0
WBTC, $1k to $100M, 30d 1 wei 1 wei 0
WBTC $100M, 10% APR, 365d 2.2 wei 1 wei 0

The drift has two parts:

  1. Rounding amortizedValue up costs up to 1 wei of the token per take. Precision doesn't touch it, because it is in token units, not rate units. This is the part that zeroes small WBTC-like positions, so RAY leaves that case unchanged.
  2. Rounding netRate down loses about netCredit * ttm / WAD wei per take, which matters only for large, long positions. RAY divides this part by 1e9.

This PR removes both parts for 0-unit takes and drops part 1 entirely. Only part 2 remains, at most one rate unit per real trade. Adding RAY on top would shrink that remaining part too, but at the storage and interface cost above.

@adhusson

Copy link
Copy Markdown
Contributor

is there a simpler way of doing single rounding / increasing precision

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@adhusson Yes, there's a simpler form, and I've pushed it in 40c2bc0. Both functions now keep main's exact shape, (newNetCredit - amortizedValue).mulDivDown(WAD, amortizedValue * ttm), with every term multiplied by discount. Each function adds a single new local and the diff against main is a few lines:

// onBuy
uint256 discount = WAD + _marketData.netRate * ttm;
uint256 amortizedValueTimesDiscount = (newNetCredit - boughtNetCredit) * WAD + paidAssets * discount;
_marketData.netRate = (newNetCredit * discount - amortizedValueTimesDiscount)
    .mulDivDown(WAD, amortizedValueTimesDiscount * ttm).toUint64();

// onSell, shortfall branch (taken iff soldNetCredit * WAD > sellerAssets * discount)
uint256 amortizedValueTimesDiscount = (newNetCredit + soldNetCredit) * WAD - sellerAssets * discount;
require(amortizedValueTimesDiscount <= newNetCredit * discount, ShortfallTooHigh()); // main: amortizedValue <= newNetCredit
_marketData.netRate = (newNetCredit * discount - amortizedValueTimesDiscount)
    .mulDivDown(WAD, amortizedValueTimesDiscount * ttm).toUint64();

This gives the same numbers as the previous commit, since newNetCredit * discount - amortizedValueTimesDiscount is exactly the old interestTimesDiscount. All 712 tests still pass unchanged, and the overflow bound is unchanged too. MidnightAdapter is now 21,456 B, 23 B smaller than main.

I don't see anything simpler on the precision side:

  • Skip the update on a 0-unit take (if (boughtNetCredit > 0)): this is a one-line fix for the free repeatable case. Dust real trades would still compound the 1-wei round-up, so I'd rather keep the exact form.
  • Compute only amortizedValue at higher precision (mulDivUp(WAD * 1e9, discount), rate formula adjusted to match): this costs about as many lines as the exact form and still rounds twice, just with a 1e9 smaller error.
  • RAY netRate: see my previous comment for the storage and interface cost. It also doesn't remove the round-up.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ade1a5cc20

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +485 to +486
_marketData.netRate = (newNetCredit * discount - amortizedValueTimesDiscount)
.mulDivDown(WAD, amortizedValueTimesDiscount * ttm)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use overflow-safe arithmetic for the rescaled rate

For large but permitted positions and rates, scaling both operands by discount makes this calculation overflow even when the resulting rate fits in uint72. For example, netCredit may approach uint128.max, netRate may approach uint72.max, and ttm may approach uint32.max; then both the numerator multiplication inside mulDivDown and amortizedValueTimesDiscount * ttm exceed uint256, whereas the previous unscaled calculation did not. This makes otherwise valid subsequent buys revert; the mirrored shortfall-sale calculation has the same issue and should also use overflow-safe scaling.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed the numerator overflow on ade1a5cc with an actual Midnight take (no storage injection): a first buy of 1e31 asset units at price 1e11 (WAD, i.e. 1e-7) and 30-day maturity creates netCredit = 1e38 and netRate = 3858024305555555555. A subsequent zero-unit buy panics with 0x11 in the scaled numerator multiplication. The previous unscaled formula evaluates successfully, returning 3858024305555555554, within uint72.

These are extreme amounts/rates, but the position is reachable. A temporary Foundry reproduction passed; it was not added to the branch. The denominator claim is not established by this reproduction. Leaving the thread open and the implementation unchanged pending approval to extend this update with an overflow-safe fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants