Skip to content

fix: price staked ETH at parity with native currency instead of $0 - #10067

Open
gomesalexandre wants to merge 1 commit into
MetaMask:mainfrom
gomesalexandre:fix_staked_eth_zero_price
Open

fix: price staked ETH at parity with native currency instead of $0#10067
gomesalexandre wants to merge 1 commit into
MetaMask:mainfrom
gomesalexandre:fix_staked_eth_zero_price

Conversation

@gomesalexandre

@gomesalexandre gomesalexandre commented Sep 2, 2026

Copy link
Copy Markdown

Summary

Staked ETH was valued at $0 throughout the new unified assets-controller — in the aggregated-balance selectors and in every public asset getter (getAccountAssetByID, getAccountAssetsByIDs, getAccountAssetsByScope, getAssets, getAssetsPrice).

The staking-vault contract's own asset ID (e.g. eip155:1/erc20:0x4fef9d741011476750a243ac70b9789a63dd47df) is never a priced token — the Price API always returns null for it. The legacy packages/assets-controllers/src/balances.ts handles this by aliasing a staked-native position to the native token's price (added in #8141, whose own description says native/staked-native balances were previously excluded and undercounted). The new assets-controller package re-derives that calculation from scratch and never carried the alias over.

Evidence

Live API + real code, before the fix:

GET price.api.cx.metamask.io/v3/spot-prices
  "eip155:1/erc20:0x4fef9d741011476750a243ac70b9789a63dd47df": null   <- the vault
  "eip155:1/slip44:60": { "price": 2408.068… }                        <- ETH

Holdings: 1 ETH liquid + 2 ETH staked
  old balances.ts calculateBalanceForAllWallets -> 7224.21
  new selectors/balance.ts (all entry points)    -> 2408.07
  understatement = 4816.14  (66.67% of this portfolio hidden)

The fix

  • STAKING_CHAIN_CONFIG (new, in staking-contracts.ts) is a single source of truth mapping each staking-supported chain to both its vault address and its native SLIP-44 coin type together. STAKING_CONTRACT_ADDRESS_BY_CHAINID (the existing public export) is now derived from it rather than hand-maintained separately, so the two pieces of a chain's staking config can't drift apart.
  • resolvePriceLookupAssetId(assetId) resolves a staking-vault asset ID to its chain's native asset ID, or returns the input unchanged. It's used unconditionally, not merely as a fallback on a missing entry — so a stale price recorded directly under the vault's own key (e.g. from persisted pre-fix state) can never win over the correct native price.
  • selectors/balance.ts's getPriceDatumFast and AssetsController#getAssetFromState (which backs every public getter) both route through this resolver.
  • PriceDataSource maps a staking-vault ID to its native asset ID before both fetch-request sites, so the native price is actually requested, not merely not-dropped by the isPriceableAsset filter (which now also excludes the vault address, since requesting it can only ever return null).
  • The same resolve-before-check fix is applied to AssetsController#fetchMissingPricesWithoutCache and DetectionMiddleware's price-queuing (maybeQueue) — both previously checked presence against the raw vault key, which could let a stale entry there suppress a legitimate re-fetch of the native price.

Testing

  • packages/assets-controller/src/data-sources/evm-rpc-services/utils/staking-contracts.test.ts (new): direct coverage of getNativeAssetIdForStakedAsset/resolvePriceLookupAssetId — mainnet, Hoodi, case-insensitivity, wrong-chain, unrelated-erc20, malformed-input.
  • selectors/balance.test.ts: a real mixed liquid+staked portfolio (was 2000, now 6000 for 1 ETH + 2 staked ETH at 2000/ETH); a boundary test for an unrelated erc20 on a staking-supported chain; a regression test proving the native price wins even when a stale price is also recorded under the vault's own key.
  • data-sources/PriceDataSource.test.ts: the fetch request for a staked-only balance targets the native asset, never the vault address; a mixed native+staked balance only requests the native asset once (no duplicate).
  • middlewares/DetectionMiddleware.test.ts: a staking asset is still queued for a price update even when a stale price exists under its own vault key.
  • AssetsController.test.ts: the public getAccountAssetByID getter returns the correct native-derived price and fiatValue for a staked position.

All new tests were verified genuinely red-before/green-after via git stash on the source fix (each fails with the exact predicted symptom against unfixed code).

$ yarn workspace @metamask/assets-controller jest --coverage=false
Test Suites: 34 passed, 34 total
Tests:       998 passed, 998 total

yarn lint:tsc (the repo's real project-references-aware typecheck, run from a fully cache-cleared state) and yarn eslint on every changed file are both clean.

Review notes

  • Went through three rounds of adversarial Codex review. Round 1 found the core fix was too narrow (only the balance selector, not the public getters) and that the fallback-on-absence logic could be permanently defeated by a stale persisted price under the vault key. Round 2 found the "fix" for the type-level anti-drift check on the two staking maps was actually a no-op (Record<string, string> collapses keyof typeof back to plain string, so nothing was enforced) — replaced with the single STAKING_CHAIN_CONFIG map instead. It also found the same stale-key-suppression bug existed in three additional enqueue paths (#fetchMissingPricesWithoutCache, DetectionMiddleware, PriceDataSource's detected-assets branch) — all fixed and covered by new regression tests.
  • Round 3 flagged what looked like a blocking type error (parseCaipAssetType argument type mismatch) using a raw tsc -p packages/assets-controller/tsconfig.json invocation. I verified this directly: that invocation bypasses this monorepo's TypeScript project-references build mode, and produces ~80 clearly-false errors on completely untouched pre-existing lines in the same files (e.g. Property 'messenger' does not exist on type 'AssetsController', which is trivially false). A fully cache-cleared run of the repo's actual CI command (yarn lint:tsc, i.e. tsc --build) is genuinely clean. Flagging this in case a reviewer's own local tsc -p invocation reproduces the same false positive.
  • Codex ran synchronously each time (killing only its own tracked PID when it stalled once, never a broad pattern-match kill).

Note

Medium Risk
Changes fiat valuation and price-fetch behavior for staking vault balances across selectors and getters; scope is well-tested but incorrect aliasing would misstate portfolio value.

Overview
Staked vault positions (e.g. mainnet stETH contract) were shown at $0 in the unified assets-controller because the Price API never prices the vault token itself. This PR aliases staking-vault asset IDs to the chain’s native asset for all price reads and fetches.

STAKING_CHAIN_CONFIG now pairs each supported chain’s vault address with its native SLIP-44 id; resolvePriceLookupAssetId maps vault IDs to native IDs (always, not only when the vault key is missing) so stale assetsPrice entries under the vault cannot block correct fiat. That resolver is used in aggregated balance selectors, getAccountAssetByID (and related getters), PriceDataSource (fetch + detected-asset queuing, with dedupe), DetectionMiddleware, and the force-refresh missing-price path. isPriceableAsset treats vault contracts as non-priceable so batches request native ETH instead.

Regression tests cover public getters, price API request shape, duplicate native fetches, stale vault-key suppression, and portfolio totals.

Reviewed by Cursor Bugbot for commit ca59661. Bugbot is set up for automated code reviews on this repo. Configure here.

Staked positions were valued at $0 across the aggregated-balance
selectors and every public asset getter (getAccountAssetByID,
getAccountAssetsByIDs, getAccountAssetsByScope, getAssets,
getAssetsPrice). The staking-vault contract's own asset ID is never a
priced token -- the Price API always returns null for it -- and the
new unified assets-controller re-derived the legacy balances.ts
staked-native price aliasing without carrying it over.

Adds a single source of truth (STAKING_CHAIN_CONFIG) mapping each
staking-supported chain to its vault address AND native SLIP-44 coin
type together, plus resolvePriceLookupAssetId() which both the
selector and AssetsController#getAssetFromState now use
unconditionally -- not just as an absence-fallback, so a stale price
recorded directly under the vault's own key (persisted pre-fix state,
or a partial currency-switch refresh) can never win over the correct
native price.

PriceDataSource now maps a staking-vault ID to its native asset ID
before both fetch-request sites, so the correct price is actually
requested rather than merely not-dropped, and isPriceableAsset
excludes the vault address so the app stops issuing a request that
can only return null. The same resolve-before-check fix is applied to
AssetsController#fetchMissingPricesWithoutCache and
DetectionMiddleware's price-queuing so a stale vault-keyed price entry
can't suppress a legitimate re-fetch of the native price through any
of the three enqueue paths.
@gomesalexandre
gomesalexandre marked this pull request as ready for review September 2, 2026 00:00
@gomesalexandre
gomesalexandre requested review from a team as code owners September 2, 2026 00:00
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.

1 participant