feat: dollar tokens Phase 3.3 - virtual Dolares end-to-end + brand/icon/order harmonization#152
Merged
Merged
Conversation
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.
chore: bump version to 1.118.5
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
via
Promise.allSettledwithunquotedUsd,Logger.warnper failedstep). Same wei fix mirrored in the
executeMultiSwapsaga soconfirmation behaves identically to the preview.
SwapScreen resolves it explicitly,
quoteToTokensettles into USDTfor the actual router call, TO field renders the multi-swap output
with capped decimals).
(USDT/USDC/USDm/USAT per CLAUDE.md), concrete dollar brands route
through their
imageUrllogos (not the generic DollarsIcon SVG),the picker order is fixed (USDT, USDC, USAT, USDm) at the
TokenBottomSheetlevel so every consumer inherits it for free, andthe home BalanceCard breakdown follows the same canonical order.
SwapTransactionDetailspanel: both swap directionssurface 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.
GoldSellEnterAmountdefaults to virtual Dolares andshows a 'Recibirás en {brand}' hint;
GoldSellConfirmationcapsdecimals + resolves the brand label;
GoldBuyEnterAmountchip usesthe brand label too.
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
`Saldo: 4.67 Dolares` (the aggregated balance, not USDT only).
whole-unit decimals; 'Recibirás en USDT' row visible in the
transaction-details panel.
panel (USDm / USDC / USDT slices); no separate block below the
confirm button.
Send, Jumpstart): dollar tokens listed in
USDT / USDC / USAT / USDm order with brand-specific icons.
'Recibirás en USDT' hint; picking a brand explicitly overrides
the hint.
decimals, brand label).
with the Tether icon.
Follow-ups (not in this PR)
(the hook already exposes it; only the render is missing).
`fetchSwapQuote` / `fetchSwapQuoteForExecution`.
to have no Squid route, so $1 of USDm-only does not deadlock the
flow.