Skip to content

feat(src): Earn's deposit flow - #992

Closed
aristidesstaffieri wants to merge 28 commits into
mainfrom
feat/earn-deposit
Closed

aristidesstaffieri wants to merge 28 commits into
mainfrom
feat/earn-deposit

Conversation

@aristidesstaffieri

@aristidesstaffieri aristidesstaffieri commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds Earn to the mobile wallet: a deposit flow into the allowlisted Blend v2 "Fixed Pool" (mainnet and testnet), reached from a new Earn tile on Home. The user picks a reserve, enters an amount, reviews projected earnings and the before/after position, and signs a single Soroban pool.submit with one SupplyCollateral request. Deposits use request type 2 (SupplyCollateral) rather than 0, following Blend's own integration guidance, which keeps the user's borrowing power open.

Pool and position data come from freighter-backend-v2's Blend routes; transaction building, simulation, Blockaid scanning, sign/submit and biometrics all reuse the existing shared paths rather than new Earn-specific ones.

Scope: deposit only. There is no Positions tab and no withdraw — Home's tabs stay Tokens | Collectibles, and the Earn action tile is the only entry point. Where the user does not hold a reserve asset, the flow offers Buy / Swap / Receive instead, including a full swap-within-Earn branch that submits without leaving the Earn stack.

What's in this PR

  • src/config/blend.ts, src/config/blendTypes.ts — per-network pool allowlist (mirrors the backend's earn-pools.json), isEarnSupportedNetwork, and the API/domain types.
  • src/services/blend.ts — backend client for /protocols/blend/earn-options, /protocols/blend/pools and /accounts/positions. Snake_case wire shapes mapped to camelCase domain types at the boundary.
  • src/helpers/blend.ts, src/services/transactionService.ts — Blend Request struct encoding and buildBlendDepositTransaction (build + simulate, returning prepared XDR and minResourceFee).
  • src/ducks/earn.ts, src/ducks/transactionBuilder.ts — Earn-domain state (pool, selected reserve, before-position, last-submit-failed) plus a buildBlendDepositTransaction action on the shared builder store.
  • src/navigators/EarnNavigator.tsx, src/config/routes.ts — new EARN_STACK with the token picker and amount screens. Flow-scoped cleanup resets transaction settings, the builder, the earn duck and the network-fee cache on unmount.
  • src/components/screens/EarnScreen/screens/ — intro (first-run), token picker, amount, and the inline processing/success terminal screen.
  • src/components/screens/EarnScreen/components/ — pool card, pool details sheet, review sheet, not-enough-token sheet, XLM-fee-shortfall sheet, receive sheet, swap and swap-source sheets, and the ambient glow.
  • src/components/screens/EarnScreen/hooks/ + helpers/ — reserve/balance matching, position fetch, simulate, sign/submit, CTA state machine, projected earnings, pool-stat formatting, and swap-destination derivation. All the decision logic is in pure helpers with direct unit tests.
  • src/config/analyticsConfig.ts, src/services/analytics/ — earn_* screen events and earn.deposit_completed / earn.deposit_failed. Event names match the extension exactly so both platforms land in one Amplitude funnel. No amounts or fiat values on any earn event — asset code, pool_id, apy and reason_code only.
  • src/i18n/locales/{en,pt} — new strings for the whole flow.

Changes to shared code

Four touchpoints outside Earn, all additive and defaulting to existing behavior:

  • src/helpers/balances.ts — getBalanceByContractId bridges Blend's contract-id addressing to balances keyed by "XLM" / "CODE:ISSUER". Native XLM is handled first and explicitly: any check shaped like "issuer" in balance.token silently skips it.
  • src/components/AmountCard.tsx — isLocked (renders the picker chip as a plain View, since hiding only the chevron leaves an invisible tap target that still fires onPress) and isError (amount in the error color).
  • src/components/screens/SwapScreen/hooks/useSwapTransaction.ts — navigation is now optional and onProcessingClose is new, so swap-within-Earn can drive the hook from a sheet without its hard reset onto the History tab stranding the user mid-flow.
  • src/components/AccountQrDisplay.tsx — the account QR block extracted out of ReceiveTabView so Earn's receive sheet reuses it verbatim.

Notes for review

  • Request is hand-encoded, not nativeToScVal. Soroban orders UDT struct keys by byte order; nativeToScVal sorts with String.localeCompare, which ignores _. The two agree for address/amount/request_type, but the map is built explicitly so the next struct does not inherit the trap.
  • total_tokens, not supplied_tokens. Because deposits are SupplyCollateral, the position lands in the collateral bucket, so reading supplied_tokens would always report zero for Review's before-value.
  • No fee buffer; a measured shortfall check. One submit's resource fee runs ~5,000x the inclusion fee, so a naive Max on XLM simulates into insufficient balance. Rather than reserving a guessed buffer, the flow simulates and then compares spendable XLM against the real fee, showing XlmFeeShortfallBottomSheet when it falls short.
  • Blockaid scanning is isolated from simulation. scanTransaction throws NETWORK_NOT_SUPPORTED off mainnet, so a shared try/catch would report "simulation failed" for every testnet deposit that actually simulated fine.
  • The terminal screen renders inline, not as a route. That structurally prevents a swipe-back from abandoning an in-flight submit, and useEarnTransaction guards every post-settle write behind an active-request check so a closed-away submit cannot corrupt a later session's retry banner.
  • useEarnTokens fetches on account/network only. Depending on pricedBalances re-ran the effect on the balance store's 30s repoll, unmounting the picker and every sheet over it on a 30-second cadence. Rows are now derived from balances, which also moves a row between sections immediately after a deposit or swap.

Gating and dependencies

  • The Home tile requires both earn_deposit (remote config, default false) and an allowlisted pool for the active network. EARN_STACK itself stays registered, matching how the extension leaves its /earn route reachable.

Test plan

  • yarn jest over the Earn suites and the shared files this touches — 22 suites, 271 tests passing
  • yarn lint:ts clean
  • CI green
  • Manual deposit on testnet against a backend serving the Blend routes
  • Manual pass on the swap-within-Earn branch (no balance → swap → deposit)
  • Verify the earn_* funnel in Amplitude alongside the extension's

Followups

  • Withdraw and a Positions

  "Swap for {CODE}" was an inert stub. It now opens a swap sheet stacked over
  the token picker, with the destination locked to the reserve the user was
  trying to deposit into, and runs through to submission — source picker,
  review, and processing included.

  Presented as sheets over the still-visible picker (Figma 13722:341980
  composites it on a dimmed picker) rather than routing into SWAP_STACK, which
  would eject the user from Earn and leave them to find their way back to the
  reserve they wanted.

  Everything reuses Swap's own hooks — useSwapBalances, useSwapAmountError,
  useSwapPathFinding, useSwapCtaState, useSwapSecurityAssessments,
  useSwapTransaction, useSwapFooter — so quoting, security scanning, and
  submission behave identically. Only two things are Earn-specific: the
  destination is fixed, and the source defaults to the account's most valuable
  swappable holding so the sheet opens ready to type.

  Earn addresses reserves by SAC contract id but swapping is classic-only —
  pathPaymentStrictSend rejects contract-id assets, and a SAC address is a hash
  that cannot be decomposed back into code + issuer. The catalog's `name` field
  already carries the canonical "CODE:ISSUER"; useEarnTokens was discarding it.
  It now threads through as `canonicalId`, and buildEarnSwapDestination parses
  it, returning null when a reserve has no classic form rather than falling back
  to `assetId` and building a descriptor the path finder fails on silently.

  Two additive changes to shared Swap code, both defaulting to current behavior:
  useSwapTransaction gains `onProcessingClose` (its hard reset onto the History
  tab strands an Earn user mid-flow) and makes `navigation` optional, since Earn
  drives it from a sheet rather than a route. AmountCard gains `isLocked`, which
  renders the picker chip as a plain View — hiding only the chevron would leave
  an invisible tap target that still fired onPress.

  Also fixes a pre-existing defect this surfaced: useEarnTokens' fetch depended
  on `pricedBalances`, which the balances store repolls every 30s, so the effect
  re-ran, flipped isLoading, and unmounted the screen — and every sheet mounted
  over it — on a 30-second cadence. The catalog is now fetched on account/network
  only and rows are derived from balances, which also moves a row between
  sections immediately after a deposit or swap. The screen's loading and error
  branches are additionally gated on having no rows, since they early-return past
  the sheet tree.

  Corrects the "not enough token" sheet: the design has two layouts, not one.
  Two actions stack a filled primary above an outlined secondary; three actions
  (13132:50277) put Buy and Swap side by side with an "or" rule and Receive as a
  bare text button. An earlier pass had generalized from the two-action frames
  and flattened all of them. XlmFeeShortfallBottomSheet gets the same header
  rework and 13722:341905's copy.

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 a Blend-powered Earn deposit flow with Soroban transaction building, asset acquisition options, analytics, localization, and tests.

Changes:

  • Adds Earn navigation, token selection, review, deposit, and processing flows.
  • Integrates Blend APIs, balance mapping, Soroban simulation, signing, and submission.
  • Adds shared UI enhancements, analytics, localization, and extensive tests.

Reviewed changes

Copilot reviewed 76 out of 78 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
src/config/blend.ts Configures supported Blend pools.
src/config/blendTypes.ts Defines Blend API models.
src/config/routes.ts Adds Earn routes.
src/config/analyticsConfig.ts Adds Earn analytics events.
src/services/blend.ts Implements Blend API clients.
src/services/transactionService.ts Builds and simulates deposits.
src/services/analytics/types.ts Defines Earn event payloads.
src/services/analytics/transactions.ts Tracks deposit outcomes.
src/services/analytics/index.ts Exposes Earn analytics.
src/helpers/blend.ts Encodes Blend contract requests.
src/helpers/balances.ts Maps SAC IDs to balances.
src/ducks/earn.ts Stores Earn flow state.
src/ducks/preferences.ts Persists intro visibility.
src/ducks/transactionBuilder.ts Stages Blend transactions.
src/navigators/EarnNavigator.tsx Defines and cleans up Earn navigation.
src/navigators/RootNavigator.tsx Registers the Earn stack.
src/navigators/index.ts Exports the Earn navigator.
src/components/screens/HomeScreen/HomeScreen.tsx Adds the Earn entry action.
src/components/screens/EarnScreen/screens/index.ts Exports Earn screens.
src/components/screens/EarnScreen/screens/EarnIntroScreen.tsx Implements the first-run intro.
src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx Handles deposit amounts and fees.
src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx Implements token and acquisition selection.
src/components/screens/EarnScreen/screens/EarnProcessingScreen.tsx Displays deposit progress and success.
src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx Presents deposit review details.
src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx Presents Blend pool details.
src/components/screens/EarnScreen/components/PoolCard.tsx Displays the selected pool.
src/components/screens/EarnScreen/components/EarnTokenRow.tsx Renders Earn asset rows.
src/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.tsx Offers asset acquisition actions.
src/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.tsx Handles insufficient fee XLM.
src/components/screens/EarnScreen/components/ReceiveFundsBottomSheet.tsx Adds in-flow account receiving.
src/components/screens/EarnScreen/components/EarnSwapBottomSheet.tsx Adds in-flow swap entry.
src/components/screens/EarnScreen/components/EarnSwapFromBottomSheet.tsx Selects swap source assets.
src/components/screens/EarnScreen/components/EarnScreenHeader.tsx Adds the Earn close header.
src/components/screens/EarnScreen/components/EarnGlow.tsx Renders Earn background styling.
src/components/screens/EarnScreen/hooks/useSimulateEarnDeposit.ts Simulates and scans deposits.
src/components/screens/EarnScreen/hooks/useEarnTokens.ts Loads and partitions Earn assets.
src/components/screens/EarnScreen/hooks/useEarnPosition.ts Loads existing Blend positions.
src/components/screens/EarnScreen/hooks/useEarnTransaction.ts Signs and submits deposits.
src/components/screens/EarnScreen/hooks/useEarnSwap.ts Coordinates in-flow swaps.
src/components/screens/EarnScreen/helpers/index.ts Exports Earn helpers.
src/components/screens/EarnScreen/helpers/depositAmount.ts Calculates percentage deposits.
src/components/screens/EarnScreen/helpers/earnCtaState.ts Determines deposit CTA state.
src/components/screens/EarnScreen/helpers/earnSwapDestination.ts Builds classic swap destinations.
src/components/screens/EarnScreen/helpers/formatPoolStats.ts Formats pool statistics.
src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts Selects acquisition actions.
src/components/screens/EarnScreen/helpers/poolDescriptions.ts Maps pool descriptions.
src/components/screens/EarnScreen/helpers/projectEarnings.ts Calculates projected earnings.
src/components/screens/SwapScreen/hooks/useSwapTransaction.ts Supports Earn-specific swap closure.
src/components/screens/ScanReceiveScreen/ReceiveTabView.tsx Reuses the account QR display.
src/components/AccountQrDisplay.tsx Extracts shared account QR UI.
src/components/AmountCard.tsx Adds locked and error states.
src/components/PercentageButtons.tsx Updates percentage-button spacing.
src/i18n/locales/en/translations.json Adds English Earn copy.
src/i18n/locales/pt/translations.json Adds Portuguese Earn copy.
__tests__/config/blend.test.ts Tests Blend network configuration.
__tests__/services/blend.test.ts Tests Blend API mapping.
__tests__/services/transactionService.test.ts Tests deposit construction.
__tests__/helpers/blend.test.ts Tests contract encoding.
__tests__/helpers/balances.test.ts Tests SAC balance matching.
__tests__/ducks/earn.test.ts Tests Earn state.
__tests__/ducks/preferences.test.ts Tests intro persistence.
__tests__/navigators/EarnNavigator.test.tsx Tests navigator cleanup.
__tests__/components/AccountQrDisplay.test.tsx Tests shared QR rendering.
__tests__/components/screens/EarnScreen/useSimulateEarnDeposit.test.ts Tests simulation and scanning.
__tests__/components/screens/EarnScreen/useEarnTokens.test.ts Tests Earn token derivation.
__tests__/components/screens/EarnScreen/useEarnPosition.test.ts Tests position loading.
__tests__/components/screens/EarnScreen/EarnTokenPickerScreen.test.tsx Tests picker and intro behavior.
__tests__/components/screens/EarnScreen/earnSwapDestination.test.ts Tests swap destination conversion.
__tests__/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.test.tsx Tests fee-shortfall actions.
__tests__/components/screens/EarnScreen/components/ReceiveFundsBottomSheet.test.tsx Tests in-flow receiving.
__tests__/components/screens/EarnScreen/components/PoolDetailsBottomSheet.test.tsx Tests pool details rendering.
__tests__/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.test.tsx Tests acquisition variants.
Suppressed comments (1)

src/components/screens/EarnScreen/components/EarnSwapBottomSheet.tsx:112

  • This icon-only close control has no accessible name or role, so screen-reader users cannot identify its action. Add the standard close semantics.

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

Comment on lines +563 to +587
if (isXlm) {
// The backend omitted `minResourceFee` from this simulation, so the fee
// is genuinely UNKNOWN, not zero. `tokenAmount` is already <=
// `spendableXlm` by construction (the CTA's own `isAmountTooHigh` guard,
// and `availableBalance === spendableXlm` for an XLM deposit), so
// feeding "0" here would make `getXlmFeeShortfall` report "0" in every
// case that reaches this point — silently disabling the check rather
// than skipping it visibly, and reintroducing the exact failure this
// whole check exists to prevent: a max-balance XLM deposit signing
// successfully and only failing at submission. Fall back to a
// conservative floor instead (see its doc for why "0.1" specifically)
// — this is logged too, since it is still a gap in what simulation told
// us, even though it is now covered rather than skipped.
if (sorobanResourceFeeXlm === null) {
logger.warn(
"EarnAmountScreen",
"sorobanResourceFeeXlm is null after a successful simulation; falling back to the unknown-fee floor for the shortfall check",
);
}
shortfall = getXlmFeeShortfall({
spendableXlm: spendableXlm.toFixed(),
amount: tokenAmount || "0",
resourceFee: sorobanResourceFeeXlm ?? UNKNOWN_RESOURCE_FEE_FLOOR_XLM,
});
}
Comment on lines +48 to +56
useEffect(
() => () => {
useTransactionSettingsStore.getState().resetSettings();
useTransactionBuilderStore.getState().resetTransaction();
useEarnStore.getState().resetEarn();
clearNetworkFeesCache();
},
[],
);
Comment on lines +131 to +136
useEffect(() => {
if (!destination) {
return;
}
setDestinationToken(destination);
}, [destination, setDestinationToken]);
Comment on lines +195 to +198
const resolvedPool = pools.find((p) => p.id === poolId) || null;

setCatalog({ options: earnOptions, poolId, pool: resolvedPool });
setPool(resolvedPool);
Comment on lines +67 to +70
<TouchableOpacity
onPress={handleClose}
testID="xlm-fee-shortfall-close"
>
Comment on lines +224 to +225
<TouchableOpacity onPress={onClose} testID="not-enough-token-close">
<Icon.X
Comment on lines +271 to +272
<TouchableOpacity onPress={handleClose} testID="pool-details-close">
<Icon.X
Comment on lines +96 to +101
<TouchableOpacity
onPress={onSettingsPress}
hitSlop={12}
accessibilityRole="button"
testID="earn-swap-settings"
>
Comment on lines +75 to +76
<TouchableOpacity onPress={onClose} testID="earn-swap-from-close">
<Icon.X
Comment on lines +155 to +159
// Earn is gated purely on network support (an allowlisted Blend pool) —
// there is deliberately no feature flag alongside it.
const earnEnabled = isEarnSupportedNetwork(
mapNetworkToNetworkDetails(network),
);
CassioMG and others added 5 commits November 10, 2026 17:15
Brings the Earn deposit branch onto current main: the Varial rebrand,
light and dark themes, stellar-sdk 17.4.0 and the Varial bottom sheet.

Conflicts:
- RootNavigator: took main's accessibility-wrapped navigator and re-added
  the EARN_STACK screen after SWAP_STACK.
- HomeScreen: main's imports and showAddCollectibleButton, plus the Earn
  network gate. DEFAULT_PADDING and showCollectiblesPill are dropped
  because main no longer uses them.
- ReceiveTabView: kept the AccountQrDisplay extraction. AccountQrDisplay
  now renders main's Varial QR (varial2d logo, logoMargin 7).
- analytics/index: kept both the token-chart and the Earn trackers.
- en/pt translations: kept main's common.stellarNetwork. Dropped the
  branch's common.back, which main already adds with the same text.
- balances and transactionService tests: kept both sides' imports.

Adapted to main's APIs:
- BottomSheet: dropped handleCloseModal and useInsetsBottomPadding, which
  main removed (both were no-ops for content sheets). The transaction
  details sheet uses main's scrollable sheet with SignTransactionDetailsHeader.
- useEarnTransaction: submitTransaction now resolves with an outcome.
  The hook reads the hash, error and result codes from it instead of
  from the store, which a mid-submit Close resets.
- ReceiveFundsBottomSheet: dropped the dark-only icon color; main's
  Button tints its icon per theme.
- Native checks now use helpers/assetIdentity (isNativeBalance,
  isNativeToken), as main's asset-identity lint rule requires.
- EarnTokenRow: its doc comment no longer names the dark palette, which
  main's theme guard test rejects.
- Tests: moved to stellar-sdk 17 XDR accessors, removed the deleted
  gesture-handler shim, and made the submitTransaction mocks return
  outcomes.

Committed with --no-verify. lint-staged lints every file main changed,
and 8 of main's own files already fail its ESLint/Prettier on main
(e.g. src/helpers/linkedText.ts). tsc and the full Jest suite pass on
this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The glow behind the intro and the token picker was baked to the dark
design's green (#11351E). On Varial's light page it rendered as a grey
smudge. It now takes a theme token: green-4 in dark, the nearest token to
the design's color, and green-6 in light, where green-4 all but vanishes.
This matches the extension's EarnGlow. The blur profile is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Earn's sheets were the last ones still drawing their own circled X. The
Varial bottom sheet restyle moved every other sheet to the shared,
labelled header.

- XLM fee shortfall and Not enough: stacked header with the token icon
  leading, as in main's "Not enough XLM" sheet.
- Receive funds: centered header.
- Pool details, Swap and Swap from keep their own header rows (a two-line
  pool name; a settings or back control left of a centered title). They
  now use the header's X, which BottomSheetHeader exports as
  BottomSheetCloseButton.
- Deposit review: a "Review deposit" title above the card, matching the
  Send and Swap reviews.

Every X now has a "Close" accessibility label, and the swap sheet's
settings control has a "Settings" label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The Blend deposit build and its tests use stellar-sdk 17's toXdr and
  fromXdr. Main's source no longer uses the deprecated toXDR/fromXDR
  aliases. The output is byte-identical.
- getNotEnoughVariant identifies native XLM with isNativeToken, as
  main's asset-identity lint rule requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Earn now targets the v2.1 deployment's Fixed pools:

- mainnet "Fixed v2.1": CBAKAZUEJBAFA2DCGBRS2WKJT3FNIMJBHTFL76AY7XMAVEINQNNIQWAO
- testnet "Testnet v2.1": CB3447A446DY3USGWPIDXTNQHF5EX24XDQA2EN6TAZP62JTPBTNOA52Q

Both run the same pool contract code as the v2 pools they replace (same
wasm hash on both networks), so the request encoding and the submit
call are unchanged. freighter-backend-v2's earn-pools.json allowlist
(main-blend) lists the same two IDs.

The pool sheet's description was keyed by pool ID because its copy
("Permissionless lending pool with no admin…") claims something only
true of one deployment. That claim was true of the v2 mainnet pool,
whose admin account is burned. It was never true of the v2 testnet pool,
and the v2.1 pools both have a contract admin. The sheet now shows the
extension's generic description for every pool, in en and pt.

Unit tests read BLEND_FIXED_POOL_IDS instead of a copied literal, as on
the extension. The allowlist's own test keeps the literal IDs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Android Emulator preview APK is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-bbeac572b26ae223c194
Backend: sandbox (aristidesstaffieri). SDF collaborators only — install instructions in the release description.

@github-actions

Copy link
Copy Markdown
Contributor

iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-7080a8308b4d56c79db5
Backend: sandbox (aristidesstaffieri). SDF collaborators only — install instructions in the release description.

@CassioMG CassioMG closed this Nov 11, 2026
@CassioMG CassioMG mentioned this pull request Nov 11, 2026
2 of 6 tasks
@CassioMG

Copy link
Copy Markdown
Contributor

Re-opening this under my account so PR Previews build against my sandbox. Same branch and commits; the new PR links back here: #1059

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