Skip to content

feat(src): Earn deposit flow - #2974

Open
aristidesstaffieri wants to merge 40 commits into
masterfrom
feat/earn-deposit
Open

feat(src): Earn deposit flow#2974
aristidesstaffieri wants to merge 40 commits into
masterfrom
feat/earn-deposit

Conversation

@aristidesstaffieri

@aristidesstaffieri aristidesstaffieri commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an Earn deposit flow to the extension. From a new Earn tile on the Home action row, the user picks a supported token, sees the Blend Fixed pool's supply APY, enters an amount, reviews projected earnings, and submits a submit invocation against the pool contract. Gated to networks with an allowlisted pool — pubnet and testnet only.

Deposits use Blend request_type 2 (SupplyCollateral), not 0, so the position lands in collateral_tokens and the user's borrowing power stays open. Review reads before/after from total_tokens for that reason.

Scope: deposit only. No Positions tab and no withdraw — the Home tile is the only entry point and Home's tabs stay Tokens | Collectibles. If the account doesn't hold the selected token, the flow offers buy / swap / transfer remedies inline, including a swap-within-earn branch that reuses the Swap views inside Earn's sheet stack. Depends on freighter-backend-v2's /protocols/blend/* endpoints.

What's in this PR

  • @shared/helpers/soroban/blend.ts — request/submit op encoding. UDT ScMaps are hand-built because nativeToScVal sorts keys with localeCompare, which is not byte order.
  • @shared/api/{helpers,types}/blend.ts — client for earn-options, pools, and supplied-tokens. null USD/APY means unavailable and is never coalesced to 0.
  • @shared/constants/blend.ts — pool allowlist per network plus isEarnSupportedNetwork, which gates the tile and the route.
  • popup/views/Earn/ + popup/constants/earn.ts — step machine modelled on Send (all visited steps stay mounted). Sheets — pool details, insufficient-balance, network fee, review, swap — are owned by their host step, not steps themselves.
  • popup/components/earn/ — the five screens and their sheets, CTA-state and projected-earnings helpers, pool-stat formatting, asset-icon resolution.
  • popup/helpers/blendDeposit.ts — build + simulate. Auth comes back as sorobanCredentialsSourceAccount, so no authorizeEntry round-trip is needed, including on hardware wallets.
  • popup/components/{amount,swap}/ — extracted percentageAmount helpers and pinned the Swap footer/receive side so the reused views fit a fixed-height sheet. No behavior change for Send or standalone Swap.
  • background/messageListener/handlers/{get,dismiss}EarnIntro.ts — persists the one-time intro interstitial.
  • popup/metrics/earn.ts + constants/metricsNames.ts — funnel instrumentation. No amounts or fiat on any earn event; asset codes, pool_id, and apy only.
  • locales/{en,pt}/translation.json + translationParity.test.ts — new strings and a test that fails on missing or empty parity.

Notes for the reviewer

  • XLM is not held back from a deposit. A submit resource fee is ~546k stroops (~5,000× the inclusion fee), so a naive Max on XLM simulates into insufficient balance; the amount screen surfaces that as a fee shortfall rather than silently reserving.
  • Pool description prose is keyed by pool ID, not generic — the Fixed pool's admin key is verifiably burned, which is what the copy asserts.

Test plan

  • Unit tests pass — yarn jest blend earn Earn percentageAmount SwapAmount.layout translationParity metricsNames (14 suites, 139 tests)
  • Manual deposit against mainnet Fixed pool CAJJZSGM…BXBD
  • CI green
  • Manual pass on a hardware wallet

Followups

  • Positions tab / withdraw.

Example Flow

Screen.Recording.2026-08-24.at.9.16.37.AM.mov
Screen.Recording.2026-08-24.at.9.32.29.AM.mov

@aristidesstaffieri aristidesstaffieri self-assigned this Aug 20, 2026
@aristidesstaffieri aristidesstaffieri changed the title Feat/earn deposit feat(src): Earn deposit flow Aug 20, 2026
  First piece of the Earn feature: the pure encoding layer for depositing
  into a Blend v2 pool. No UI yet.

  Adds `buildBlendRequestScVal` and `buildBlendSubmitOp` for constructing
  `pool.submit(from, spender, to, requests)`, plus the per-network Fixed
  Pool allowlist mirroring freighter-backend-v2's `configs/earn-pools.json`.

  Deposits use request_type 2 (SupplyCollateral), following Blend's own
  integration guidance. Note this lands the position in `collateral_tokens`
  rather than `supplied_tokens`, so consumers should read `total_tokens`.

  The Request struct's ScMap is hand-built rather than going through
  `nativeToScVal`, which sorts map keys with `String.localeCompare`. Locale
  collation ignores `_`, so it is not byte order in general — it would
  invert `r_two` vs `reactivity`. The three keys here happen to agree, but
  relying on that is a trap for the next struct.

  Because from/spender/to all equal the transaction source account,
  simulation emits source-account credentials and the envelope signature
  covers the auth — no `authorizeEntry` round-trip is needed, which is what
  lets hardware wallets use the ordinary signing path.

  Also adds BLEND_DEPOSIT_XLM_FEE_BUFFER: a Blend submit's resource fee
  measures ~546,395 stroops (0.0546 XLM) against the live pool, roughly
  5,000x the inclusion fee that `getAvailableBalance` accounts for. Without
  the buffer, a Max deposit of XLM simulates into insufficient balance.

  Verified against the live mainnet and testnet Fixed pools; the golden XDR
  fixture is cross-checked byte-for-byte against `nativeToScVal` with an
  explicit type spec and matches `scRequestVec` in wallet-backend's
  integration test infrastructure.
  Wires up everything below the Earn UI: the backend client, the route and
  step machine, persistent first-run state, and the Home tile that opens it.
  Clicking Earn now shows the interstitial on first run and lands on an
  empty token picker.
  The first Earn screen with real data behind it: pool-supported assets
  split into what the account holds and what it does not, each with the
  pool's headline rate, and a sheet offering the ways to acquire a token
  the account has none of.
  Amount entry, pool card with current rate, percentage buttons, and the
  pool details sheet. The CTA builds and simulates the real Blend deposit
  via blendDeposit.ts.

  Max holds back an XLM buffer: getAvailableBalance nets out only the
  inclusion fee, but a Blend submit is dominated by its resource fee
  (~0.0546 XLM), so the raw balance would fail simulation. The fee check
  runs after the CTA gate so an unaffordable XLM deposit reads as
  insufficient funds rather than a missing-fee problem.

  Moves getAmountFontSizeClass/buildFiatLineText out of swap/ into
  amount/helpers — they are generic and Earn needs them too.

  The CTA stops at a successful simulation: stepping into DEPOSIT_CONFIRM
  would hit SendingTransaction, which submits on mount, and the review
  sheet does not exist yet.
  Completes the deposit path — the amount CTA simulates, opens a review
  sheet, and Confirm signs and submits.

  useSimulateEarnDeposit returns the same State<SimulateTxData> shape Send
  and Swap produce, so the shared FeesPane works unchanged; the Blockaid
  scan runs on the prepared transaction, not the pre-assembly build.
  projectEarnings uses simple interest, reproducing the design's worked
  example exactly ($500 at 16.94% -> $84.70/yr).

  Uses dedicated submit/terminal components rather than useSubmitTxData and
  SendingTransaction: the former registers the destination as a recent
  address (here, a pool contract) and signs via the classic thunks; the
  latter hardcodes a Send-shaped summary.

  A failed submission returns to the amount screen with a banner, with the
  terminal blanked for that frame so it never flashes.
  Tapping a token the account holds none of now opens a swap inside the Earn
  flow, pinned to that token, returning to the picker with a toast and the
  new balance. Replaces the interim hand-off to the Swap route.

  EarnSwap is a sibling of the Swap route, owning its own sub-steps so the
  picker stays mounted underneath. Shared additions (all optional, existing
  behaviour preserved): onDone/onClose/onDismissError on TransactionConfirm,
  onDismiss on SubmitFail, isDestinationLocked on SwapAmount, and the
  quote-expiry recovery extracted into useSwapSubmitQuoteExpiry so a stale
  quote does not dead-end a user mid-deposit.

  resolveSwapDestination reads the canonical off the SAC's name() for
  zero-balance tokens, which the earn catalog identifies only by contract
  address — verified against the live contracts.

  Sets ?swap=true while the branch is active so useIsSwap classifies it
  correctly without needing to change.
  The build's i18next scanner had been writing new keys into both locale
  files with empty values, and that output rode along in the four preceding
  Earn commits. i18next returns "" for an empty value (a missing key falls
  back to the key text), so every Earn label rendered blank.

  Fills en and pt for all 47 keys, and adds an `i18n empty values` guard over
  both bundles — the class fix, since the scanner adds blank entries on every
  build and nothing currently fails when it does.

  Also wires the two declared-but-unemitted Earn metrics and adds
  e2e-tests/earnDeposit.test.ts (7 cases). The spec is typechecked but not
  executed: every spec in the suite, including swap.test.ts, fails at import
  on Node v22.12.
    Presents swap-within-earn as a bottom sheet over the still-visible token
    picker, per Figma C3G0a4Gd6RQyplRBppGDsL section 9453:29848, and fixes the
    defects that walking the flow against that design turned up.

    The swap can no longer be a step: the radix sheet portals to the document
    body, so a `display: none` step wrapper cannot hide it. STEPS.SWAP is gone
    and the branch is driven by swapTarget with CHOOSE_TOKEN staying the active
    step underneath; ?swap=true keys off the same state. SlideupModal is unusable
    here because it self-measures via scrollHeight, so .EarnSwapSheet takes an
    explicit 90% height and re-bases the reused Swap Views off 100dvh. Its rules
    are scoped by data-slot because our shadcn Sheet forwards className to the
    overlay as well, and it deliberately has no overflow: hidden — the slide-in
    transform makes it the containing block for the review SlideupModal nested
    inside it, which clipping would cut off. Sheet chrome (56px header, 12px to
    the first card, 24px under the CTA, 40px seam notch) is measured off frame
    9459:47811.
    Every screen in the Earn flow that draws an asset icon was handing AssetIcon
    an empty `assetIcons` map, which it reads as "Method 1 lookup still in
    flight" and answers with a loader — before it consults the `icon` prop at all.
    So those icons never resolved; they sat as blank circles. XLM hid it, because
    its logo is bundled and short-circuits the lookup, and until the swap branch
    worked XLM was the only token reachable as a deposit.
      Brings the deposit screen, its pool card and the wallet-address screen to
      spec, measured against the frames rather than eyeballed. The APY ribbon is
      Colors/Green/10 on Green/4 with 16px top corners, where green-08 on green-01
      at 8px read as muted; the card is 16px with the pool name on Text/Primary
      instead of inheriting Secondary; the pool details sheet gains its Backstop
      row and 16px accepted-token icons; and an over-limit amount turns red rather
      than adding a message row the design does not have — the CTA already reads
      "Insufficient funds".

      Three behavioural fixes alongside it. The swap's receive side is pinned
      again, chosen on the picker before it, so the pill is a label rather than a
      dropdown that silently does nothing. The fiat toggle no longer takes focus
      from the amount input, which was flashing the disabled CTA white on every
      press. And an account holding no XLM no longer sees it named after its
      contract address: the catalog reports native with a null symbol *and* a null
      name, so the code now comes from recognising its SAC.

      Shared components keep their existing behaviour by default — AmountCard's
      invalidAmountStyle and isAssetLocked, SwapAmount's isDestinationLocked — and
      the wallet-address screen's titled chrome is gated on ?flow=earn so its five
      other callers are untouched. The Backstop row reads "--" until a backend
      serves backstop_usd: the v2 backend drops the field its own upstream already
      provides.
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-517432f1a2f91337ca7d
Backend: sandbox (aristidesstaffieri). SDF collaborators only — install instructions in the release description.

  All 7 tests in earnDeposit.test.ts failed on every PR run. With
  maxFailures: 1, that aborted the whole e2e job — the last CI run reported
  4 failed / 62 passed / 182 did not run. Three separate bugs, all in the
  test code; the feature code was fine.

  Stub scope. stubBlendEarn registered its three routes with page.route,
  but the Blend endpoints are backend-v2: the popup only messages the
  background, and the service worker makes the fetch (blend.ts ->
  fetchBackendV2 -> callBackendV2). page.route never sees those, so every
  request went to the real INDEXER_V2_URL — http://indexer.invalid on CI —
  and the picker rendered its error state. Moved to context.route, the
  convention stubCollectibles already documents. Also registered via
  loginToTestAccount's stubOverrides so the routes exist before the popup
  navigates.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds the Blend-backed Earn deposit flow, including token selection, simulation, review, signing, submission, funding remedies, metrics, and network gating.

Changes:

  • Adds Blend API types, contract encoding, pool allowlists, and deposit simulation.
  • Adds the complete Earn UI, embedded swap path, intro persistence, and analytics.
  • Extends shared amount, transaction, localization, and test infrastructure.

Reviewed changes

Copilot reviewed 104 out of 106 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
@shared/api/helpers/blend.ts Adds Blend API clients and mappings.
@shared/api/helpers/__tests__/blend.test.ts Tests Blend API clients.
@shared/api/internal.ts Adds Earn intro messaging APIs.
@shared/api/types/blend.ts Defines Blend response models.
@shared/api/types/message-request.ts Adds Earn intro messages.
@shared/api/types/types.ts Extends background response data.
@shared/constants/blend.ts Adds pool allowlists and gating.
@shared/constants/sac.ts Adds known SAC addresses.
@shared/constants/services.ts Registers Earn service types.
@shared/helpers/soroban/blend.ts Encodes Blend submit operations.
@shared/helpers/soroban/__tests__/blend.test.ts Tests Blend XDR encoding.
extension/e2e-tests/README.md Documents service-worker request stubbing.
extension/e2e-tests/earnDeposit.test.ts Covers the Earn flow end-to-end.
extension/e2e-tests/helpers/stubs.ts Adds Blend and simulation stubs.
extension/src/background/messageListener/popupMessageListener.ts Registers intro handlers.
extension/src/background/messageListener/handlers/getEarnIntroSeen.ts Reads intro state.
extension/src/background/messageListener/handlers/dismissEarnIntro.ts Persists intro dismissal.
extension/src/background/messageListener/__tests__/getEarnIntroSeen.test.ts Tests intro reads.
extension/src/background/messageListener/__tests__/dismissEarnIntro.test.ts Tests intro persistence.
extension/src/constants/localStorageTypes.ts Adds the intro storage key.
extension/src/helpers/metrics.ts Adds the Earn metric flow.
extension/src/popup/App.tsx Registers the Earn reducer.
extension/src/popup/Router.tsx Registers Earn routes.
extension/src/popup/__testHelpers__/index.tsx Registers Earn in test stores.
extension/src/popup/assets/blend-logo.svg Adds the Blend logo.
extension/src/popup/components/BalanceRow/index.tsx Supports custom row metadata.
extension/src/popup/components/SubviewHeader/index.tsx Supports left-side actions.
extension/src/popup/components/account/AccountHeader/index.tsx Adds the Earn action tile.
extension/src/popup/components/account/AccountHeader/styles.scss Supports variable action counts.
extension/src/popup/components/amount/AmountCard/index.tsx Adds locked and invalid variants.
extension/src/popup/components/amount/AmountCard/styles.scss Styles new card variants.
extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx Tests invalid amount styling.
extension/src/popup/components/amount/constants.ts Centralizes default amounts.
extension/src/popup/components/amount/helpers/amountDisplay.ts Shares amount display helpers.
extension/src/popup/components/amount/helpers/percentageAmount.ts Shares percentage calculations.
extension/src/popup/components/amount/helpers/__tests__/percentageAmount.test.ts Tests percentage calculations.
extension/src/popup/components/earn/EarnAmount/index.tsx Implements deposit entry and simulation.
extension/src/popup/components/earn/EarnAmount/styles.scss Styles deposit entry and fee sheets.
extension/src/popup/components/earn/EarnAmount/PoolCard.tsx Displays the selected pool.
extension/src/popup/components/earn/EarnAmount/NetworkFeeSheet.tsx Provides XLM funding remedies.
extension/src/popup/components/earn/EarnAmount/hooks/useGetEarnAmountData.tsx Loads balances and prices.
extension/src/popup/components/earn/EarnAmount/hooks/useSimulateEarnDeposit.tsx Simulates and scans deposits.
extension/src/popup/components/earn/EarnAmount/helpers/earnCtaState.ts Computes CTA and fee states.
extension/src/popup/components/earn/EarnAmount/helpers/__tests__/earnCtaState.test.ts Tests CTA and fee logic.
extension/src/popup/components/earn/EarnIntro/index.tsx Adds the first-run intro.
extension/src/popup/components/earn/EarnIntro/styles.scss Styles the intro.
extension/src/popup/components/earn/EarnIntro/hooks/useEarnIntroSeen.ts Manages persisted intro state.
extension/src/popup/components/earn/EarnReview/index.tsx Adds deposit review and hardware signing.
extension/src/popup/components/earn/EarnReview/styles.scss Styles review panes.
extension/src/popup/components/earn/EarnReview/helpers/projectEarnings.ts Calculates projected earnings.
extension/src/popup/components/earn/EarnReview/helpers/__tests__/projectEarnings.test.ts Tests earnings projections.
extension/src/popup/components/earn/EarnReview/__tests__/EarnReview.hardwareWallet.test.tsx Tests hardware signing.
extension/src/popup/components/earn/EarnSubmit/index.tsx Adds deposit progress and success UI.
extension/src/popup/components/earn/EarnSubmit/styles.scss Styles submission states.
extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx Signs, submits, and refreshes deposits.
extension/src/popup/components/earn/EarnSubmit/__tests__/EarnSubmit.test.tsx Tests software and hardware submission.
extension/src/popup/components/earn/EarnSwap/index.tsx Embeds Swap within Earn.
extension/src/popup/components/earn/EarnSwap/styles.scss Adapts Swap to a sheet.
extension/src/popup/components/earn/EarnTokenPicker/index.tsx Adds supported-token selection.
extension/src/popup/components/earn/EarnTokenPicker/styles.scss Styles picker and remedy sheets.
extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx Adds funding remedies.
extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx Resolves pools, balances, and icons.
extension/src/popup/components/earn/EarnTokenPicker/helpers/resolveSwapDestination.ts Resolves embedded-swap destinations.
extension/src/popup/components/earn/EarnTokenPicker/helpers/getNotEnoughVariant.ts Selects available remedies.
extension/src/popup/components/earn/EarnTokenPicker/helpers/__tests__/getNotEnoughVariant.test.ts Tests remedy selection.
extension/src/popup/components/earn/PoolDetailsSheet/index.tsx Displays pool statistics.
extension/src/popup/components/earn/PoolDetailsSheet/styles.scss Styles pool details.
extension/src/popup/components/earn/PoolDetailsSheet/poolDescriptions.ts Adds pool-specific descriptions.
extension/src/popup/components/earn/PoolDetailsSheet/hooks/usePoolReserveIcons.tsx Resolves reserve icons.
extension/src/popup/components/earn/PoolIcon/index.tsx Renders protocol branding.
extension/src/popup/components/earn/PoolIcon/styles.scss Styles protocol branding.
extension/src/popup/components/earn/StatRow/index.tsx Adds reusable Earn stat rows.
extension/src/popup/components/earn/StatRow/styles.scss Styles stat rows.
extension/src/popup/components/earn/helpers/earnAssetIcons.ts Resolves catalog asset identities.
extension/src/popup/components/earn/helpers/formatPoolStats.ts Formats pool statistics.
extension/src/popup/components/earn/helpers/__tests__/earnAssetIcons.test.ts Tests catalog asset identity.
extension/src/popup/components/earn/helpers/__tests__/formatPoolStats.test.ts Tests pool formatting.
extension/src/popup/components/InternalTransaction/SubmitTransaction/index.tsx Supports embedded transaction exits.
extension/src/popup/components/InternalTransaction/SubmitFail/index.tsx Supports custom error dismissal.
extension/src/popup/components/send/SendAmount/index.tsx Uses shared amount helpers.
extension/src/popup/components/swap/SwapAsset/index.tsx Supports custom picker back icons.
extension/src/popup/components/swap/SwapAmount/index.tsx Supports locked and sheet layouts.
extension/src/popup/components/swap/SwapAmount/styles.scss Styles embedded Swap variants.
extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.layout.test.tsx Tests locked destination layout.
extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapAmountHelpers.test.ts Updates shared helper tests.
extension/src/popup/components/swap/hooks/useSwapSubmitQuoteExpiry.ts Shares quote-expiry recovery.
extension/src/popup/constants/earn.ts Defines Earn steps and query state.
extension/src/popup/constants/metricsNames.ts Adds Earn metric names.
extension/src/popup/constants/routes.ts Adds the Earn route.
extension/src/popup/constants/__tests__/metricsNames.test.ts Tests Earn metric names.
extension/src/popup/ducks/earn.ts Adds Earn workflow state.
extension/src/popup/helpers/blendDeposit.ts Builds and simulates deposits.
extension/src/popup/helpers/searchAsset.ts Reuses canonical SAC constants.
extension/src/popup/locales/en/translation.json Adds English Earn strings.
extension/src/popup/locales/pt/translation.json Adds Portuguese Earn strings.
extension/src/popup/locales/__tests__/translationParity.test.ts Enforces translation parity.
extension/src/popup/metrics/earn.ts Adds Earn funnel emitters.
extension/src/popup/metrics/earn.test.ts Tests Earn event payloads.
extension/src/popup/metrics/views.ts Excludes the Earn container view.
extension/src/popup/metrics/views.test.ts Tests container-route exclusions.
extension/src/popup/views/Earn/index.tsx Orchestrates the Earn step machine.
extension/src/popup/views/Earn/styles.scss Adds step transitions.
extension/src/popup/views/Earn/__tests__/Earn.submitFailure.test.tsx Tests failure recovery and retries.
extension/src/popup/views/Swap/index.tsx Uses shared quote-expiry recovery.
extension/src/popup/views/ViewPublicKey/index.tsx Adds Earn-specific receive chrome.
extension/src/popup/views/ViewPublicKey/styles.scss Styles the Earn receive header.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread extension/src/popup/components/earn/EarnAmount/index.tsx Outdated
Comment thread extension/src/popup/components/earn/EarnAmount/index.tsx Outdated
Comment thread extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx Outdated
Comment thread extension/src/popup/Router.tsx
Comment thread extension/src/popup/components/earn/PoolDetailsSheet/index.tsx Outdated
Comment thread extension/src/popup/components/earn/EarnAmount/index.tsx
Comment thread extension/src/popup/components/earn/EarnIntro/index.tsx Outdated
  The existing-position fetch was fired without being awaited, so review
  could open on the simulation alone and then flip Position, Monthly and
  Yearly under the user when the slower lookup landed. Await both, with the
  position leg resolving to "0" on failure so it still cannot block a
  deposit.
  Closing the "Depositing" screen navigates back to the account view rather
  than closing the popup, so the submit hook's continuation kept running and
  emitted earn.deposit_completed after earn.deposit_abandoned. The deposit was
  never abandoned — only the screen was.

  Worse than the double count was the asymmetry: a post-close success still
  emitted from the hook's closure, while a post-close failure emitted nothing,
  because earn.deposit_failed lived in the Earn view's effect and that view had
  unmounted.

  - useSubmitEarnTxData now emits both outcomes, from the closure that outlives
    the screen. The Earn view keeps the failure UI work but skips its emit while
    DEPOSIT_CONFIRM is active, so it still owns the failures that never reach
    that step (a device-rejected signature at review) without double counting.
  - Rename earn.deposit_abandoned to earn.deposit_processing_dismissed: a UX
    signal, not an outcome. The outcome is lost only when the popup itself
    closes.
  - Ignore a submitStatus left behind by a previous flow. A late rejection wrote
    ERROR back after closeEarnFlow had reset the submission, and re-entering Earn
    read it as its own failure — emitting against an empty asset and dropping the
    fresh flow onto the amount screen.
  - Extract getFailureReasonCode, now shared by both emitters, and stop it
    throwing: the soroban submit thunk rejects with the parsed response body in
    errorMessage, which scrubStrKeys called .replace on. Inside the hook's
    try/catch that swallowed the failure entirely.
  The pool details sheet listed every reserve the catalog reports under
  "Accepted tokens", including reserves whose `enabled` flag is false.
  Blend's `require_action_allowed` panics with `ReserveDisabled` (#1223)
  on Supply, SupplyCollateral, and Borrow into such a reserve — only
  Withdraw and Repay stay open — so the row could claim a token was
  depositable when it was not.

  The backend already applies the same filter one endpoint over:
  `deriveEarnOptions` skips disabled reserves, so the token picker never
  offers them. The pools catalog deliberately reports every reserve with
  its flag and leaves the decision to the client, which left the sheet as
  the only place able to contradict the picker on the same screen.

  Filter through a shared `getAcceptedReserves`, used by both the icon row
  and the icon-resolution hook so the two cannot drift and no icon is
  fetched for a token that is never drawn. An all-disabled pool renders
  "--", the same unavailable marker the USD and rate rows use.

  No currently served pool is affected — all three mainnet Fixed Pool
  reserves are enabled — but a pool admin can flip the flag at any time.

  Covered by a unit test on the helper and by the e2e pool-details test,
  whose Blend stub now carries a disabled reserve alongside the enabled
  ones.
// Blend takes the amount in the asset's smallest unit. toFixed(0) because
// an i128 cannot carry a fraction, and exponential notation would not parse.
amount: parseTokenAmount(amount, decimals).toFixed(0),
requestType: BlendRequestType.SupplyCollateral,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think it'd be worth adding a comment here explaining why we're using BlendRequestType.SupplyCollateral instead of BlendRequestType.Supply for clarity (like the one we have on PR description)

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.

added this in a03d32c

preparedTransaction,
// Internal flows have no originating dApp; the pool contract is the
// subject, so attribute the scan to it.
params.assetId,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

just doing a sanity check here as we are passing a contract id as the url param of scanTx, is this safe to do so?

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.

while it was safe, it was inconsistent with other calls so I used the internal label in db451df

@CassioMG

Copy link
Copy Markdown
Contributor

earn.max_amount_selected fires for every percentage shortcut, not just Max

Suggestion (analytics correctness) — wrong-by-name event data from the moment the flow ships; cheap to fix now, awkward to fix once dashboards are built on it. Not a merge blocker.

TL;DR: All four percentage shortcuts on the deposit amount screen — 25%, 50%, 75% and Max — emit the same event named "max amount selected". The event does carry a percent property, so the underlying data is recoverable, but anything keyed on the event name alone (dashboards, funnels, alerting) will count a 25% tap as Max usage. The Send flow shipped this exact behaviour, got called out for it in review, and was subsequently fixed to emit only on the 100% tap — this is the same pattern going in the other direction.


Detailed explanation (for agents)

Root cause: the handler emits unconditionally, and the tracker hard-codes the "max" metric name regardless of the percent it receives.

The call site fires for any pct:

<PercentageButtons
onSelect={(pct) => {
trackEarnPercentAmountSelected({
assetCode: selected?.code || "",
percent: pct,
});
dispatch(
saveAmount(

The tracker always emits METRIC_NAMES.earnMaxAmountSelected, with the percentage relegated to a property:

/** A percentage shortcut on the amount screen; `percent: 100` is Max. */
export const trackEarnPercentAmountSelected = ({
assetCode,
percent,
}: {
assetCode: string;
percent: number;
}) => {
emitMetric(METRIC_NAMES.earnMaxAmountSelected, {
asset_code: assetCode,
percent,
});
};

And the constant it resolves to is explicitly named for Max:

// A percentage shortcut on the amount screen; `percent: 100` is Max.
earnMaxAmountSelected: "earn.max_amount_selected",
// The deposit cannot cover its own network fee; `reason` separates an account

Prior art in this repo: SendAmount had the identical issue. Review feedback on #2764 said the metric "will make analytics indistinguishable from actual 'set max' usage. Consider only emitting this metric for the 100% case, or introduce a separate metric (and include the percentage value) for the partial buttons." It now gates on master:

const handlePercentage = (pct: number) => {
if (pct === 100) {
emitMetric(METRIC_NAMES.paymentMaxAmountSelected);
}

The Earn implementation took the second half of that suggestion (the percent property) without the first (a name that matches what happened), so the ambiguity the reviewer flagged is reintroduced at the name level.

Suggested fixes (in increasing order of depth):

  1. Match Send: gate the emit on percent === 100 inside trackEarnPercentAmountSelected, leaving partial taps untracked. Smallest diff, consistent with the sibling flow, loses partial-shortcut usage data.
  2. Rename to match behaviour: rename the constant to earn.percent_amount_selected and keep emitting for all four. The percent property then fully disambiguates and you retain the richer funnel data. Requires coordinating the rename with whoever consumes the metrics, since the string is the wire format.
  3. Two metrics: emit earn.percent_amount_selected for 25/50/75 and earn.max_amount_selected for 100, matching Send's name for the Max case while still capturing partials.

Option 2 or 3 preserves the most signal; option 1 is the safest if Earn's funnel is meant to mirror Send's exactly. Note the existing reports Max as percent 100 test only exercises percent: 100, so it would keep passing under option 1 and would need updating only if the constant is renamed.

@CassioMG

Copy link
Copy Markdown
Contributor

Earn submit hook reports status: "success" when the submission fails

Bug (latent) — the hook's own state is wrong on every failed deposit, but a second check in the consuming component currently keeps the wrong value off the screen. Not a merge blocker.

TL;DR: When the on-chain submission is rejected, the deposit hook records the attempt as a success anyway — it tracks the failure metric, then falls through and reports success regardless. Nothing is visibly broken today, because the screen decides what to render by also consulting the Redux submission status, which is correctly set to error. The risk is that the hook's own signal is wrong: anyone who later trusts it directly — a refactor, a new consumer, a test exercising the hook in isolation — silently gets "the deposit succeeded" for a deposit that failed.


Detailed explanation (for agents)

Root cause: the submit-failure branch is missing the early return its sibling branch has. It emits earn.deposit_failed and then falls through to the shared success tail.

if (!submitFreighterSorobanTransaction.fulfilled.match(submitResp)) {
trackDepositFailed(
submitFreighterSorobanTransaction.rejected.match(submitResp)
? submitResp.payload
: undefined,
);
} else {
trackEarnDepositCompleted({

Control flow rejoins here and dispatches unconditionally, so RequestState.SUCCESS is reached on both paths:

}
const payload: SubmitEarnTxData = { status: "success" };
dispatch({ type: "FETCH_DATA_SUCCESS", payload });
return payload;

Compare the sign-failure branch a few lines above, which is the shape the submit branch should have — track, FETCH_DATA_ERROR, return:

if (
!signFreighterSorobanTransaction.fulfilled.match(res) ||
!res.payload.signedTransaction
) {
// Submitting `xdr` unsigned would fail on the network anyway, but as a
// *second* failure: the rejected sign thunk has already set
// submitStatus to ERROR and the flow has already stepped back to the
// amount screen, so the late submit rejection would report a second
// earn.deposit_failed for one attempt.
trackDepositFailed(
signFreighterSorobanTransaction.rejected.match(res)
? res.payload
: undefined,
);
dispatch({ type: "FETCH_DATA_ERROR", payload: res.payload });
return res.payload;
}

Why nothing is visibly broken today: EarnSubmit does not trust the hook alone. It ands the hook's state together with the Redux submitStatus, which the submitFreighterSorobanTransaction thunk sets to ERROR independently, so the success view stays hidden:

const isSuccess =
submissionState.state === RequestState.SUCCESS &&
submission.submitStatus !== ActionStatus.ERROR;
const isLoading = !isSuccess;

That also means the earn_success screen-view metric, which is gated on the same isSuccess, does not fire spuriously. So this is a correctness defect in the hook, not a user-facing one — worth fixing because the guard that saves it lives in a different file and is not obviously load-bearing.

Deterministic repro: drive the hook directly rather than trying to fail a real submission — make submitFreighterSorobanTransaction reject (mock the thunk, or point at an RPC that rejects the envelope), call fetchData, and assert on the hook's returned payload and reducer state. Current behaviour: returns { status: "success" } and state === RequestState.SUCCESS. Expected: an error state, mirroring the sign-failure path. The existing EarnSubmit.test.tsx covers the failure path only through the emitted metric, which is why this passes CI.

Suggested fix: mirror the sibling branch exactly — inside the if (!submitFreighterSorobanTransaction.fulfilled.match(submitResp)) block, after trackDepositFailed(...), dispatch FETCH_DATA_ERROR with the rejection payload and return it. The else wrapper around the success path then becomes redundant and can be flattened, which also removes the possibility of this class of fall-through recurring.

@CassioMG

Copy link
Copy Markdown
Contributor

PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-6a7858a6668267bb229c Backend: V1 prod + V2 beta (no sandbox configured for @aristidesstaffieri). SDF collaborators only — install instructions in the release description.

@aristidesstaffieri @piyalbasu it seems I can't test the Earn Deposit feature from the PR preview yet because the sandbox is not configured (and I believe the sandbox-per-dev-config is still WIP), is that right?

So in case I'd like to test the full Earn Deposit flow I'd need to checkout the backend-v2 main-blend branch and run/deploy it locally, is that the right approach?

Thanks!

Screenshot 2026-08-24 at 18 22 02

@CassioMG

Copy link
Copy Markdown
Contributor

scanTx result is computed but never read

Nit / question — no security impact, not a merge blocker.

TL;DR: The Earn deposit flow runs a Blockaid scan on the prepared transaction
and stores the verdict on the simulation payload, but nothing downstream ever
reads it — the review sheet doesn't surface it and the Confirm button doesn't
consider it. Flagging it in case wiring up the warning UI was intended and got
left behind; if it was deliberate (the scan and simulate endpoints share a host,
so the verdict adds little here, and the deposit target is a pinned constant
rather than anything caller-supplied), then the round-trip itself is probably
worth dropping.


Detailed explanation (for agents)

What I found: useSimulateEarnDeposit awaits scanTx(preparedTransaction, …)
and writes the result onto the SimulateTxData payload:

// Scanned on the PREPARED transaction — the thing the user actually
// signs — not the pre-assembly build.
const scanResult = await scanTx(
preparedTransaction,
// Internal flows have no originating dApp; the pool contract is the
// subject, so attribute the scan to it.
params.assetId,
params.networkDetails,
);
reduxDispatch(
saveSimulation({
preparedTransaction,
response: simulationResponse,
}),
);
const payload: SimulateTxData = {
transactionXdr: preparedTransaction,
scanResult,
inclusionFee: params.transactionFee,
resourceFee,

A grep for scanResult across components/earn/ and views/Earn/ returns only
that hook plus two test fixtures. EarnReview destructures simulationState.data
for transactionXdr only, and FeesPane reads just inclusionFee / resourceFee
/ state, so the verdict is dead on arrival.

For contrast, ReviewTransaction reads the same field and turns a
Malicious / Suspicious / UNABLE_TO_SCAN verdict into a banner plus a
demoted "Confirm anyway" affordance via getTransactionSecurityLevel
mergeSecurityLevelsshouldShowTxWarningActionButtons. Earn is the
first signing surface that computes the verdict and drops it.

Two ways to close it:

  1. Wire it up: consume simulationState.data?.scanResult in EarnReview the
    way ReviewTransaction does — render BlockAidScanLabel /
    BlockAidScanExpanded and require the explicit acknowledgement before
    onConfirmTx fires. Keeps Earn consistent with every other signing path.
  2. Drop it: if the scan was never meant to gate this flow, remove the call so
    the flow isn't paying for a round-trip whose answer is discarded.

</Text>
</div>
</NavLink>
{isEarnSupportedNetwork(networkDetails) ? (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we also put this button behind a feature flag?

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.

I would lean towards no, my thoughts are that once this feature is live we wont want to remove functionality that users have already had access to. I think in the event of a protocol problem we would want to use our notification system to warn users. wdyt?

@CassioMG CassioMG Aug 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

hum yeah I'm all in for the notification but I'm afraid some users would still ignore it, I think having a mechanical way to prevent users from doing it would be a little bit safer. The notification would also make it clear for users why the deposit button is missing or disabled with a "This feature is temporarily unavailable" message or something like that. An alternative approach would be disabling our endpoints in an emergency case, but the feature flag would degrade more gracefully I think (and also easier than having to change an endpoint).

Users would still be able to check their positions or withdraw from it, they would just not be able to make new deposits. Wdyt?

I don't think it's a blocker though, we could work on it as a follow-up task after all 3 are implemented (deposit, withdraw, positions) if you think that makes sense

@CassioMG CassioMG Aug 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Although thinking again about this I think having a feature flag for withdraw could also be important, so we avoid users interacting with the contract

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.

yeah I think its a valid concern. One thing that seems potentially inconsistent is that in other cases we choose to not stop users from interacting with the network, like in the case a transaction is flagged then we choose to warn users but let them continue if they choose to. I think this could be a similar case. I think what I can do here is just add the feature flag anyway and then we can choose to use it or not as a case for it arrives(or not). I will add that and report back.

I assume we would use the same feature flag for deposit and withdrawl?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Humm great point on having a consistent UX compared to Blockaid warnings, I haven't thought from this point of view 🤔 I think that's fair

But for some reason I feel like this feature will bring more attention and have more users actively using it with higher amounts so it seems a bit more sensitive, but that's just a hunch

In case we go for the feature flags, I'd suggest having 1 for earn_deposit and 1 for earn_withdraw so we have a bit more control, also considering we will have more protocols in the future I think it's possible there could be a situation where we want to disable only one of the features. Wdyt?

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.

yeah that sounds good I will add both flags

Comment thread extension/src/popup/views/Swap/index.tsx
@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author

PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-6a7858a6668267bb229c Backend: V1 prod + V2 beta (no sandbox configured for @aristidesstaffieri). SDF collaborators only — install instructions in the release description.

@aristidesstaffieri @piyalbasu it seems I can't test the Earn Deposit feature from the PR preview yet because the sandbox is not configured (and I believe the sandbox-per-dev-config is still WIP), is that right?

So in case I'd like to test the full Earn Deposit flow I'd need to checkout the backend-v2 main-blend branch and run/deploy it locally, is that the right approach?

Thanks!

Screenshot 2026-08-24 at 18 22 02

@CassioMG correct, the earn APIs are still in our dev cluster so what I have been doing is just running a port forward to the backend v2 instance in dev from my machine.

@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author

scanTx result is computed but never read

Nit / question — no security impact, not a merge blocker.

TL;DR: The Earn deposit flow runs a Blockaid scan on the prepared transaction and stores the verdict on the simulation payload, but nothing downstream ever reads it — the review sheet doesn't surface it and the Confirm button doesn't consider it. Flagging it in case wiring up the warning UI was intended and got left behind; if it was deliberate (the scan and simulate endpoints share a host, so the verdict adds little here, and the deposit target is a pinned constant rather than anything caller-supplied), then the round-trip itself is probably worth dropping.

Detailed explanation (for agents)

great catch, it seems like this actually needs design input so I will look into that and get back to this thread.

  Deposits use Blend `request_type` 2 (SupplyCollateral) rather than 0
  (Supply) so the position lands in the pool's `collateral_tokens` instead
  of `supply_tokens`, leaving the account's borrowing power open. That
  reasoning only lived in the PR description; the enum's own docstring
  covers the naming but not why the deposit flow picks 2. Record it where
  the choice is actually made.
    All four percentage shortcuts on the deposit amount screen — 25%, 50%,
    75% and Max — emitted `earn.max_amount_selected`. The `percent` property
    kept the data recoverable, but anything keyed on the event name alone
    (dashboards, funnels, alerting) would count a 25% tap as Max usage.

    Send and Swap already gate this: both emit the shared
    `payment.max_amount_selected` only when `pct === 100`, matching mobile
    (RFC #2883, D5), and Swap's telemetry test asserts a partial tap emits
    nothing. Earn was the only amount screen that widened the trigger.

    Gate inside `trackEarnPercentAmountSelected` rather than at the call
    site, so the decision stays in the metrics module and `EarnAmount`'s
    handler is untouched. Keep the earn-scoped name — action events carry no
    `flow` property, so reusing `payment.max_amount_selected` would make
    Earn max taps indistinguishable from Send's — and keep `percent` (now
    always 100) for payload symmetry with mobile. If Earn later wants
    partial-shortcut usage, that belongs in a separately named event.
  The submit-failure branch in `useSubmitEarnTxData` emitted
  `earn.deposit_failed` and then fell through to the shared tail, which
  dispatches `FETCH_DATA_SUCCESS`. Both paths reached `RequestState.SUCCESS`,
  so the hook's own state reported a rejected deposit as a successful one.
  Its sign-failure sibling a few lines above already has the right shape:
  track, `FETCH_DATA_ERROR`, return.

  Nothing was visibly broken, because two guards outside the hook kept the
  wrong value off screen. `EarnSubmit` ands the hook's state together with
  redux `submitStatus`, which the submit thunk independently sets to ERROR,
  and that conjunct also gates the `earn_success` screen view. The Earn view
  goes further and unvisits DEPOSIT_CONFIRM on a failure, so the screen
  unmounts before it could render anything. The defect is in the signal, not
  the UI: anything that later trusts the hook directly — a refactor, a second
  consumer, a test — silently gets "the deposit succeeded".

  Give the branch its early return and flatten the now-redundant `else`, so
  the success tail is only reachable from a fulfilled submission and this
  class of fall-through cannot recur. Behaviour on screen is unchanged;
  `isLoading` is `!isSuccess` either way.

  Cover it at the hook level rather than through the component: both states
  render "Depositing", so the DOM cannot tell them apart, which is why the
  existing failure test — asserting only on the emitted metric — stayed
  green. Extract the store fixture out of `renderSubmit` so the new
  `renderHook` cases share it, and assert both outcomes so a future change
  cannot break success the same way.
  useSimulateEarnDeposit scanned the prepared deposit and wrote the result
  onto its SimulateTxData payload, but nothing read it: EarnReview pulled
  only transactionXdr off simulationState.data, so Earn was the one
  signing surface that computed a verdict and dropped it. If Blockaid ever
  flagged the Blend pool contract, the review said nothing.

  Consume scanResult in EarnReview through the same helpers the other
  review screens use — useShouldTreatTxAsUnableToScan,
  useBlockaidOverrideState, getTransactionSecurityLevel. Only the
  transaction verdict applies here, since Earn's reserves come from the
  backend allowlist and there is no counterparty token to scan, so this
  reads the level directly rather than merging several.

  A flagged deposit now renders BlockaidBanner above the review body,
  opens the BlockAidScanExpanded reasons sheet, and demotes confirmation:
  Cancel takes over the Confirm slot (destructive when malicious) and a
  "Confirm anyway" text button appends below the row, keeping the fee gear
  in place. The sheet renders after the hardware-sign check so confirming
  from it with a device connected still swaps in HardwareSign.

  The action row is built locally rather than reusing ReviewTx's
  ActionButtons, which hardcodes the Send/Swap CTA copy and takes memo
  props this flow has none of.
@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author

scanTx result is computed but never read

Nit / question — no security impact, not a merge blocker.
TL;DR: The Earn deposit flow runs a Blockaid scan on the prepared transaction and stores the verdict on the simulation payload, but nothing downstream ever reads it — the review sheet doesn't surface it and the Confirm button doesn't consider it. Flagging it in case wiring up the warning UI was intended and got left behind; if it was deliberate (the scan and simulate endpoints share a host, so the verdict adds little here, and the deposit target is a pinned constant rather than anything caller-supplied), then the round-trip itself is probably worth dropping.
Detailed explanation (for agents)

great catch, it seems like this actually needs design input so I will look into that and get back to this thread.

@CassioMG update: this is now implemented according to the new design layout in 92e5f56

@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author

earn.max_amount_selected fires for every percentage shortcut, not just Max

Suggestion (analytics correctness) — wrong-by-name event data from the moment the flow ships; cheap to fix now, awkward to fix once dashboards are built on it. Not a merge blocker.

TL;DR: All four percentage shortcuts on the deposit amount screen — 25%, 50%, 75% and Max — emit the same event named "max amount selected". The event does carry a percent property, so the underlying data is recoverable, but anything keyed on the event name alone (dashboards, funnels, alerting) will count a 25% tap as Max usage. The Send flow shipped this exact behaviour, got called out for it in review, and was subsequently fixed to emit only on the 100% tap — this is the same pattern going in the other direction.

Detailed explanation (for agents)

this is addressed in c60e904

@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author

Earn submit hook reports status: "success" when the submission fails

Bug (latent) — the hook's own state is wrong on every failed deposit, but a second check in the consuming component currently keeps the wrong value off the screen. Not a merge blocker.

TL;DR: When the on-chain submission is rejected, the deposit hook records the attempt as a success anyway — it tracks the failure metric, then falls through and reports success regardless. Nothing is visibly broken today, because the screen decides what to render by also consulting the Redux submission status, which is correctly set to error. The risk is that the hook's own signal is wrong: anyone who later trusts it directly — a refactor, a new consumer, a test exercising the hook in isolation — silently gets "the deposit succeeded" for a deposit that failed.

Detailed explanation (for agents)

this is addressed in 55b1057

*/
export const formatCompactUsd = (value: number | null): string => {
if (value === null) {
return "--";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we have an existing constant for this on the codebase

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.

indeed there is, thanks. This was updated in a22cfd8

* "0.00%". Never conflate the two.
*/
export const formatRate = (rate: number | null): string =>
rate === null ? "--" : `${new BigNumber(rate).times(100).toFormat(2)}%`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

same here for the "--" constant

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.

updated in a22cfd8

  Add `earn_deposit` as an Amplitude boolean flag, defaulting to off, and
  require it alongside the existing network check before the Earn action
  renders in the home action row. The default keeps the tile hidden when
  the flag fetch fails or no deployment key is configured.

  The /earn route itself stays registered — only the entry point is gated.

  - remoteConfig: new flag, default, and earnDepositSelector
  - AccountHeader: flag && isEarnSupportedNetwork gate on the tile
  - stubBlendEarn: serve earn_deposit "on" from the Experiment vardata
    route so the Earn e2e specs still reach the flow
  - backfill earn_deposit in hand-built RemoteConfigState test fixtures
  The codebase already exports NO_FIAT_VALUE from popup/helpers/formatters
  for a USD figure that cannot be determined, with docs distinguishing it
  from "$0.00" (unknown vs. known zero). The earn flow was hardcoding the
  same "--" glyph in five places while documenting that same distinction in
  its own comments.

  Swap the literals for the shared constant in formatCompactUsd, formatRate,
  formatProjection, the review screen's USD line, the token picker's APY
  badge, and the pool sheet's empty accepted-tokens state.

  No behavior change — the rendered output is identical, and the tests still
  assert the literal "--" so a change to the constant's value would be caught.
@CassioMG CassioMG mentioned this pull request Aug 26, 2026
1 task
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.

3 participants