Skip to content

built Built. This is a decision record, not documentation.

What is still current: earnUSD ships as described: a listed multi-strategy fund with its own share-rate series, backfilled history, strategy brief and statistics tower.

Landed: v0.43.0

Header updated 2026-09-14. The body below is frozen history. All plans.

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) ​

RoleAddress
Share token (TokenizedShareManager)0x4ce1ac8f43e0e5bd7a346a98af777bf8fbea1981
Core vault (shareManager() ↔ share token's vault())0x014e6DA8F283C4aF65B2AA0f201438680A004452
Price oracle0x827044735c9708a2cf850e7Ea37EBa43bc786028
FeeManager0x72fa23f40e08eB9E45953233b2Dd9665E347e8Dc
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) / priceD18

earnETH 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 == totalShares

So 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 ​

ParameterValue
Oracle0x827044735c9708a2cf850e7ea37eba43bc786028
Asset argUSDC 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
Selector0xa3bdae3e + 32-byte left-padded asset
Stored rate1e30 / priceD18 (USDC per share)
Deploymentblock 24,602,425, 2026-03-07 01:54:35 UTC (getReport still returns 0)
First reportts 2026-03-07 02:02:23 UTC, priceD18 = 999960000000000000000000000000 → 1.00004
START_UTC2026-03-07 06:00 UTC, the first clean 6h boundary that already has a report
Cadenceroughly 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.0M

Keeping 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 / srUSDe appear in both sleeves and USDe is 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-funds is entirely refresher-driven: token_yield_apy has no per-token registry, no FK and no allowlist on token_address, and there is no funds table anywhere in the schema. The canonical "add a USD fund" commit touched no SQL either.
  • No /portfolio scope, as a NAMED follow-up rather than a silent gap: #627. No portfolio_tokens row, so no JIT_RATE_GETTERS entry 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, since share_rate history from inception is exactly what a variable_rate valuation source needs.
  • No covered-assets / asset-narratives entry. Those are yield ASSETS; a managed fund is not one, and keeping its ticker out of assets is what keeps it off /asset-profiles.
  • No erc4626-universe entry — it is not an ERC-4626.
  • No new brand art. LidoIcon and MellowIcon already exist.

Server steps after this reaches prod ​

  1. /opt/onchain-credit/scripts/run-cron.sh backfill-lido-earn-usd.ts (~665 snapshots, about 5 minutes; needs ETHEREUM_ARCHIVE_RPC_URL). Writes share_rate, supply_apy, block_at_snapshot and total_supply.
  2. /opt/onchain-credit/scripts/run-cron.sh backfill-apy-30d.ts (pure DB) to fill apy_30d.
  3. /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) so EARNETH_STALL_NOTES still matches. The script does not write total_supply, so the earnETH TVL series is untouched.
  4. Re-run the deploy. /multi-strategy-funds prerenders with revalidate = 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.

Private documentation. creddit.xyz