feat(src): Earn deposit flow - #2974
Conversation
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.
a4fa2c2 to
7d9e9e6
Compare
|
PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-517432f1a2f91337ca7d |
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.
There was a problem hiding this comment.
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.
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, |
There was a problem hiding this comment.
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)
| preparedTransaction, | ||
| // Internal flows have no originating dApp; the pool contract is the | ||
| // subject, so attribute the scan to it. | ||
| params.assetId, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
while it was safe, it was inconsistent with other calls so I used the internal label in db451df
|
Earn submit hook reports
|
@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!
|
|
| </Text> | ||
| </div> | ||
| </NavLink> | ||
| {isEarnSupportedNetwork(networkDetails) ? ( |
There was a problem hiding this comment.
Should we also put this button behind a feature flag?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
yeah that sounds good I will add both flags
@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. |
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.
@CassioMG update: this is now implemented according to the new design layout in 92e5f56 |
this is addressed in c60e904 |
this is addressed in 55b1057 |
| */ | ||
| export const formatCompactUsd = (value: number | null): string => { | ||
| if (value === null) { | ||
| return "--"; |
There was a problem hiding this comment.
I think we have an existing constant for this on the codebase
There was a problem hiding this comment.
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)}%`; |
There was a problem hiding this comment.
same here for the "--" constant
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.


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
submitinvocation against the pool contract. Gated to networks with an allowlisted pool — pubnet and testnet only.Deposits use Blend
request_type2 (SupplyCollateral), not 0, so the position lands incollateral_tokensand the user's borrowing power stays open. Review reads before/after fromtotal_tokensfor 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/submitop encoding. UDTScMaps are hand-built becausenativeToScValsorts keys withlocaleCompare, which is not byte order.@shared/api/{helpers,types}/blend.ts— client for earn-options, pools, and supplied-tokens.nullUSD/APY means unavailable and is never coalesced to0.@shared/constants/blend.ts— pool allowlist per network plusisEarnSupportedNetwork, 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 assorobanCredentialsSourceAccount, so noauthorizeEntryround-trip is needed, including on hardware wallets.popup/components/{amount,swap}/— extractedpercentageAmounthelpers 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, andapyonly.locales/{en,pt}/translation.json+translationParity.test.ts— new strings and a test that fails on missing or empty parity.Notes for the reviewer
submitresource 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.Test plan
yarn jest blend earn Earn percentageAmount SwapAmount.layout translationParity metricsNames(14 suites, 139 tests)CAJJZSGM…BXBDFollowups
Example Flow
Screen.Recording.2026-08-24.at.9.16.37.AM.mov
Screen.Recording.2026-08-24.at.9.32.29.AM.mov