Skip to content

fix(earn): aggregate mUSD balance across mainnet and Linea for estimated annual bonus#28663

Open
Kureev wants to merge 3 commits intoMetaMask:mainfrom
margelo:kureev/MUSD-628
Open

fix(earn): aggregate mUSD balance across mainnet and Linea for estimated annual bonus#28663
Kureev wants to merge 3 commits intoMetaMask:mainfrom
margelo:kureev/MUSD-628

Conversation

@Kureev
Copy link
Copy Markdown
Contributor

@Kureev Kureev commented Apr 10, 2026

Description

The "Estimated annual bonus" row in the bonus card on the mUSD asset details screen (and the Cash/Money tokens full view) was being computed against a single chain's mUSD balance. mUSD bonuses are actually distributed by Merkl across both Ethereum mainnet and Linea, so users with mUSD on both chains were seeing an estimate that only reflected one of those balances depending on which screen they opened:

  • Opening the Linea mUSD details page → estimate = Linea balance × 3%
  • Opening the mainnet mUSD details page → estimate = mainnet balance × 3%
  • Opening the Cash full view → estimate = mainnet balance × 3% (the view passes a hardcoded mainnet asset reference)

Worse, none of those views agreed with each other, and none of them agreed with the "Lifetime bonus claimed" line shown right below the estimate — which is sourced from the Linea Merkl Distributor contract and is already global across both chains. Same component, two different scopes.

This PR makes the estimate global so it lines up with the lifetime line and with what users actually accrue:

  • For mUSD assets, the balance feeding the estimate is now the sum of the user's mainnet mUSD balance and Linea mUSD balance, regardless of which chain's asset details page is being rendered. Two useSelector(selectAsset, ...) calls (one per eligible chain) are added; both run unconditionally so hook order is stable.
  • For non-mUSD eligible tokens (the AGLAMERKL test token path that the same component supports), the existing per-chain useTokenBalance(asset) behaviour is preserved untouched.
  • The aggregation is gated on isMusdToken(asset.address) so the test-token path is fully bypassed.
  • Everything downstream of the balance variable — hasBalance, formattedAnnualBonus, and the CTA ctaLabel/ctaDisabled derivation — automatically picks up the aggregate semantics. The CTA on the Linea details page will now correctly say "accruing next bonus" for a user whose only balance is on mainnet, matching the lifetime line above it.

The 3% APY is still a hardcoded display constant. Switching it to a live Merkl APR is intentionally out of scope here (the rate fluctuates weekly and the wording around "estimated" is acceptable per product), and is tracked separately.

Changelog

CHANGELOG entry: Fixed the mUSD estimated annual bonus so it reflects the user's combined mUSD balance across Ethereum mainnet and Linea instead of only the chain currently being viewed.

Related issues

Fixes: MUSD-628

Manual testing steps

Feature: Estimated annual mUSD bonus aggregates across mainnet and Linea

  Background:
    Given the user holds mUSD on both Ethereum mainnet and Linea
    And the user's mainnet mUSD balance is $15.25
    And the user's Linea mUSD balance is $4.96

  Scenario: User opens the mUSD asset details page on Linea
    When the user navigates to the mUSD asset details on Linea
    Then the "Your bonus" card is visible
    And the "Estimated annual bonus" row shows +$0.61
    And the CTA reads "Accruing next bonus"

  Scenario: User opens the mUSD asset details page on Mainnet
    When the user navigates to the mUSD asset details on Ethereum mainnet
    Then the "Estimated annual bonus" row shows +$0.61
    And the CTA reads "Accruing next bonus"

  Scenario: User opens the Cash/Money tokens full view
    When the user navigates to the Cash tokens full view
    Then the "Estimated annual bonus" row shows +$0.61

  Scenario: User has mUSD only on mainnet, opens Linea details page
    Given the user holds mUSD only on Ethereum mainnet
    And the user's mainnet mUSD balance is $100.00
    And the user's Linea mUSD balance is $0
    When the user navigates to the mUSD asset details on Linea
    Then the "Estimated annual bonus" row shows +$3.00
    And the CTA reads "Accruing next bonus"

  Scenario: User has no mUSD on either chain
    Given the user holds no mUSD on Ethereum mainnet or Linea
    When the user navigates to the mUSD asset details on either chain
    Then the "Estimated annual bonus" row shows +$0.00
    And the CTA reads "No accruing bonus"

Screenshots/Recordings

Before

After

Pre-merge author checklist

Pre-merge reviewer checklist

  • I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed).
  • I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.

Note

Medium Risk
Changes user-visible mUSD bonus/CTA state derivation by switching the balance source to cross-chain Redux lookups, which could affect displayed bonus amounts and enable/disable states if asset selection or balances are missing/misformatted.

Overview
Fixes the mUSD “Estimated annual bonus” (and downstream CTA state) to use the user’s combined mUSD holdings on Ethereum mainnet and Linea, regardless of which chain’s asset details screen is open.

This gates the aggregation behind isMusdToken, pulls per-chain balances via selectAsset using MUSD_TOKEN_ADDRESS_BY_CHAIN + CHAIN_IDS, and updates tests to render with an explicit Redux state (including backgroundState) so the new selectors can resolve assets during unit tests.

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

@Kureev Kureev requested a review from a team as a code owner April 10, 2026 13:43
@github-actions
Copy link
Copy Markdown
Contributor

CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes.

The component now reads state.engine.backgroundState.AccountsController via
selectAsset (added in fa2bad4 for cross-chain mUSD balance aggregation),
which crashed all 15 tests because renderWithProvider was called with no
state. Pass DeepPartial<RootState> with the standard backgroundState mock.
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 61426a4. Configure here.

...overrides,
});

const mockInitialState: DeepPartial<RootState> = {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

New mUSD aggregation branch is completely untested

Medium Severity

The createMockAsset helper uses address '0x8d652c6d4A8F3Db96Cd866C1a9220B1447F29898' (aglaMerkl), so isMusdToken(asset.address) returns false in every test. The new mUSD aggregation branch — which sums mainnetMusdAsset?.balance and lineaMusdAsset?.balance via two selectAsset calls — is never exercised. Every test follows the non-mUSD fallback path (parseFloat(liveBalance || asset.balance)), leaving the core behavior change of this PR completely uncovered. This violates the "Different code paths — all if/else branches" requirement from Unit Testing Guidelines.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: Unit Testing Guidelines

Reviewed by Cursor Bugbot for commit 61426a4. Configure here.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants