Skip to content

feat: dollar tokens Phase 3.3 - virtual Dolares end-to-end + brand/icon/order harmonization#152

Merged
TuCopFi merged 30 commits into
Developmentfrom
feat/dollars-spend-ui
Jun 12, 2026
Merged

feat: dollar tokens Phase 3.3 - virtual Dolares end-to-end + brand/icon/order harmonization#152
TuCopFi merged 30 commits into
Developmentfrom
feat/dollars-spend-ui

Conversation

@TuCopFi

@TuCopFi TuCopFi commented Jun 12, 2026

Copy link
Copy Markdown
Member

Summary

Builds on PR #151 (Phase 3.2 - virtual Dolares wired into Swap + Gold Buy)
to complete the virtual Dolares experience end-to-end and standardise
the brand / icon / order treatment of dollar tokens across every
surface that lists them.

  • Multi-swap quote works (wei conversion FROM + TO, partial-success
    via Promise.allSettled with unquotedUsd, Logger.warn per failed
    step). Same wei fix mirrored in the executeMultiSwap saga so
    confirmation behaves identically to the preview.
  • Virtual Dolares on the swap TO side (home CTAs pre-select virtual,
    SwapScreen resolves it explicitly, quoteToToken settles into USDT
    for the actual router call, TO field renders the multi-swap output
    with capped decimals).
  • Brand + icon + order harmonization: short symbols
    (USDT/USDC/USDm/USAT per CLAUDE.md), concrete dollar brands route
    through their imageUrl logos (not the generic DollarsIcon SVG),
    the picker order is fixed (USDT, USDC, USAT, USDm) at the
    TokenBottomSheet level so every consumer inherits it for free, and
    the home BalanceCard breakdown follows the same canonical order.
  • Consolidated SwapTransactionDetails panel: both swap directions
    surface details inside the same bordered card (rate / receiving-in /
    per-token spend breakdown / fees / slippage) instead of the previous
    mixed inline + standalone block layout the user flagged as messy.
  • Gold flows: GoldSellEnterAmount defaults to virtual Dolares and
    shows a 'Recibirás en {brand}' hint; GoldSellConfirmation caps
    decimals + resolves the brand label; GoldBuyEnterAmount chip uses
    the brand label too.
  • Success screen shows the concrete brand + token icon for FROM / TO
    rows so the user can tell which specific stablecoin actually landed
    in their wallet.

Bonus / unrelated: `fix(navigator)` dismisses the iOS splash screen
when `NavigatorWrapper` returns early on the force-upgrade branch
(previously the splash covered the UpgradeScreen forever).

23 granular commits, each one buildable / lintable / its own tests
passing independently. Commit log:
```
90f9d6e fix(navigator): dismiss splash on force-upgrade early return
f215f34 feat(success): brand label + token icon on TransactionSuccessScreen
48553bc fix(gold): brand resolution in GoldBuyEnterAmount
2ef72e4 fix(gold): brand resolution + decimals cap in GoldSellConfirmation
3f1fa71 feat(gold): default GoldSellEnterAmount to virtual Dolares + settlement hint
14cf93e chore(swap+gold): drop standalone DolaresMultiStepSummary block
5f7d0ef refactor(dollarsSpend): DolaresMultiStepSummary as panel shape
22d7ea3 refactor(swap): consolidate SwapTransactionDetails panel
727147c refactor(home-balance-card): canonical dollar order + shared helper
ce94d27 feat(token-bottom-sheet): apply canonical dollar order centrally
73a6154 feat(tokens): canonical USDT/USDC/USAT/USDm picker order helper
c062056 refactor(tokens): share getDollarTokenLabelKey across consumers
edd8518 feat(token-display): recognize virtual Dolares synthetic
779f7bf fix(token-icon): route concrete dollar brands through imageUrl logos
53d0b9d feat(i18n): switch dollar brand labels to short symbols
17082e4 feat(swap): route virtual TO through quoteToToken + wire multiSwap output
e6eea5d feat(swap): resolve virtual Dolares as TO in SwapScreen lookup
a050553 feat(home): pre-select virtual Dolares on swap CTAs
cb093df fix(dollarsSpend): shift aggregated buyAmount to whole units
344095c feat(dollarsSpend): partial-success via Promise.allSettled + unquotedUsd
2764da5 fix(dollarsSpend): convert whole→wei in executeMultiSwap saga
b4f738d fix(dollarsSpend): convert whole→wei in multi-swap quote fetch
28d1d56 feat(dollarsSpend): carry token decimals on snapshot + step types
```

Root cause notes (for reviewers)

The original 'No quote available' cascade users saw on every multi-step
swap traced to the `fetchSwapQuote` helper passing the planner's
whole-unit amount ('1.33') directly to `/getSwapQuote`. The endpoint
expects wei ('1330000' for a 6-decimal token); the regular
`useSwapQuote` already does `.shiftedBy(decimals)` at line 262 of
`useSwapQuote.ts` but the multi-swap path was missing the equivalent
conversion. Verified by hitting
`api.mainnet.valora.xyz/getSwapQuote` directly:
`sellAmount=1.33` returns
`{"errors":[{"openocean":"invalid BigNumber string"},
{"squid":"Request failed with status code 400"}]}`,
while `sellAmount=1330000` returns a fully-resolved
`unvalidatedSwapTransaction`.

The same boundary returns `buyAmount` in wei, which is what caused
the TO field to render `8,294,124,603,728,709,287,862.00 Pesos` after
the input-side fix. Both sides are now corrected.

Test plan

  • Home -> 'Intercambia tus divisas': swap card lands on
    `Saldo: 4.67 Dolares` (the aggregated balance, not USDT only).
  • Pesos -> Dolares: TO field shows the multi-swap output with
    whole-unit decimals; 'Recibirás en USDT' row visible in the
    transaction-details panel.
  • Dolares -> Pesos: 'Detalle por token' expandable row in the same
    panel (USDm / USDC / USDT slices); no separate block below the
    confirm button.
  • Picker rows in every consumer (Swap TO, GoldSell, GoldBuy, Earn,
    Send, Jumpstart): dollar tokens listed in
    USDT / USDC / USAT / USDm order with brand-specific icons.
  • Home Dolares card breakdown: same canonical order.
  • Gold sell: lands on virtual Dolares with the
    'Recibirás en USDT' hint; picking a brand explicitly overrides
    the hint.
  • Gold sell confirmation: 'Recibes 0.040586 USDm' (capped
    decimals, brand label).
  • Success screen after any swap / gold flow: 'A: 0.04 USDT'
    with the Tether icon.

Follow-ups (not in this PR)

  • Surface `unquotedUsd` as a partial-coverage banner in the swap UI
    (the hook already exposes it; only the render is missing).
  • Fix the misleading "whole token units (not wei)" comment on
    `fetchSwapQuote` / `fetchSwapQuoteForExecution`.
  • Optional planner fallback that skips a step whose token is known
    to have no Squid route, so $1 of USDm-only does not deadlock the
    flow.

TuCopFi added 30 commits May 30, 2026 22:06
chore: promote Development to main for 1.118.5
- iOS: MARKETING_VERSION 1.118.4 -> 1.118.5, CURRENT_PROJECT_VERSION
  254 -> 255 across all build configs in MobileStack.xcodeproj.
- Android: versionName "1.118.4" -> "1.118.5", VERSION_CODE
  1021081773 -> 1021081774.
- package.json version bumped to 1.118.5.
- CLAUDE.md and docs/reference/NAVIGATION_FLOWS.md updated to reflect
  the new version metadata.
CFBundleShortVersionString and CFBundleVersion were hardcoded as
"1.118.4" / "254". Bumping MARKETING_VERSION / CURRENT_PROJECT_VERSION
in the pbxproj alone did not propagate to the archived bundle, so the
TestFlight upload for 1.118.5 was rejected with code 90062
("CFBundleShortVersionString must be higher than 1.118.4").

Switch both keys to \$(MARKETING_VERSION) / \$(CURRENT_PROJECT_VERSION)
so future version bumps via pbxproj alone are enough.
…-vars

fix(ios): wire Info.plist version keys to build-setting template vars
Add a `decimals: number` field on DollarTokenBalanceSnapshot and
SpendStep so downstream callers (useMultiSwapQuote, executeMultiSwap
saga) can shift the planner's whole-unit amount into the smallest unit
the Squid /getSwapQuote endpoint requires.

Pure data-shape change. The actual wei conversion lands in follow-up
commits; this one just threads the value end-to-end so subsequent
commits can use `step.decimals` without falling back to a Redux lookup.

- src/dollarsSpend/types.ts: extend the two interfaces with `decimals`.
- src/dollarsSpend/useDollarBalanceSnapshots.ts: populate from
  `token.decimals` when building the snapshot.
- src/dollarsSpend/planSpend.ts: forward `snap.decimals` into each
  pushed SpendStep.
- 8 test files updated to include `decimals` in their SpendStep /
  DollarTokenBalanceSnapshot fixtures so the type check passes:
  planSpend.test.ts (snap helper hard-codes 18 for USDm, 6 otherwise),
  saga.test.ts, selectors.test.ts, slice.test.ts,
  dolaresVirtualToken.test.ts, MultiSwapProgressSheet.test.tsx,
  PartialSuccessSheet.test.tsx, DolaresMultiStepSummary.test.tsx,
  useMultiSwapQuote.test.tsx.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/dollarsSpend/ -> 44/44 pass
useMultiSwapQuote was passing the planner's whole-unit amount
("1.33") directly to the Valora /getSwapQuote endpoint, which
expects the smallest unit ("1330000" for a 6-decimal token). Every
sub-quote rejected with "No quote available", leaving totalOutToken
at 0; nothing the user did surfaced a working multi-step preview.

Verified the boundary by hitting api.mainnet.valora.xyz/getSwapQuote
directly: sellAmount=1.33 returns
{"errors":[{"openocean":"invalid BigNumber string"},
{"squid":"Request failed with status code 400"}]}, while
sellAmount=1330000 returns a fully-resolved unvalidatedSwapTransaction.
The regular useSwapQuote already does .shiftedBy(decimals) at
line 262 of useSwapQuote.ts; the multi-step path was missing the
equivalent conversion.

- src/dollarsSpend/useMultiSwapQuote.ts: shift step.amountTokenWhole
  by step.decimals before passing as sellAmount, using
  .toFixed(0, BigNumber.ROUND_DOWN) so we never request more than
  the user actually holds (the planner already enforces balance
  caps, but ROUND_DOWN guards against edge-case rounding upward).

The output-side wei conversion (the API's buyAmount is also in wei
and currently leaks into totalOutToken) lands in a follow-up commit
so this one stays focused on the input bug. The saga
(executeMultiSwap) carries the same bug and is fixed in the next
commit.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/dollarsSpend/ -> 44/44 pass
Mirror the wei conversion fix from useMultiSwapQuote into the
multi-step execution saga. Without this the saga would have repeated
the same "No quote available" failure on every step the moment the
user tapped "Reintentar restante" from PartialSuccessSheet (or
confirmed a fresh multi-step flow), keeping the user stuck in the
recovery state even after the preview was finally working.

- src/dollarsSpend/saga.ts: shift step.amountTokenWhole by
  step.decimals before passing as `amount` to
  fetchSwapQuoteForExecution. Same conversion + ROUND_DOWN as the
  quote hook so confirmation behaves identically to the preview.
- Adds the `bignumber.js` import (the saga did not need it before
  because it only forwarded strings).

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/dollarsSpend/ -> 44/44 pass
useMultiSwapQuote used Promise.all over the per-step quote fetches,
so a single missing Squid route (e.g. USDm -> XAUt0 not configured
on the integrator) zeroed out the aggregated total even when the
other steps would have resolved fine. The user saw the same "0.00"
output and the saga retries dead-ended on the rejected step with
no UI signal explaining which token was unroutable.

- src/dollarsSpend/useMultiSwapQuote.ts: switch Promise.all to
  Promise.allSettled. Separate fulfilled vs rejected:
  - fulfilled: sum their swapAmount.TO into totalOutToken, expose
    via perStepQuotes.
  - rejected: accumulate the step's amountUsd into the new
    `unquotedUsd` field so callers can render a partial-coverage
    banner ("Couldn't quote $X - only $Y will be swapped").
- Surface `error` only when EVERY sub-quote failed; partial success
  is a recoverable state that should not be reported as a hard
  failure (the UI keeps the working portion).
- Logger.warn (TAG = 'dollarsSpend/useMultiSwapQuote') per rejected
  step with the symbol + USD amount + reason message. Previously
  the .catch path was silent, so the original "No quote available"
  cascade was invisible until reproduced via DevTools.
- src/dollarsSpend/useMultiSwapQuote.test.tsx: replace the old
  "surfaces an error if any quote fetch fails" with two cases that
  reflect the new contract:
  - "reports partial coverage when some steps fail to quote":
    asserts perStepQuotes length, totalOutToken sum, unquotedUsd =
    failing step's amountUsd, and error = undefined.
  - "surfaces the error only when all quote fetches fail": asserts
    totalOutToken = 0, unquotedUsd = full requested total,
    error.message contains the rejection reason.

Note that totalOutToken is still returned in wei here (it sums the
API's wei `buyAmount` directly). The wei -> whole conversion lands
in the next commit so this one stays focused on the partial-success
semantics.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/dollarsSpend/ -> 45/45 pass
The previous commit left totalOutToken in wei because that was the
unit the Squid /getSwapQuote endpoint returned per step. The first
end-to-end test surfaced the consequence: the TO field rendered
"8,294,124,603,728,709,287,862.00 Pesos" (raw COPm wei at 18
decimals) instead of the correct ~8,294 Pesos whole. Project rule:
never surface wei to user-facing displays - always convert at the
boundary.

- src/dollarsSpend/useMultiSwapQuote.ts:
  - Add `toTokenDecimals: number` constructor argument so the hook
    knows what to shift by without re-deriving it from Redux state
    (callers already have the token info in scope).
  - Sum the fulfilled `swapAmount.TO` values into `sumOutWei`
    (exact wei arithmetic), then shift once via
    `.shiftedBy(-toTokenDecimals)` and expose `sumOutWhole` as
    totalOutToken. Note that perStepQuotes still carry the raw API
    wei in `swapAmount.TO`; only the aggregated `totalOutToken`
    crosses into "whole" units. Documented inline so future readers
    know which side carries which unit.
- src/dollarsSpend/useMultiSwapQuote.test.tsx:
  - Existing aggregation/error tests pass `toTokenDecimals=0` so
    the shift is a no-op and the assertions stay readable (no need
    to pre-shift every mocked buyAmount). Comment block above the
    describe explains why.
  - New "shifts wei buyAmount back to whole units using
    toTokenDecimals" test: mocks 1e18 wei per step, passes
    decimals=18, asserts totalOutToken="2" for two steps (not
    "2000000000000000000").
- Callers updated to thread their settlement-token decimals:
  - src/swap/SwapScreen.tsx: pass `toToken?.decimals ?? 18`.
  - src/gold/GoldBuyConfirmation.tsx: pass `xaut0Token?.decimals ?? 6`.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/dollarsSpend/ src/swap/SwapScreen.test.tsx src/gold/
  -> 100/100 pass
When the user tapped "Intercambia tus divisas Pesos/Dólares" on the
home, the swap landed on `Saldo: 2.00 Dolares` (their USDT-only
balance) instead of the aggregated 4.67 across USDT/USDC/USDm/USAT
that the home BalanceCard had just shown them. Root cause: the CTA
pre-selected a concrete dollar token as TO instead of the virtual
aggregator. Same problem on the "Add COPm by swapping cUSD" sheet,
which locked the spend to USDT only.

- src/home/TabHome.tsx onPressHoldUSD ("Intercambia tus divisas"):
  navigate to SwapScreenWithBack with toTokenId set to
  DOLARES_VIRTUAL_TOKEN_ID. The swap card now lands on
  "Saldo: 4.67 Dolares" with the brand-aggregated balance and the
  swap layer translates virtual back to USDT for the actual
  settlement (see SwapScreen.quoteToToken in a follow-up commit).
- src/home/TabHome.tsx onPressSwapFromCusd ("Add COPm by swapping
  cUSD" bottomsheet card): fromTokenId set to virtual so the user
  spends from the full dollar balance via the multi-step planner
  (USAT -> USDm -> USDC -> USDT spend order) instead of being
  locked to a single concrete token. TO stays COPm.
- Drop the now-unused `useUSDT()` call in AddCOPmBottomSheet (the
  top-level useUSDT at line 111 stays - it still drives the
  USDTToken-detail navigation lower on the home).
- Add `DOLARES_VIRTUAL_TOKEN_ID` import from src/dollarsSpend.
- src/home/TabHome.test.tsx: update the "Tapping swap to USD" test
  to assert `toTokenId: 'virtual:dolares'` instead of usdtTokenId,
  with an inline comment explaining the virtual-to-concrete
  translation that happens on the swap side.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/home/TabHome.test.tsx -> 7/7 pass
The home swap CTAs now navigate with toTokenId: 'virtual:dolares'
(previous commit), but SwapScreen had no way to resolve that
synthetic id - the toToken lookup only searched the swappable list,
returned undefined, and the swap card showed an empty token slot.

- src/swap/SwapScreen.tsx: extend the from/to token memo with an
  explicit branch for toTokenId === DOLARES_VIRTUAL_TOKEN_ID that
  returns the locally-built `dolaresVirtualToken` synthetic. Falls
  back to the existing swappableToTokens.find() for any concrete
  toTokenId so picker-driven flows behave unchanged.
- Add `dolaresVirtualToken` to the memo's dependency list so the
  reference updates whenever the underlying snapshot aggregation
  changes (e.g. balance refresh).

The picker list itself is NOT modified here. Per the design
directive ("el picker no debe mostrar el agrupado"), pickers are
for picking a concrete destination; the virtual is only available
when callers (home CTAs, later GoldSell default, etc.) route into
swap with TO pre-set to virtual. The picker stays raw and the
explicit lookup branch is what makes the synthetic survive the
state -> render path.

This commit on its own makes the TO card render the synthetic with
its aggregated balance, but does NOT yet handle quote/settlement -
the swap router still needs a concrete ERC-20 tokenId; that comes
in the next commit via quoteToToken.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/swap/SwapScreen.test.tsx -> 54/54 pass
…tput

Two coupled wirings that together make the virtual "Dolares" TO work
end-to-end: the swap router gets a concrete settlement token, and the
TO field renders the multi-swap output amount when the FROM is virtual.

quoteToToken routing (commit 8 of the granular plan)
- src/swap/SwapScreen.tsx: new `quoteToToken` memo. When toToken is
  the synthetic virtual Dolares, resolves to USDT (default settlement
  per the wallet's SPEND_ORDER strategy). Falls back to the first
  DOLLAR_TOKEN_IDS member if USDT isn't swappable on the active
  network.
- Replace toToken with quoteToToken in every place the swap router
  needs a concrete ERC-20:
  - useMultiSwapQuote second arg (settlement tokenId for multi-step
    quotes from FROM=virtual into TO=virtual).
  - quoteUpdatePending toTokenId comparison (the regular quote
    settles into concrete; comparing against virtual would mark the
    quote as forever pending).
  - quoteKnown check in the debounced refresh useEffect.
  - refreshQuote(fromToken, toToken, ...) call - now passes the
    concrete quoteToToken so /getSwapQuote receives a real ERC-20.
  - executeMultiSwap dispatch: settlementTokenId = quoteToToken.tokenId
    so the saga always operates on a concrete destination, even when
    the user picked virtual on BOTH sides.

multiSwap TO field display (commit 9 of the granular plan)
- src/swap/SwapScreen.tsx: TO SwapAmountInput now sources its
  value/parsedValue/loading from multiSwapQuote when isVirtualDolares
  (FROM=virtual triggers the multi-step path and the regular `quote`
  is never fetched, so inputSwapAmount[Field.TO] stays empty). Cap
  decimals via getInputDecimalsForToken(toToken?.tokenId) +
  ROUND_DOWN + .toFormat({ decimalSeparator }) so the field doesn't
  dump raw BigNumber precision (project rule: never surface chain
  precision to displays).
- loading flag uses multiSwapQuote.loading instead of
  quoteUpdatePending when virtual, so the skeleton state reflects
  the actual multi-step fetch and not the regular-quote pipeline
  that the screen no longer drives.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/swap/SwapScreen.test.tsx -> 54/54 pass
The four concrete dollar stablecoins were previously translated to
long brand names ("Tether USD", "USD Coin", "Dolar Mento",
"Tether America USD"). The labels appeared everywhere a brand row
was rendered - the swap picker, the home BalanceCard breakdown, the
gold buy/sell chips, the success screen - and wrapped awkwardly in
narrow chips and rows, e.g. "Recibes 0.04 Dolar Mento" wrapping
onto two lines on the GoldSellConfirmation card.

Standardise on the short on-chain symbols so every surface stays
compact and the user can identify each token at a glance:

- assets.tetherUsd:        "Tether USD"          -> "USDT"
- assets.usdCoin:           "USD Coin"            -> "USDC"
- assets.mentoDollar:       "Dolar Mento"         -> "USDm"
- assets.tetherAmericaUsd: "Tether America USD"  -> "USAT"

For USDm, the choice follows the CLAUDE.md rule
"NEVER use old cXXX symbols -> use XXXm"; the legacy "cUSD" form
is intentionally NOT used even though some users still recognise it
better, because the project rule is unambiguous on this.

Files:
- locales/es-419/translation.json: update the four asset keys.
- locales/en-US/translation.json: add the four asset keys (they
  did not exist in en-US; the brand labels are not translated, just
  the short symbols are universal).

This commit is consumed implicitly by every screen that resolves a
dollar brand via getDollarTokenLabelKey (TokenBalanceItem, the
gold flows, TransactionSuccessScreen, etc.) - no code changes
required at the call sites; the translation lookup picks up the
new value automatically.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
USDT and USD₮ were in SVG_ICON_SYMBOLS, which forced both into the
custom DollarsIcon SVG and made every dollar token in the picker
look identical to USDT. The user could not visually tell USDT from
USDC / USDm / USAT - all rendered the same green-circle-$ glyph,
which defeats the picker's "pick a specific brand" purpose.

- src/components/TokenIcon.tsx: drop 'USDT' and 'USD₮' from
  SVG_ICON_SYMBOLS so the concrete dollar brands fall through to
  the FastImage branch and render their own imageUrl logos (the
  real Tether T-mark for USDT, the USDC circle, the Mento C+$
  for USDm, the USAT badge). Add 'Dolares' to the SVG list -
  the synthetic virtual aggregator has no imageUrl, so it still
  needs to route through DollarsIcon to avoid the "Dola" text
  fallback when it surfaces in the swap card.
- src/components/TokenIcon.tsx: collapse the renderSvgIcon DollarsIcon
  branch from `symbol === 'USDT' || symbol === 'USD₮'` down to
  `symbol === 'Dolares'`. Inline comment documents the split between
  the synthetic virtual (SVG) and the concrete brands (imageUrl).

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/components/ -> 238/238 pass
The synthetic virtual Dolares (tokenId 'virtual:dolares') never lives
in the Redux token store, so `useTokenInfo(tokenId)` returned
undefined inside TokenDisplay and the row fell into the error path -
showing "-" and "Precio no disponible" wherever the virtual was
rendered (the GoldBuy picker, future surfaces, etc.). The virtual is
intentionally USD-denominated at priceUsd=1, so this is a clean
synthetic that should render with concrete numbers, not as an error.

- src/components/TokenDisplay.tsx: add `isVirtualDolares` check
  derived from the DOLARES_VIRTUAL_TOKEN_ID constant.
- effectivePriceUsd = isVirtualDolares ? 1 : tokenInfo?.priceUsd
  used both for the showError test and the amountInUsd
  calculation, so the local-amount row stops returning the error
  fallback when no tokenInfo exists.
- hasSymbolOrIsKnownToken includes isVirtualDolares so the no-
  tokenInfo branch in the !showLocalAmount path does not trip the
  error fallback either.
- Symbol fallback substitutes 'Dolares' when virtual, since the
  synthetic does not appear in the getTokenSymbol map.

This makes the virtual row in the GoldBuy "Seleccionar Token"
picker render "4.67 Dolares" / "COP$16,634.76" the same way any
other dollar token would, instead of "-" / "Precio no disponible".

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
BalanceCard had a local `getDollarTokenLabelKey` that mapped the
four concrete dollar tokenIds to their brand-name i18n keys
(assets.tetherUsd / usdCoin / mentoDollar / tetherAmericaUsd). The
same mapping needs to live in: the swap picker rows
(TokenBalanceItem), the gold confirmations (Recibes row), the
success screen, and the gold sell settlement hint. Without a shared
helper, each consumer would either inline the same conditional
chain or fall back to the raw `token.name` (which for USDm reads
"Celo Dollar" - a legacy value the user explicitly does not want
to see in any user-facing surface).

- src/tokens/dollarGroup.ts: export
  `getDollarTokenLabelKey(tokenId): string | null`. Returns null
  when the tokenId is not one of the four concrete dollar tokens
  (callers fall back to "Dolares" or to token.name). Header comment
  documents both the brand-vs-virtual distinction and the
  usat-on-sepolia caveat.
- src/tokens/TokenBalanceItem.tsx: replace the previous
  USDT-only check
  (`if (token.tokenId === networkConfig.usdtTokenId) return t('assets.dollars')`)
  with the brand-resolution helper so each of USDT / USDC / USDm
  /USAT renders its own short symbol in the picker. The header
  comment explains that the synthetic virtual Dolares row (tokenId
  = 'virtual:dolares', no brand label) intentionally falls through
  to `token.name === 'Dolares'`.

BalanceCard.tsx still has a local copy and uses the old name
(`useDollarBalance`); both get consolidated in the home-balance-card
commit later in this branch, together with the canonical breakdown
order.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/tokens/ src/components/TokenBottomSheet.test.tsx ->
  205/205 pass
Dollar tokens appeared in arbitrary, balance-driven order across
every surface that listed them - the swap picker, the gold output
picker, the home BalanceCard breakdown. The user explicitly asked
for a fixed canonical order ("podemos poner en el picker que el
orden siempre sea USDT, USDC, USAT, USDm") so the four brands stay
in the same visual position regardless of balance, network, or
filter chip selection.

- src/tokens/dollarGroup.ts: add a private DOLLAR_TOKEN_PICKER_ORDER
  array listing the four concrete dollar tokenIds in the canonical
  order (USDT, USDC, USAT, USDm). The order is INDEPENDENT of the
  SPEND_ORDER (which is the spending-priority sequence used by the
  multi-step planner) - this is purely about visual presentation
  and intentionally lives in the same module so callers can stay on
  one import.
- src/tokens/dollarGroup.ts: export
  `sortDollarTokensForPicker<T extends { tokenId: string }>(tokens: T[]): T[]`.
  Stable sort that only reshuffles the dollar tokens among themselves;
  non-dollar entries (Pesos, other destinations) stay in their
  original index so the helper is a safe drop-in replacement at any
  call site. Tokens missing from the canonical order fall to the
  end of the dollar group but stay ahead of non-dollar entries.
- Short-circuits when there are fewer than two dollar tokens (no
  reshuffle needed).
- Generic over T so consumers that wrap tokens in a `{ tokenInfo,
  usdValue, localValue }` shape (the home BalanceCard breakdown
  hook) can use it without losing type information.

This commit only exports the helper. The first consumers
(TokenBottomSheet for the central picker order, and the home
BalanceCard breakdown via useDollarTokensWithBalance) land in
follow-up commits.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
Every TokenBottomSheet consumer (swap, gold sell, gold buy, earn,
send, jumpstart, ...) renders the same sheet with the same picker
behaviour, so the dollar-tokens-canonical-order policy belongs here
rather than duplicated at each call site. The user explicitly asked
for the standard to apply everywhere ("la idea es que esto sea
estandar en todos") and the central placement guarantees that
without each individual screen knowing about the helper.

- src/components/TokenBottomSheet.tsx: pipe the post-filter +
  post-search token list through `sortDollarTokensForPicker` (added
  in the previous commit) before handing it to the FlatList. Dollar
  tokens reshuffle into the USDT / USDC / USAT / USDm order;
  non-dollar entries (Pesos, other destinations) keep their relative
  position so Send / Jumpstart / Earn consumers that show their own
  curated list are not disturbed.
- Inline comment explains why the sort happens here instead of at
  the call site: every consumer inherits the same order without
  needing to import or remember the helper.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/components/TokenBottomSheet.test.tsx pass
Two related cleanups to the home "Dolares" card so the breakdown
matches the picker convention row-for-row.

Canonical breakdown order
- src/tokens/hooks.ts useDollarTokensWithBalance: switch from
  sort-by-localValue-desc to sortDollarTokensForPicker (the helper
  added in feat(tokens): canonical USDT/USDC/USAT/USDm picker order
  helper). The home Dolares card breakdown now lists the four
  brands in the same canonical order as every picker, so the user
  builds one mental model instead of having to map "card order" ->
  "picker order" each time. Comment block above the hook updated
  to document the new contract.
- src/tokens/hooks.test.tsx: rename the test
  "sorts entries by localValue descending" -> "lists entries in the
  canonical picker order (USDT/USDC/USAT/USDm)" and update the
  expected ID order to [usdtId, usdcId, usdmId]. Comment explains
  that the order is now independent of balance / localValue.

Shared brand-label helper
- src/components/BalanceCard.tsx: drop the local
  getDollarTokenLabelKey copy that mapped tokenIds to brand i18n
  keys. Import the shared helper from src/tokens/dollarGroup so the
  home breakdown rows render the same brand labels as the swap
  picker and the success screen.
- Adopt useDollarUsdBalance (the explicit "USD balance" hook) for
  the dolares card amount + renderAmount surfaces, consistent with
  the rest of the file's USD-denominated values. The renderAmount
  helper now accepts an optional symbol so the Dolares card can
  render with the US$ prefix while the other cards keep the local
  currency symbol.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/tokens/hooks.test.tsx pass
Three coupled changes that turn SwapTransactionDetails into a single
panel that surfaces every per-swap detail (rate, settlement target,
spend breakdown, fees, slippage) regardless of swap direction. The
swap card was previously inconsistent: Pesos -> Dolares listed
"Tasa de cambio / Tarifas / Tolerancia" inline, while Dolares ->
Pesos rendered a separate "Vas a cambiar X en Dolares a Y Pesos"
block above plus the same fee/slippage rows below - the user
reported the second variant as "muy desordenado". This commit
unifies the layout: every direction stays inside the same bordered
panel and the differences become rows inside it.

Three contributions, all in src/swap/SwapTransactionDetails.tsx
unless otherwise noted:

1. Rate row uses display labels
   - Wrap fromToken.symbol and toToken.symbol through getTokenSymbol
     (formerly imported only for the rate template). The row now reads
     "1 Pesos ~= X Dolares" instead of "1 cCOP ~= X Dolares" -
     getTokenSymbol already maps every legacy / on-chain symbol to
     the canonical user-facing label.

2. New settlementToken prop + "Recibirás en" row
   - Added optional `settlementToken?: TokenBalance` prop. When set,
     renders a new row that reads "Recibirás en <brand>" with the
     brand resolved through getDollarTokenLabelKey.
   - Surfaces the concrete settlement target when the user picked
     the virtual "Dolares" as TO. They land on the aggregated row
     by default and this line tells them which brand the router
     will actually deliver into (USDT in the default routing) so
     the swap is not opaque about its concrete destination.
   - i18n: add `receivingIn` to es-419 and en-US.

3. New spendSteps prop + expandable "Detalle por token" row
   - Added optional `spendSteps?: SpendStep[]` prop. When set, renders
     an expandable header row that toggles between "{count} tokens"
     and "Ocultar"; on expand, the per-step breakdown (USAT / USDm /
     USDC / USDT with the per-step USD slice) appears as indented
     sub-rows.
   - Layout uses LayoutAnimation.configureNext on toggle so the
     expand collapse feels native (matches the BalanceCard
     stack-and-peek interaction).
   - Mirrors the receive-side breakdown the user already had via
     the now-standalone DolaresMultiStepSummary block (whose
     removal lands in a follow-up commit so each step here stays
     atomic).
   - i18n: add `perTokenDetail`, `perTokenDetailExpand_one`,
     `perTokenDetailExpand_other`, `perTokenDetailCollapse` to
     es-419 and en-US.

Side effect: the early return that bailed when exchangeRatePrice
was undefined now only requires fromToken + toToken (both surfaces
need at least one token to render anything meaningful). The
rate-row itself renders conditionally on exchangeRatePrice, so the
panel survives the multi-step path where the regular `quote` is
never fetched.

SwapScreen call site (src/swap/SwapScreen.tsx):
- Pass `settlementToken={quoteToToken}` ONLY when toToken is the
  virtual aggregator AND quoteToToken resolved to a distinct
  concrete token (the panel uses presence to decide whether to
  render the row).
- Pass `spendSteps={isVirtualDolares ? multiSwapPlan?.steps : undefined}`.
- Pass `exchangeRatePrice` synthesized from
  multiSwapQuote.totalOutToken / totalInUsd when the FROM is virtual
  (the regular `quote.price` is never populated in that path); fall
  back to `quote?.price` otherwise.
- Pass `fetchingSwapQuote={isVirtualDolares ? multiSwapQuote.loading : quoteUpdatePending}`
  so the skeleton state reflects the actual multi-step fetch and
  not the regular-quote pipeline that the screen no longer drives.

The standalone DolaresMultiStepSummary block render in SwapScreen
is removed in a follow-up commit so this commit stays focused on
the panel-side changes.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/swap/ src/dollarsSpend/ -> 134/134 pass
DolaresMultiStepSummary used to render a free-standing block above
the swap confirm button: a big headline that read "Vas a cambiar
$X.XX en Dolares a Y.YY {Brand}" followed by an expandable toggle
that revealed the per-step rows. That layout duplicated the FROM
amount the user had just typed, conflicted with the
SwapTransactionDetails panel that surfaced the same info more
compactly, and on the gold buy confirmation screen rendered the
brand symbol raw ("0.00 XAUt0") instead of a user-facing label.

The component now matches the SwapTransactionDetails panel shape:
- Bordered container (same padding, border, radius, gap as
  SwapTransactionDetails) so it sits naturally beside the other
  detail cards on the gold buy confirmation screen.
- Single expandable row: "Detalle por token" label on the left,
  "{count} tokens" / "Ocultar" on the right with the same
  LayoutAnimation toggle behaviour as the panel's spend-breakdown
  row.
- Per-step sub-rows render only when expanded, each with the
  step's symbol and USD slice.
- Headline + toTokenSymbol prop are gone; the component does NOT
  duplicate the FROM amount any more (the Recibes card on gold
  buy and the FROM card on swap already render it), so the brand
  / wei display bugs the headline used to have ("0.00 XAUt0",
  18-decimal output) no longer apply.
- `totalInUsd`, `totalOutToken`, `toTokenSymbol` props remain
  optional but unused so existing callers (GoldBuyConfirmation)
  don't break before the call-site cleanup commit lands.
- Returns null when steps is empty, so a caller with an empty
  plan can render the component unconditionally without an extra
  guard.

Tests rewritten to match: covers the panel container, the
collapsed -> expanded toggle, one row per step when expanded, and
the empty-steps -> null case. The fixture rows still carry the
`decimals` field (foundation commit) so the SpendStep type check
passes.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/dollarsSpend/DolaresMultiStepSummary.test.tsx pass
Removes the standalone DolaresMultiStepSummary render now that the
spend breakdown lives inside the consolidated SwapTransactionDetails
panel, and trims the GoldBuyConfirmation call site to match the new
panel-shape contract.

src/swap/SwapScreen.tsx
- Drop the standalone <DolaresMultiStepSummary> render that appeared
  below the confirm button when isVirtualDolares + plan valid +
  fromAmountUsd > 0. The same data (per-token USD slices) now lands
  via the panel's spendSteps prop, so rendering it twice would mean
  the user sees the breakdown above the confirm button and again
  below it - the layout the user explicitly flagged as
  "muy desordenado".
- Drop the `DolaresMultiStepSummary` import that this block was the
  only consumer of inside SwapScreen.
- Inline comment replaces the removed block to document why it is
  gone and to point at GoldBuyConfirmation as the remaining
  consumer of DolaresMultiStepSummary.

src/gold/GoldBuyConfirmation.tsx
- Drop the now-unused useMultiSwapQuote hook call and its import.
  The quote was only feeding totalInUsd/totalOutToken/toTokenSymbol
  into DolaresMultiStepSummary; the panel no longer accepts those
  props, the multi-step saga refetches its own quotes at execution
  time, and nothing else on this screen depended on the hook.
- Trim the <DolaresMultiStepSummary> call down to just
  `<DolaresMultiStepSummary steps={multiSwapPlan.steps} />` since
  the component drops the headline + totals.

src/swap/SwapScreen.test.tsx
- Update renderWithDollarBalances to accept an optional `toTokenId`
  param so the test can render with a resolved TO (the
  SwapTransactionDetails panel hides its rows when fromToken or
  toToken is missing).
- Rename "shows multi-step summary..." -> "renders the per-token
  spend breakdown inside the transaction-details panel..." and
  swap the testID assertion from DolaresMultiStepSummary (now
  removed from SwapScreen) to
  SwapTransactionDetails/SpendBreakdown. Inline comment documents
  the layout consolidation.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/swap/SwapScreen.test.tsx src/gold/ -> 54/54 + gold
  suite pass
…nt hint

Three coupled changes that align the gold sell flow with the swap
"virtual aggregated dollars" treatment. Before this commit the
output card defaulted to USDT specifically, the brand label fell
back to the raw token.name (so USDm rendered as "Celo Dollar" in
the chip) and the user had no way to see which concrete brand the
settlement would land in when they kept the default.

- src/gold/GoldSellEnterAmount.tsx selectedOutputToken default:
  initialise with the synthetic virtual Dolares (built via
  buildDolaresVirtualToken from useDollarBalanceSnapshots) so the
  output card lands on "Dolares" with the aggregated balance the
  user just saw on the home BalanceCard. Falls back to the USDT
  swappable token when the aggregator yields nothing (no dollar
  snapshots), and to the first swappable destination after that.
- onPressContinue settlement translation: when the user kept the
  virtual default, translate to USDT before navigating to
  GoldSellConfirmation (which needs a concrete ERC-20 in its
  route params). If USDT is not swappable on the active network,
  fall back to the first available swappable token; abort the
  navigation if neither resolves (defensive - matches the
  picker's empty-list behaviour). Picking a brand explicitly in
  the picker overrides this back to that brand.
- "Recibirás en {brand}" hint: render a new row below the balance
  display when the user kept the virtual default. Resolves the
  concrete settlement token + its brand label via
  getDollarTokenLabelKey so the user can see which specific brand
  they will receive without having to open the picker. Reuses the
  swapScreen.transactionDetails.receivingIn key for consistency.
- getTokenName: use getDollarTokenLabelKey for all four dollar
  tokens so the chip resolves to USDT / USDC / USDm / USAT
  instead of the raw token.name ("Celo Dollar" for USDm). Header
  comment explains the synthetic-virtual fallthrough to
  token.name === 'Dolares'.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
- yarn test src/gold/ pass
The "Recibes" card on GoldSellConfirmation rendered USDm as
"0.040586098192709597 Celo Dollar" - the raw 18-decimal output from
`parsedToAmount.toFormat(toToken.decimals)` plus the legacy
token.name. Two bugs in one row: too many decimals (violates the
project rule about not surfacing raw chain precision to displays)
and the wrong brand label (Mento Dollar's i18n key is mapped to
"USDm" since the i18n switch, but the GoldSellConfirmation chip
never went through getDollarTokenLabelKey).

- src/gold/GoldSellConfirmation.tsx getTokenName: switch from the
  USDT-only early return to getDollarTokenLabelKey for all four
  dollar tokens. USDm now resolves to "USDm" via assets.mentoDollar,
  not to the raw token.name "Celo Dollar". Header comment explains
  the brand-vs-name distinction and why falling through to
  token.name leaked the legacy value.
- src/gold/GoldSellConfirmation.tsx Recibes amount: replace
  `parsedToAmount.toFormat(toToken.decimals)` with
  `.decimalPlaces(getInputDecimalsForToken(toToken.tokenId),
  BigNumber.ROUND_DOWN).toFormat()`. The cap is the same display-
  decimals helper SwapScreen uses for the TO field (currently 6 for
  every token), so the user sees "0.040586 USDm" instead of the
  full 18-decimal precision dump.
- src/gold/GoldSellConfirmation.tsx imports: add the shared
  getDollarTokenLabelKey from src/tokens/dollarGroup and
  getInputDecimalsForToken from src/utils/formatting.

Network fee row (parsedGasFee.toFormat(6) + getTokenName(gasFeeToken))
inherits the brand fix because it routes through the same
getTokenName - dollar-denominated gas fees now read as their short
symbol too.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
GoldBuyEnterAmount.getTokenName had a USDT-only early return that
mirrored the GoldSellConfirmation bug: every dollar token that was
not USDT (USDC, USDm, USAT) fell through to the raw token.name,
so the chip in the input box read "Celo Dollar" for USDm instead
of "USDm". The user explicitly flagged the inconsistency
("ademas, el simbolo de dollar esta bien para dollar, pero cuando
es USDT usa el USDT symbol").

- src/gold/GoldBuyEnterAmount.tsx getTokenName: replace the early
  return for usdtTokenId / usdt-like symbols with a call to
  getDollarTokenLabelKey. The chip now resolves to the short
  symbol the user just saw in the picker (USDT / USDC / USDm /
  USAT) without dropping to the raw token.name. Header comment
  documents the same brand-vs-name distinction as the sell side.
- The synthetic-virtual fallthrough (symbol === 'dolares' check)
  still maps to assets.dollars so the aggregated row reads
  "Dolares" - getDollarTokenLabelKey returns null for the
  virtual tokenId by design.
- src/gold/GoldBuyEnterAmount.tsx imports: add getDollarTokenLabelKey
  to the existing dollarGroup import.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
The post-transaction success card rendered each TO / FROM amount
via a single TokenDisplay with showSymbol=true, which routed
through getTokenSymbol and collapsed every dollar token to the
generic "Dolares" label. The user could not tell which concrete
brand actually landed in their wallet from this screen alone
(they had to fall back to the block explorer to confirm USDT vs
USDC vs USDm). Same row had no token icon, so visual scanning was
also worse than the picker rows on the input screens.

- src/transactions/TransactionSuccessScreen.tsx: new local
  TokenAmountWithBrand helper that wraps the TokenDisplay output
  with a brand-aware layout:
  - Renders TokenIcon (size SMALL) first, sourced from
    useTokenInfo(tokenId). When the token is one of the four
    dollar stablecoins, the icon is the concrete brand logo via
    imageUrl (Tether T, USDC circle, Mento C+$, USAT badge) -
    each one is visually distinct.
  - Renders TokenDisplay with showSymbol=false for dollar tokens
    and appends the brand label resolved via
    getDollarTokenLabelKey ("USDT" / "USDC" / "USDm" / "USAT").
    Non-dollar tokens keep the existing TokenDisplay with its
    default symbol (Oro / Pesos / etc.) so this is a strict
    superset of the previous behaviour.
- Replace the from / to rows in showFromToDetails with
  TokenAmountWithBrand calls. testIDs preserved (the icon gets
  `${testID}/Icon`).
- New brandRow style (flexDirection row, alignItems center, gap
  Smallest8) keeps the icon + amount + label inline.

Affects every transaction type that hits TransactionSuccessScreen:
swap, gold buy, gold sell, send, earn deposit/withdraw,
jumpstart. Non-dollar tokens render identically to before, so this
is a pure UX upgrade for dollar-token flows.

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
The MainStackScreen useEffect that normally calls
SplashScreen.hide() never runs when NavigatorWrapper returns early
on the shouldForceUpgrade branch. The native splash stayed on top
of the UpgradeScreen, so the user saw the splash forever instead
of the "Update required" prompt - they had no idea why the app
appeared to hang and no path to actually upgrade.

- src/navigator/NavigatorWrapper.tsx: call SplashScreen.hide()
  inside requestAnimationFrame just before returning the
  UpgradeScreen render. The rAF defer ensures the hide happens
  after React has committed the UpgradeScreen mount, so the
  transition is splash -> UpgradeScreen rather than splash ->
  blank -> UpgradeScreen.

Predates the dollars-spend-ui feature work but rides this branch
because the force-upgrade gate is what surfaced the original
behaviour gap (the 1.118.4 -> 1.118.5 Statsig min-version bump on
this branch was what put the app into the shouldForceUpgrade path
on local dev devices during testing).

Verification:
- yarn build:ts pass
- yarn lint --quiet pass
….decimals

CI's "Fail if someone forgot to commit RootStateSchema.json" step
caught the missing schema regen for the SpendStep.decimals field
introduced in feat(dollarsSpend): carry token decimals on snapshot
+ step types (28d1d56). The Redux state shape did not actually
change (SpendStep only lives transiently on the screen, not in any
persisted slice), so no migration is needed; this commit just
catches the auto-generated JSON up to the type change.

Verification:
- yarn test:update-root-state-schema -> diff committed.
@TuCopFi TuCopFi merged commit 0abb389 into Development Jun 12, 2026
8 checks passed
@TuCopFi TuCopFi deleted the feat/dollars-spend-ui branch June 12, 2026 06:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant