Lido Earn USD as a multi-strategy fund, August 2026: plan
Adds Lido Earn USD (earnUSD) to /multi-strategy-funds as a first-class USD fund: a token_yield_apy share-rate series, a one-time history backfill, a series getter, a rendered row with its strategy brief and statistics tower, and the fixture / spec / docs that go with them. Ships as ONE PR against staging.
Every on-chain figure below was executed against Ethereum mainnet on 2026-08-20 (cast, plus raw eth_call against an archive node for the historical blocks). Selectors were computed with cast sig, never typed from memory.
What the vault is
The dollar sibling of Lido Earn ETH. Consumer brand is Lido; the vault contracts and the curation are Mellow's "Flexible (Core) Vaults" v3 MetaVault primitive, which is why the row leads with the Lido mark and the MANAGER filter lists Mellow — the same split the earnETH row already uses.
It is a meta-vault over two internal sleeves, roughly 27% "conservative" and 72% "experimental", with about 1% idle at the top level. The conservative sleeve supplies money markets (Maple syrupUSDC / syrupUSDT, SparkLend, a Sentora PYUSD MetaMorpho vault, Aave v3) and holds sUSDe; its mandate also whitelists Balancer v3 + Aura and a PT-reUSD/USDC Morpho market with borrow permission. The experimental sleeve runs Aave v3 e-mode 2 leverage (supply sUSDe/USDe, borrow USDC/USDT), Twyne leveraged PT-srUSDe/USDe vaults, Pendle router positions, and bridges USDT/USDe/sUSDe/syrupUSDT to Plasma and Mantle.
That mandate is what the fund's three yield-source categories are tagged from, per the categorisation rule in architecture: Lending, Leveraged carry, Fixed rate. Not Liquidity provision — the Balancer v3 and Aura exposure is a whitelist, i.e. a permission, and the rule asks for something the mandate is BUILT on, a bar that an open position does not clear on its own. And not Basis & funding — the fund levers a staked synthetic dollar whose own yield comes from perpetual funding, which is its issuer's trade on a line the manager can replace, exactly the case Fluid Lite USD already settles.
Contract map (all verified by on-chain round-trip)
| Role | Address |
|---|---|
Share token (TokenizedShareManager) | 0x4ce1ac8f43e0e5bd7a346a98af777bf8fbea1981 |
Core vault (shareManager() ↔ share token's vault()) | 0x014e6DA8F283C4aF65B2AA0f201438680A004452 |
| Price oracle | 0x827044735c9708a2cf850e7Ea37EBa43bc786028 |
| FeeManager | 0x72fa23f40e08eB9E45953233b2Dd9665E347e8Dc |
Subvault 0 → earnUSDc (conservative) | 0x77B9441d5Cb89fca435190A9B6D108ad4B00ccFd |
Subvault 1 → earnUSDe (experimental) | 0xe3e0111e31FA3AEB7A528128F2DbAe1C15397242 |
Share token: name() = "Lido Earn USD", symbol() = "earnUSD", decimals() = 18, Ethereum mainnet only (no code at that address on Base, Arbitrum or Optimism). The address was confirmed three ways rather than taken from a listing site: the on-chain name/symbol, the vault() ↔ shareManager() round trip, and Mellow's public scripts/ethereum/earnUSD.s.sol, which hardcodes that vault with the same name and symbol.
It is not an ERC-4626. asset(), totalAssets() and convertToAssets() all revert, exactly as they do on earnETH. So it gets no MANAGED_STRATEGY_VAULTS entry, and its price has to come off the oracle.
The three traps this wiring walks past
1. It has its own oracle
getReport(USDC) on earnETH's oracle (0xada1f4c2…) reverts with 0xee84f40b (unsupported asset), returning the USDC address in the error data. A Mellow oracle is per vault and per asset. earnUSD's was resolved from vault.oracle() and answers for USDC, USDT and USDe.
2. The scaling is 1e30, not 1e18
Mellow quotes priceD18 as RAW shares per RAW asset unit — IOracle.sol says "shares = (assets * priceD18) / 1e18" and DepositQueue._claim implements it as mulDiv(request._value, priceD18, 1 ether) over a raw amount. Both sides are raw, so the decimals have to be reintroduced:
assetPerShare = 10^(18 + shareDecimals − assetDecimals) / priceD18earnETH is 18-dec over 18-dec, which collapses to the familiar 1e18/priceD18. earnUSD is an 18-dec share over 6-dec USDC, so its numerator is 1e30. Reusing earnETH's constant stores 1.026e-12 instead of 1.026 — wrong by a trillion, with no revert and no type error to catch it. The registry therefore carries oracleAssetDecimals per fund and derives the numerator from it, and token-yields.test.ts decodes both vaults' recorded priceD18 words and asserts each one's par-range check rejects the sibling's numerator.
Independent corroboration of the 1e30 par scale: the deploy script seeds the first report as Chainlink USDC/USD (8-dec) latestAnswer() * 1e22 ≈ 1e30.
3. TVL reads totalShares(), not totalSupply()
Shares priced at a report but not yet claimed by the depositor are already a claim on the assets and already inside the reported NAV, while remaining unminted as ERC-20. At block 25,795,199 the split is exact:
totalShares() 38,821,633.81 (0x3a98ef39)
totalSupply() 28,599,940.62
allocatedShares() 10,221,693.19
─────────────
totalSupply + allocatedShares == totalSharesSo totalSupply understates this vault by 26%. The registry gains a supplySelector override, honoured by both the live refresher and backfill-token-supply.ts, and the funds page passes the same selector to its live TVL read so the KPI and the chart under it are one basis.
earnETH has the same split, and at head it reads only ~1.2%, which is why copying its supply read would have looked right here. Sampled across its history it is not small and not stable: 14.7% at block 24,700,000, 2.9% at 25,000,000, 3.0% at 25,300,000, 1.4% at 25,600,000, 1.2% at head — so earnETH's published TVL series carries a time-varying 1.2-15% understatement, the same SHAPE error one order smaller. earnETH deliberately stays on totalSupply in this PR: switching it would silently restate an already-published TVL series, and that re-derivation should be a deliberate change with its own backfill, not a side effect of adding a sibling. Tracked as #626.
Fees: already in the price, do not deduct twice
The on-chain FeeManager reads depositFeeD6 = redeemFeeD6 = performanceFeeD6 = protocolFeeD6 = 0. That is not a free vault: IOracle.submitReports requires that "Submitted prices MUST reflect protocol and performance fee deductions", so Lido's advertised 10% performance / 1% platform fees are taken off-chain, inside the reported NAV. The stored share rate is therefore already a net-of-fee investor return, and the fund's brief says so.
Price history
| Parameter | Value |
|---|---|
| Oracle | 0x827044735c9708a2cf850e7ea37eba43bc786028 |
| Asset arg | USDC 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 |
| Selector | 0xa3bdae3e + 32-byte left-padded asset |
| Stored rate | 1e30 / priceD18 (USDC per share) |
| Deployment | block 24,602,425, 2026-03-07 01:54:35 UTC (getReport still returns 0) |
| First report | ts 2026-03-07 02:02:23 UTC, priceD18 = 999960000000000000000000000000 → 1.00004 |
START_UTC | 2026-03-07 06:00 UTC, the first clean 6h boundary that already has a report |
| Cadence | roughly daily, on a clock that WANDERS (12:50 UTC in March, 05:55 in August) |
Expect a step function. Reports land about once a day, so on the 6h grid three of every four snapshots repeat. That is the published price standing still, not a missing snapshot. Do not run a gap repair across the repeats.
Expect the 24h column to swing in both directions, which is the part that reads as a bug. A 24h window on a daily-report series contains zero, one or two reports depending on where the report clock sits relative to the snapshot boundary, so the reading is 0%, about the daily rate, or about double it. Over the 661 backfilled supply_apy values, 25 are exactly 0, 4 are negative (the markdown below) and 9 are above 15%, topping out at 22.77%; the extremes are adjacent (2026-07-10 at 19.2% then 07-11 at 0%). The 7d and 30d columns always span several reports and carry none of it — 7d has never read 0.00% for this fund. The 30d column is the steadier read and is the page default for that reason, but it is not exact either: the ratio spans the two REPORTS the window endpoints resolve to while the annualisation divides by the SNAPSHOT span, worth ±3.3% relative on a 30d window (measured 2026-08-20: 7.198% printed against 6.975% actually accrued over 30.926 days).
The drift is measured, not assumed. Read off the oracle at the stored blocks, the report standing at the 18:00 snapshot was stamped 12:50 UTC on 2026-03-20, 13:08 on 05-20, 07:41 on 07-20 and 05:55 on 08-20; across the 163 steps in the series the snapshot at which the price moves is 12:00 UTC 102 times, 18:00 UTC 52 times and 06:00 UTC 8 times. Nothing keys off a publication hour, and nothing should.
An archive RPC is required: eth.drpc.org serves historical eth_call back to inception, while ethereum-rpc.publicnode.com answers HTTP 403 for any non-recent block.
The series is not quite monotonic
The backfill reproduced all eleven independently sampled reference points exactly, and turned up one thing sampling missed: a single −0.369 ppm markdown on 2026-06-19 12:00 UTC (block 25,351,484). Verified against the oracle directly at the two adjoining blocks; isSuspicious is false on both, well inside the oracle's 0.1% flag and 0.5% rejection rails. It is a real, tiny published NAV decrease, not a decode artefact, and it is the concrete reason nothing downstream may assume this index only rises.
The tracked record starts later than the backfill does
The backfill reaches back to the first report (2026-03-07 06:00) on purpose: the price is real, and trimming at read time is cheaper than re-deriving a start later. But the row's series getter opens at 2026-03-12 12:00, which is the first snapshot at which the vault held depositor capital ($2.0M). What sits before it is not a track record:
2026-03-07 06:00 .. 03-11 00:00 rate 1.000040 0.00 shares $0
2026-03-11 06:00 .. 03-12 06:00 rate 1.000602 3.00 shares $3
2026-03-12 12:00 rate 1.000792 1,998,426 shares $2.0MKeeping it costs a TVL chart that opens on 3.75 days pinned at $0 and a +5.6bp step taken on $3 of capital sitting inside the row's since-inception return — 5.6bp of a 259bp figure, about 2.2% of it — while the annualisation is stretched over five days the fund could not earn in (5.7885% against 5.8023% YTD APY). YOETH_LAUNCH_MS already trims yoETH's pre-activation run for the same reason: a seeding step is not depositor yield.
Risk shape worth knowing (not rendered as a verdict)
- NAV is curator-reported, not trustless. A single oracle-updater key pushes an off-chain figure; nothing on chain recomputes it from positions.
- A loss cannot be marked down quickly. Reports moving the price more than 0.5% are rejected and more than 0.1% are flagged, so a sharp impairment must walk down in daily steps or go stale against the 20h timeout. Published NAV will lag a genuine drawdown.
- Most of the book is invisible on Ethereum. The experimental sleeve's mainnet token balances explain under 10% of its value; the rest sits in Aave e-mode and Twyne leveraged positions or is bridged to Plasma and Mantle.
- Ethena concentration.
USDe/sUSDe/srUSDeappear in both sleeves andUSDeis itself an accepted deposit asset. - Thin exit liquidity, and every contract is an upgradeable proxy.
Per the page's own rule the strategy paragraph states no verdict; it says what the fund is, that the price is a figure the manager publishes, and that most of the book cannot be read off Ethereum.
Scope
In: refresher registry entry (generalised mellow-oracle kind + the supplySelector override + the isSuspicious guard), the two edits to scripts/backfill-earneth.ts that keep the sibling's two writers in step with it, scripts/backfill-lido-earn-usd.ts, the getLidoEarnUsdSeries reader, the page row, the row-icon key, fixture seed row, e2e + unit tests, docs.
Two rules the generalised branch has to carry
A flagged report is not a price. Word 2 of Mellow's DetailedReport is isSuspicious, and it is not advisory. Oracle.submitReports stores every report but hands it to vault.handleReport() only when the deviation checks pass; a flagged one waits for acceptReport(address,uint256,uint32) (0x6c038d03, present in this oracle's bytecode) to clear the flag and only then reaches the vault. So a stored isSuspicious == true means "submitted, flagged, NOT in force" — the vault will not convert a deposit or a redemption at it. The refresher throws on it (no row, one greppable [token-yields/fail] line); both backfills skip the snapshot. securityParams() on this vault: flag at 0.1% relative, hard reject at 0.5%, timeout 72,000s — so the level error is capped near 0.5% while the derived ones are not (a 0.5% step annualises to roughly +500% on 24h and sits inside the 30d window for a month), and a flagged report that is superseded rather than accepted would leave a permanent phantom, since nothing rewrites share_rate after the fact. No suspicious report is outstanding on either vault today.
The two earnETH writers stay bit-identical. Generalising the branch moved the arithmetic from 1 / (Number(priceD18) / 1e18) to Number(10^(pow+18) / priceD18) / 1e18 for earnETH as well as earnUSD. Those are the same value mathematically and not the same double — over 200k sampled priceD18 values in earnETH's range they differ in the last bit ~23% of the time. Nothing rendered moves, but flatPeriods groups a stall by EXACT float equality with a 144h threshold and annotations keyed on the run's start ISO, so a 1-ULP seam inside a stall run splits it: both halves can fall under the threshold and the band vanishes, or the tail survives with its annotation no longer matching. earnETH's ~27-day April-May band is how the loss behind it became visible at all. scripts/backfill-earneth.ts therefore moves onto the same integer expression. Verified this seams nothing existing: at priceD18 = 994514895251577425, the value throughout that band, both forms return 1.0055153570596196, which is the stored figure.
Out, deliberately:
- No SQL migration.
/multi-strategy-fundsis entirely refresher-driven:token_yield_apyhas no per-token registry, no FK and no allowlist ontoken_address, and there is no funds table anywhere in the schema. The canonical "add a USD fund" commit touched no SQL either. - No
/portfolioscope, as a NAMED follow-up rather than a silent gap: #627. Noportfolio_tokensrow, so noJIT_RATE_GETTERSentry and no per-wallet re-book repair, and a holder's position is not tracked by this PR. That leaves earnUSD the only fund on the page with no ticker, no accounting book and no seed row — defensible while nothing lists it as collateral on a covered venue, and cheap to close once something does, sinceshare_ratehistory from inception is exactly what avariable_ratevaluation source needs. - No
covered-assets/asset-narrativesentry. Those are yield ASSETS; a managed fund is not one, and keeping its ticker out ofassetsis what keeps it off/asset-profiles. - No
erc4626-universeentry — it is not an ERC-4626. - No new brand art.
LidoIconandMellowIconalready exist.
Server steps after this reaches prod
/opt/onchain-credit/scripts/run-cron.sh backfill-lido-earn-usd.ts(~665 snapshots, about 5 minutes; needsETHEREUM_ARCHIVE_RPC_URL). Writesshare_rate,supply_apy,block_at_snapshotandtotal_supply./opt/onchain-credit/scripts/run-cron.sh backfill-apy-30d.ts(pure DB) to fillapy_30d./opt/onchain-credit/scripts/run-cron.sh backfill-earneth.ts— a FULL re-run of the sibling's history, ~795 snapshots and about 5 minutes. This is the other half of keeping earnETH's two writers bit-identical: aligning the script on the refresher's integer expression stops them diverging from here on, but the rows already stored were written by the float form, so until the history is rewritten a stall run straddling this deploy could still seam. Rehearsed on staging: 118 of 795 rows change, all of them in the last bit (e.g.1.0001999992368649→1.000199999236865), no rendered figure moves, and both long flat bands survive with identical start keys (2026-02-02 18:00, 732h; 2026-04-18 18:00, 642h) soEARNETH_STALL_NOTESstill matches. The script does not writetotal_supply, so the earnETH TVL series is untouched.- Re-run the deploy.
/multi-strategy-fundsprerenders withrevalidate = 1800, so a backfill that lands after the build leaves the page stale for up to 30 minutes; re-running forces the re-prerender.
backfill-token-supply.ts is not needed: this backfill writes total_supply itself, and that script only touches rows where the column is NULL.