From 4a0bf8e596853c2f2aa93640000f00ca2cd654a2 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:21:21 -0700 Subject: [PATCH 01/18] Share the swap price-impact calculation and display Extract the swap confirmation scene's price-impact computation and the quote card's colored percentage text into PriceImpactText, so the send scene's quote row can reuse the same delta UI. The quote card and the provider row also stop narrowing the destination wallet inline. EdgeSwapRequest.toWallet is optional once swap-to-address exists, and every wallet-to-wallet surface needs the same guard, so requireDestinationWallet holds the narrowing and its message in one place. --- eslint.config.mjs | 1 - src/components/rows/SwapProviderRow.tsx | 6 +- .../themed/ExchangeQuoteComponent.tsx | 32 +- src/components/themed/PriceImpactText.tsx | 111 ++ src/docs/stealth-send-swap.md | 1179 +++++++++++++++++ src/util/stealthSwap.ts | 21 + 6 files changed, 1323 insertions(+), 27 deletions(-) create mode 100644 src/components/themed/PriceImpactText.tsx create mode 100644 src/docs/stealth-send-swap.md create mode 100644 src/util/stealthSwap.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index e817da3e660..04eee3db64c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -254,7 +254,6 @@ export default [ 'src/components/rows/EdgeRow.tsx', 'src/components/rows/PaymentMethodRow.tsx', - 'src/components/rows/SwapProviderRow.tsx', 'src/components/rows/TxCryptoAmountRow.tsx', 'src/components/scenes/ChangeMiningFeeScene.tsx', diff --git a/src/components/rows/SwapProviderRow.tsx b/src/components/rows/SwapProviderRow.tsx index e94a212fc60..ed08d46c21a 100644 --- a/src/components/rows/SwapProviderRow.tsx +++ b/src/components/rows/SwapProviderRow.tsx @@ -6,6 +6,7 @@ import { sprintf } from 'sprintf-js' import { useCryptoText } from '../../hooks/useCryptoText' import { lstrings } from '../../locales/strings' import { getSwapPluginIconUri } from '../../util/CdnUris' +import { requireDestinationWallet } from '../../util/stealthSwap' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { SmallText, WarningText } from '../themed/EdgeText' import { IconDataRow } from './IconDataRow' @@ -18,7 +19,10 @@ export const SwapProviderRow: React.FC = (props: Props) => { const { quote } = props const { request, toNativeAmount, fromNativeAmount } = quote const { quoteFor } = request - const { fromWallet, fromTokenId, toWallet, toTokenId } = request + const { fromWallet, fromTokenId, toTokenId } = request + // A wallet-to-wallet swap quote always carries a destination wallet; only a + // swap-to-address request (its own flow) omits it. + const toWallet = requireDestinationWallet(request) const theme = useTheme() const styles = getStyles(theme) diff --git a/src/components/themed/ExchangeQuoteComponent.tsx b/src/components/themed/ExchangeQuoteComponent.tsx index 70b110a5db6..143bcea187b 100644 --- a/src/components/themed/ExchangeQuoteComponent.tsx +++ b/src/components/themed/ExchangeQuoteComponent.tsx @@ -6,16 +6,17 @@ import { View } from 'react-native' import { useCryptoText } from '../../hooks/useCryptoText' import { formatFiatString, useFiatText } from '../../hooks/useFiatText' import { useTokenDisplayData } from '../../hooks/useTokenDisplayData' -import { formatNumber } from '../../locales/intl' import { lstrings } from '../../locales/strings' import { convertCurrency } from '../../selectors/WalletSelectors' import { useSelector } from '../../types/reactRedux' import { fixSides, mapSides, sidesToMargin } from '../../util/sides' +import { requireDestinationWallet } from '../../util/stealthSwap' import { DECIMAL_PRECISION, removeIsoPrefix } from '../../util/utils' import { EdgeCard } from '../cards/EdgeCard' import { CurrencyRow } from '../rows/CurrencyRow' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { EdgeText } from './EdgeText' +import { PriceImpactText } from './PriceImpactText' interface Props { fromTo: 'from' | 'to' @@ -27,7 +28,10 @@ interface Props { export const ExchangeQuote: React.FC = props => { const { fromTo, priceImpact, quote, showFeeWarning } = props const { request, fromNativeAmount, toNativeAmount, networkFee } = quote - const { fromWallet, fromTokenId, toWallet, toTokenId } = request + const { fromWallet, fromTokenId, toTokenId } = request + // A wallet-to-wallet swap quote always carries a destination wallet; only a + // swap-to-address request (its own flow) omits it. + const toWallet = requireDestinationWallet(request) const theme = useTheme() const styles = getStyles(theme) @@ -188,17 +192,7 @@ export const ExchangeQuote: React.FC = props => { const priceImpactNode = !isFrom && priceImpact != null && priceImpact > 0 ? ( - = 0.15 - ? styles.priceImpactHigh - : priceImpact >= 0.05 - ? styles.priceImpactMedium - : styles.priceImpactLow - } - > - {` (${formatNumber(priceImpact * 100, { toFixed: 2 })}%)`} - + ) : undefined return ( @@ -241,17 +235,5 @@ const getStyles = cacheStyles((theme: Theme) => ({ bottomWarningText: { fontSize: theme.rem(0.75), color: theme.warningText - }, - priceImpactLow: { - fontSize: theme.rem(0.75), - color: theme.deactivatedText - }, - priceImpactMedium: { - fontSize: theme.rem(0.75), - color: theme.warningText - }, - priceImpactHigh: { - fontSize: theme.rem(0.75), - color: theme.dangerText } })) diff --git a/src/components/themed/PriceImpactText.tsx b/src/components/themed/PriceImpactText.tsx new file mode 100644 index 00000000000..027fc532907 --- /dev/null +++ b/src/components/themed/PriceImpactText.tsx @@ -0,0 +1,111 @@ +import { div, lte, sub } from 'biggystring' +import type { EdgeSwapQuote } from 'edge-core-js' +import * as React from 'react' + +import type { GuiExchangeRates } from '../../actions/ExchangeRateActions' +import { formatNumber } from '../../locales/intl' +import { getExchangeDenom } from '../../selectors/DenominationSelectors' +import { convertCurrency } from '../../selectors/WalletSelectors' +import { convertNativeToExchange } from '../../util/utils' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { EdgeText } from './EdgeText' + +/** Price impacts at or above this warrant a warning. */ +export const PRICE_IMPACT_WARNING_THRESHOLD = 0.05 + +/** + * The fiat-value fraction a swap quote loses between its from and to sides + * (0.05 = 5%), or undefined when it cannot be computed or is not a loss. + * Works for wallet-to-wallet and swap-to-address quotes alike: the quote's + * destination wallet (synthetic for swap-to-address) carries the real + * `currencyConfig`. + */ +export function calculateQuotePriceImpact( + quote: EdgeSwapQuote, + exchangeRates: GuiExchangeRates, + defaultIsoFiat: string +): number | undefined { + const { request, fromNativeAmount, toNativeAmount } = quote + const { fromWallet, fromTokenId, toWallet, toTokenId } = request + if (toWallet == null) return undefined + + const fromExchangeDenom = getExchangeDenom( + fromWallet.currencyConfig, + fromTokenId + ) + const toExchangeDenom = getExchangeDenom(toWallet.currencyConfig, toTokenId) + + const fromExchangeAmount = convertNativeToExchange( + fromExchangeDenom.multiplier + )(fromNativeAmount) + const toExchangeAmount = convertNativeToExchange(toExchangeDenom.multiplier)( + toNativeAmount + ) + + const fromFiatValue = convertCurrency( + exchangeRates, + fromWallet.currencyInfo.pluginId, + fromTokenId, + defaultIsoFiat, + fromExchangeAmount + ) + const toFiatValue = convertCurrency( + exchangeRates, + toWallet.currencyInfo.pluginId, + toTokenId, + defaultIsoFiat, + toExchangeAmount + ) + + if (lte(fromFiatValue, '0')) return undefined + + const impact = parseFloat( + div(sub(fromFiatValue, toFiatValue), fromFiatValue, 8) + ) + return impact > 0 ? impact : undefined +} + +interface Props { + priceImpact: number | undefined +} + +/** + * The colored ` (x.xx%)` price-delta suffix shared by the swap confirmation + * quote card and the send scene's quote row. + */ +export const PriceImpactText: React.FC = props => { + const { priceImpact } = props + const theme = useTheme() + const styles = getStyles(theme) + + if (priceImpact == null || priceImpact <= 0) return null + + return ( + = 0.15 + ? styles.priceImpactHigh + : priceImpact >= PRICE_IMPACT_WARNING_THRESHOLD + ? styles.priceImpactMedium + : styles.priceImpactLow + } + > + {` (${formatNumber(priceImpact * 100, { toFixed: 2 })}%)`} + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + priceImpactLow: { + color: theme.deactivatedText, + fontSize: theme.rem(0.75) + }, + priceImpactMedium: { + color: theme.warningText, + fontSize: theme.rem(0.75) + }, + priceImpactHigh: { + color: theme.dangerText, + fontSize: theme.rem(0.75) + } +})) diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md new file mode 100644 index 00000000000..2147f213c2e --- /dev/null +++ b/src/docs/stealth-send-swap.md @@ -0,0 +1,1179 @@ +# Stealth Send and Stealth Swap: send to any address, on any chain, privately + +| | | +|---|---| +| Status | Implemented (pending dependency publishes) | +| Author | Jon Tzeng | +| Reviewer | - | +| Last updated | 2026-08-27 | +| Repos | [edge-react-gui](https://github.com/EdgeApp/edge-react-gui), [edge-core-js](https://github.com/EdgeApp/edge-core-js), [edge-exchange-plugins](https://github.com/EdgeApp/edge-exchange-plugins) | +| Implementation | [edge-react-gui#6066](https://github.com/EdgeApp/edge-react-gui/pull/6066), [edge-core-js#730](https://github.com/EdgeApp/edge-core-js/pull/730), [edge-exchange-plugins#469](https://github.com/EdgeApp/edge-exchange-plugins/pull/469) | +| Supersedes | prototype PRs [#6054](https://github.com/EdgeApp/edge-react-gui/pull/6054), [#6031](https://github.com/EdgeApp/edge-react-gui/pull/6031) (kept open as reference) | +| Related | [Asana task](https://app.asana.com/0/1215088146871429/1216251688512498) | + +This document describes what is built on branch `jon/stealth-send-swap` across the three repos above. Direction came from the Asana task and its UI proposal A, plus follow-up operator comments on the task. The code is the source of truth: every code block is quoted from the branch and captioned with a link pinned to the commit it was quoted from. + +## Contents + +1. [Problem](#1-problem) +2. [Prior art](#2-prior-art) +3. [Goals and non-goals](#3-goals-and-non-goals) +4. [Design overview](#4-design-overview) +5. [Detailed design: edge-core-js](#5-detailed-design-edge-core-js) +6. [Detailed design: edge-exchange-plugins](#6-detailed-design-edge-exchange-plugins) +7. [Detailed design: edge-react-gui](#7-detailed-design-edge-react-gui) +8. [The send scene UX, end to end](#8-the-send-scene-ux-end-to-end) +9. [Testing](#9-testing) +10. [Phase history](#10-phase-history) +11. [Decisions](#11-decisions) +12. [Glossary](#12-glossary) +13. [References](#13-references) +14. [Post-implementation retrospective](#14-post-implementation-retrospective) + +## 1. Problem + +Edge can send an asset to an address on its own chain, and it can swap between two wallets the user holds. It cannot do the thing users actually ask for: pay someone whose address is on a different chain, or send without the recipient being able to link the payment back to the sender's wallet. + +Both limits show up in ordinary payment flows: + +- **Cross-asset send.** A user holding ETH who owes someone 0.25 LTC has to swap ETH to LTC into their own wallet, then send. Two operations, two fees, and the swap leg needs a Litecoin wallet they may not want. +- **Privacy.** Every ordinary send writes a direct sender-to-recipient edge on chain. The recipient, and anyone reading the chain, can walk back to the sender's wallet and its balance history. + +A swap provider that pays out to an arbitrary address solves both, because the provider address sits between sender and recipient. Edge's swap stack could not express that: `EdgeSwapRequest` required a `toWallet`, an `EdgeCurrencyWallet` the user owns. + +## 2. Prior art + +**Prototype PRs [#6054](https://github.com/EdgeApp/edge-react-gui/pull/6054) and [#6031](https://github.com/EdgeApp/edge-react-gui/pull/6031)** proved the flow but are not shippable. They add a parallel `HoudiniSendScene` reached by rerouting the wallet Send button from `TransactionListTop`, hardcode four destination chains with hand-written `memoNeeded` flags, recreate the price-delta UI that `SwapConfirmationScene` already has, and fake the destination wallet in GUI code. The fork means every other send entry point in the app keeps the old behavior. + +**A GUI-built fake destination wallet** was tried first and does not work. The object has to cross the [yaob](#yaob) bridge into the core to reach the swap plugin, and a plain JavaScript object's function properties do not survive that wire format, so any plugin call on it fails. This is the finding that moved the synthetic wallet into the core, recorded in [Decision: build the synthetic destination wallet in the core](#build-the-synthetic-destination-wallet-in-the-core). + +**Existing swap-provider address entry** (the "send to address" some providers expose) is not reusable either: it lives inside the swap scenes and assumes the user is trading between their own assets, so it does not give the send scene a destination. + +## 3. Goals and non-goals + +Goals: + +- Send from any wallet to an address on any chain the provider serves, from the ordinary send scene. +- Offer a privacy-routed send (Stealth Send) and a privacy-routed swap (Stealth Swap) that pin the request to the privacy provider. +- Accept the recipient address through every entry path a user might reach for: paste, typed entry, and scanned QR. +- Leave every constrained send flow exactly as it is: payment protocol, [FIO](#fio) requests, deep links, and any caller that pre-locks tiles or takes over broadcast. + +Non-goals: + +- **Token destinations.** Only native chain assets are offered as a destination. `getHoudiniChain` returns `undefined` for a non-null `tokenId`, so the recipient picker lists chains only. Token sources are supported and tested. +- **Max spend in swap-send mode.** The plain-mode max flow is untouched; a swap-send max through the plugins' `getMaxSwappable` is deferred, see [Phase history](#10-phase-history). +- **Multiple recipients with swap-send.** Gated off in both directions, see [Multi-recipient gating](#multi-recipient-gating). +- **Telling two [EVM](#evm) chains apart from a bare address.** Physically impossible from the address alone; see [Decision: ask the user when the address format is ambiguous](#ask-the-user-when-the-address-format-is-ambiguous). + +## 4. Design overview + +| Repo | Deliverable | Scope | +|---|---|---| +| [edge-core-js#730](https://github.com/EdgeApp/edge-core-js/pull/730) | `toAddressInfo` on `EdgeSwapRequest`, core-built [synthetic destination wallet](#synthetic-destination-wallet) | [Section 5](#5-detailed-design-edge-core-js) | +| [edge-exchange-plugins#469](https://github.com/EdgeApp/edge-exchange-plugins/pull/469) | HoudiniSwap plugin, chain mapping, destination-[memo](#memo) threading | [Section 6](#6-detailed-design-edge-exchange-plugins) | +| [edge-react-gui#6066](https://github.com/EdgeApp/edge-react-gui/pull/6066) | Send scene becomes a send-to-address swap, Stealth toggles, cross-chain address entry | [Section 7](#7-detailed-design-edge-react-gui) | + +The seam is one optional field. The GUI describes the destination as data (`toAddressInfo`); the core turns that description into an object shaped like a wallet; the plugin consumes it through the wallet surface it already knows. No plugin needs to learn about addresses-instead-of-wallets. + +```mermaid +sequenceDiagram + box edge-react-gui + participant Send as SendScene2 + end + box edge-core-js + participant API as swap-api + participant Synth as synthetic-wallet + end + box edge-exchange-plugins + participant Plug as houdini plugin + end + participant H as HoudiniSwap API + + Send->>API: fetchSwapQuotes({ toAddressInfo, quoteFor }, stealthOptions) + API->>API: resolveSwapRequest: exactly one of toWallet / toAddressInfo + API->>Synth: makeSyntheticDestinationWallet(currencyConfig, toAddress, toMemos) + Synth-->>API: bridgified EdgeCurrencyWallet + API->>Plug: fetchSwapQuote(request with toWallet = synthetic) + Plug->>Plug: getAddress(toWallet) / getDestinationMemos(toWallet) + Plug->>H: GET /tokens, GET /quotes + H-->>Plug: routes + Plug->>H: POST /exchanges (destinationTag from memos) + H-->>Plug: deposit address + Plug-->>Send: EdgeSwapQuote (approve sends to the deposit address) +``` + +## 5. Detailed design: edge-core-js + +### The request contract + +`EdgeSwapRequest` gains one optional field, and `toWallet` becomes optional. Exactly one of the two must be present. + +[`src/types/types.ts`](https://github.com/EdgeApp/edge-react-gui/blob/0d1074d79f46b50ab6a8c494a66dc476fc98074b/src/types/types.ts) +```ts +export interface EdgeSwapToAddressInfo { + toPluginId: string + toAddress: string + + /** + * Destination memos (e.g. an XRP destination tag) for memo-required payout + * chains. This descriptor field is only the GUI-to-core transport: swap + * plugins never read it. The core copies it onto the synthetic destination + * wallet, which exposes it through `getMemos` (see + * `EdgeSyntheticDestinationWallet`), so plugins consume destination memos + * through the wallet surface alone. + */ + toMemos?: EdgeMemo[] +} + +export interface EdgeSyntheticDestinationWallet extends EdgeCurrencyWallet { + readonly getMemos: () => Promise +} +``` + +The `getMemos` split matters: a descriptor field the plugin could read directly would give plugins two ways to find destination memos, one of which does not exist on real wallets. Routing memos through the wallet surface keeps one code path in the plugin. + +### Resolving the request + +`resolveSwapRequest` in `src/core/swap/swap-api.ts` enforces the exactly-one rule, validates the plugin and token exist, builds the synthetic wallet, and **drops the descriptor** from the resolved request: + +[`src/core/swap/swap-api.ts`](https://github.com/EdgeApp/edge-core-js/blob/e2bef4ed8ca1e0c741f25aa008f2e120615de007/src/core/swap/swap-api.ts) +```ts + // Drop the descriptor from the resolved request so it keeps exactly one + // destination: a resolved request that rides back to the caller inside + // `quote.request` must be re-submittable without tripping the + // exactly-one-of rule above. + return { + ...request, + toAddressInfo: undefined, + toWallet: makeSyntheticDestinationWallet(currencyConfig, toAddress, toMemos) + } +``` + +Dropping it is not tidiness. `quote.request` rides back to the GUI and can be resubmitted; leaving both fields set would make the resubmission throw. + +### The synthetic wallet + +`src/core/swap/synthetic-wallet.ts` builds an object backed by the real `EdgeCurrencyConfig` the core already holds, so `currencyInfo` and `allTokens` are authentic while the address accessors return the pasted address: + +[`src/core/swap/synthetic-wallet.ts`](https://github.com/EdgeApp/edge-core-js/blob/e2bef4ed8ca1e0c741f25aa008f2e120615de007/src/core/swap/synthetic-wallet.ts) +```ts +export const SYNTHETIC_WALLET_ID_PREFIX = 'synthetic://' + +export function makeSyntheticDestinationWallet( + currencyConfig: EdgeCurrencyConfig, + toAddress: string, + toMemos: EdgeMemo[] = [] +): EdgeCurrencyWallet { +``` + +The id prefix is a public contract: plugins branch on it to skip address-type lookups that make no sense for a single pasted address (see [Section 6](#6-detailed-design-edge-exchange-plugins)). + +The wallet is bridgified so plugin calls work unchanged across the core's WebView boundary, and anything bridgified stays in [yaob](#yaob)'s object table until something closes it. One synthetic wallet is built per `fetchSwapQuotes` call and shared by every quote that call returns, and the caller reaches it through `quote.request.toWallet`, so it is released by reference count: closed when the last quote carrying it is closed, and immediately when no quote survives to carry it. Without that, every quote refresh on a swap-to-address screen would leave another wallet in the table for the life of the account. The same reasoning is why `resolveSwapRequest` reuses the account's long-lived `currencyConfig` instead of building one per request. + +### Error reporting + +`SwapCurrencyError` reads the destination pluginId from the descriptor when `request.toWallet` is absent, so a swap-to-address failure names the destination chain instead of throwing inside the error constructor. + +[`src/types/error.ts`](https://github.com/EdgeApp/edge-core-js/blob/e2bef4ed8ca1e0c741f25aa008f2e120615de007/src/types/error.ts) +```ts + const toPluginId = + toWallet?.currencyConfig.currencyInfo.pluginId ?? + toAddressInfo?.toPluginId ?? + '' +``` + +## 6. Detailed design: edge-exchange-plugins + +### Plugin identity and transport + +[`src/swap/central/houdini.ts`](https://github.com/EdgeApp/edge-exchange-plugins/blob/b83888a640086966cf499293ac2d7a0943896c20/src/swap/central/houdini.ts) +```ts +export const swapInfo: EdgeSwapInfo = { + pluginId, + isDex: false, + displayName: 'HoudiniSwap', + supportEmail: 'support@houdiniswap.com' +} +``` + +Two transport facts decide whether any call works at all, and neither is obvious: + +- Auth is `Authorization: :` with no `Bearer` prefix. Every endpoint returns 402 without it. +- The partner API is server-to-server and answers browser-origin requests with 403. The core runs plugins inside a WebView, so `io.fetch` carries `Origin` / `Sec-Fetch-*` headers. Every call therefore passes `corsBypass: 'always'`, which routes through the native fetch host-side and matches the contract the API expects. + +### Destination handling + +The plugin reads the destination through the wallet surface, with two branches for the synthetic case: + +[`src/swap/central/houdini.ts`](https://github.com/EdgeApp/edge-exchange-plugins/blob/b83888a640086966cf499293ac2d7a0943896c20/src/swap/central/houdini.ts) +```ts +async function getDestinationMemos( + toWallet: EdgeCurrencyWallet +): Promise { + const { getMemos } = toWallet as EdgeCurrencyWallet & + SyntheticDestinationMethods + if (getMemos == null) return [] + return await getMemos() +} +``` + +and, in `fetchSwapQuoteInner`: + +[`src/swap/central/houdini.ts`](https://github.com/EdgeApp/edge-exchange-plugins/blob/b83888a640086966cf499293ac2d7a0943896c20/src/swap/central/houdini.ts) +```ts + // A synthetic (swap-to-address) destination holds exactly one pasted, + // caller-validated address, so a typed-address lookup does not apply. + const isSyntheticDestination = toWallet.id.startsWith( + SYNTHETIC_WALLET_ID_PREFIX + ) +``` + +Memos become `destinationTag` on order creation, which is what [memo](#memo)-required chains (XRP, XLM, Cosmos Hub, Hedera, Thorchain) need to credit the payment. + +### Chain mapping + +`src/mappings/houdini.ts` maps every Edge `EdgeCurrencyPluginId` to a Houdini chain `shortName`, or `null` where Houdini has no compatible chain. [IBC](#ibc)-family chains (coreum, osmosis, axelar) are deliberately `null`: Houdini reports no `memoNeeded` flag and a permissive `^.*$` address validation for them, so their payout semantics are not trustworthy enough to offer. + +The table answers what Houdini calls a chain, not whether Houdini serves it. Those are different questions with different lifetimes: the name is stable, while what is served changes whenever the provider adds or drops a native coin. Whether a chain has a tradable native is discovered at runtime by `resolveTokenId`, which declines with the same `SwapCurrencyError` `checkWhitelistedMainnetCodes` raises, so a mapped-but-unserved name costs one memoized lookup rather than a wrong answer. `celo`, `fantom` and `polkadot` are absent from `GET /tokens?mainnet=true` entirely and `ton` carries one token and no native, and all four decline through that path without the table having to say so. Houdini does serve DOT, under the chain name `AssetHub` rather than `polkadot`; that remapping is untested and deliberately not made here. + +The memoization is what makes this affordable. `resolveTokenId` caches misses as well as hits, so an unserved chain is asked about once per ten-minute window instead of once per quote. The window matters in both directions: without it, a chain Houdini lists later in the session stays refused until the app restarts, which is the failure `.cursor/BUGBOT.md`'s `catalog-cache-expiry` rule exists to prevent for exactly this kind of provider catalog. A lookup the provider FAILED to answer is deliberately not cached at all: a rate limit or a server error says nothing about whether the chain is served, and caching it would turn one bad minute into a chain that stays dead for the rest of the window. + +### Route selection + +Quotes are filtered by route type, and the caller decides which types are acceptable: + +[`src/swap/central/houdini.ts`](https://github.com/EdgeApp/edge-exchange-plugins/blob/b83888a640086966cf499293ac2d7a0943896c20/src/swap/central/houdini.ts) +```ts + const privateOnly = request.privacy === 'required' + const candidateQuotes = quotes + .filter( + (quote): quote is HoudiniQuote => + quote != null && + (quote.type === 'private' || + (!privateOnly && quote.type === 'standard')) + ) +``` + +A request carrying `privacy: 'required'` takes `private` (multi-exchange) routes only, which is what makes Stealth private. Without it, `standard` routes are acceptable too, ranked below private. The distinction decides what a user can send: Houdini serves no private route under 25 USD but serves standard routes down to 10, so a plain Swap & Send between those two figures is only possible on a standard route, while a Stealth Send at the same amount has nothing to route through. See [Minimum order sizes](#minimum-order-sizes). + +A `standard` route still settles through Houdini, so the recipient never sees the sender's address, but it uses a single exchange leg that can relink the two sides. That is why a privacy request must decline rather than accept one: the caller has no way to inspect which route it got, so a silent downgrade would be undetectable. + +Houdini prices exact-out on fixed-rate quotes alone, which its private routing does not serve, so a privacy request priced by the receive side finds nothing and declines. The send scene answers that by re-pricing from the send side and keeping its privacy, which is the [fixed-to fallback](#availability-fallbacks) it already had. + +This filter is the reason a live availability change on the provider's side can disable forward swap-to-address sends without any code change here; see [Retrospective item 2](#where-this-document-was-wrong-or-silent). + +### Rate limits + +Houdini is an aggregator behind Cloudflare, and tight request loops get blocked in a way that poisons the answers: a 429 arriving where a quote was expected reads exactly like an unavailable pair, and caching that verdict would teach the UI something false. Every call therefore goes through one wrapper that retries a 429 behind the `retryAfter` the API reports, doubling on top of it so a burst does not re-collide the moment the window reopens, and gives up after three attempts with an error that says rate limit rather than unavailable. Since only a plain `Error` comes back, the send scene's `SwapCurrencyError` branch never fires, so no `routeCaps` entry is written and no toggle turns itself off on the strength of a throttled request. + +Two bounds sit on that retry, and they pull in opposite directions. The doubling is capped, but the cap bounds OUR OWN growth only: the window the API asked for always survives it, because retrying inside a window the provider named just draws another 429 and spends the budget for nothing. Against that, a retry is only worth waiting for if what it resends is still alive when the wait ends. Houdini quote ids live about a minute and the exchange budget reports `retryAfter` near sixty seconds, so honoring the window and re-POSTing the same quote id hangs the user for a minute and then fails as an expired quote, blaming the wrong thing. The create-exchange call therefore passes the candidate quote's own expiry into the wrapper, and a wait that would land past it fails immediately as a rate limit. `validUntil` arrives as Unix seconds inside a string, which `new Date` reads as an invalid date, so the parse reads the number first. + +The `max` path is the other place the exchange budget bites. `getMaxSwappable` runs the quote function once to size the spend and the real quote runs it again, so creating an order on the sizing pass spends one of the one-per-minute slots and guarantees the real create is throttled. The sizing pass builds its spend shape from the quote alone, standing in the user's own refund address for the deposit address it does not have. + +Standing in the user's own address is what forces the other two properties of that probe. An [EVM](#evm) engine compares a spend target against its own public key, which IS the address, and rejects the match with `SpendToSelfError`; that error escapes `getMaxSwappable` and fails every max swap from an EVM wallet unless the probe's `spendInfo` sets `skipChecks: true`. And the probe deliberately quotes the full PRE-FEE balance to find the ceiling, so an above-limit balance has to clamp through `getMaxSpendable` rather than throw `SwapAboveLimitError` and abort a max swap that fits once the network fee comes off: the route maximum is enforced on the real quote only. Both are pre-PR checklist items in `docs/CREATING_AN_EXCHANGE_PLUGIN.md` and both are modelled in `src/swap/central/template.ts`. + +### Amount safety + +Three rules govern every amount that crosses the provider boundary, and all three come from the repo's own checklist rather than from anything Houdini-specific. + +Provider amounts arrive as JSON floats, so they reach `biggystring` through `floatToDecimalString`, which expands scientific notation at both ends of the range. Comparison and sorting go through `biggystring` too, not through the floats: `String(smallFloat)` can produce notation that a string comparison misreads, and the rule covers ranking as much as arithmetic. + +Rounding to whole atomic units has a DIRECTION, and it is not cosmetic. A minimum rounds UP, so the floor Edge enforces never lands below the provider's own and a deposit is not rejected on arrival. A maximum, the receive amount, and the deposit amount round DOWN, so none of them is ever larger than what the provider will honor. + +The deposit amount is also a trust boundary. `order.inAmount` comes back from Houdini and becomes a signed spend, so a `from` quote refuses an order asking for more of the source asset than the user requested. Only a `from` quote can make that comparison: on a reverse (`to`) quote the user pinned the receive amount, so the send side is the provider's to price and there is nothing local to bound it against. + +Their published tiers are 5 quote requests per minute on free and 500 on pro. Nothing in the app probes them; the wrapper is the only rate-limit machinery, per the no-probing rule in [Learn route availability from live failures](#learn-route-availability-from-live-failures-not-probes-or-tables). + +### What the plugin reports as fixed + +Only the exact-out path sends `fixed=true`, and that is the only path Houdini serves fixed rates on, so `isEstimate` is `!reverseQuote` rather than a constant. A forward quote reports itself as an estimate whether its route is private or standard, because its rate can still move. `makeSwapPluginQuote` reads `isEstimate` off the saved action, so the quote and the transaction details agree from one source. + +### Same-asset is allowed here, and only here + +Every other central swap plugin rejects a swap from an asset to itself through the shared `checkInvalidTokenIds`, which is right for a provider where it would be a no-op the user cannot have meant. Routing an asset to itself through a mixer is this provider's main flow, so the shared helper grew an `allowSameAsset` option that Houdini passes and nothing else sets. The blocked-token half of that helper still applies here; only the same-asset rejection is waived. + +## 7. Detailed design: edge-react-gui + +### Where the feature is allowed to appear + +`SendScene2` gains the feature in place rather than in a parallel scene. The gate is a single predicate: + +[`src/components/scenes/SendScene2.tsx`](https://github.com/EdgeApp/edge-react-gui/blob/0d1074d79f46b50ab6a8c494a66dc476fc98074b/src/components/scenes/SendScene2.tsx) +```ts + const swapSendAllowed = + lockTilesMap.address !== true && + lockTilesMap.amount !== true && + lockTilesMap.wallet !== true && + hiddenFeaturesMap.address !== true && + hiddenFeaturesMap.amount !== true && + fioPendingRequest == null && + onDone == null && + alternateBroadcast == null && + beforeTransaction == null && + initSpendInfo?.spendTargets[0]?.publicAddress == null +``` + +Every constrained caller fails at least one clause, so payment protocol, [FIO](#fio) requests, deep links, and any caller taking over broadcast keep today's behavior exactly. The last clause is also why deep links do not enter this flow: they pre-fill an address. + +Activation is then: + +[`src/components/scenes/SendScene2.tsx`](https://github.com/EdgeApp/edge-react-gui/blob/0d1074d79f46b50ab6a8c494a66dc476fc98074b/src/components/scenes/SendScene2.tsx) +```ts + const destPluginId = recipientPluginId ?? pluginId + const sameAsset = destPluginId === pluginId && tokenId == null + const crossAssetPicked = recipientPluginId != null && !sameAsset + const crossAsset = !sameAsset + const swapSendActive = swapSendAllowed && (stealth || crossAssetPicked) +``` + +The two cross-asset booleans answer different questions and a token source is where they part. `crossAssetPicked` is what turns a plain send into a swap-send on its own, and it is also the test for whether switching Stealth off would help: without an adopted recipient asset, the toggle is the only thing making this a swap. `crossAsset` labels the flow, and a token send to its own chain pays out that chain's native asset, so it crosses assets even though nobody picked a recipient. Reading one where the other belongs titled such a send "Stealth Send". + +`recipientPluginId` never names the source chain, because the picker never offers it: its first row already stands for the source chain, and a second row for the same chain quotes identically while flipping `crossAssetPicked`. Two rows that produce one order and recover from a missing route two different ways are not a choice a user can make deliberately. + +### Quote request + +When active, the scene requests a quote instead of building a spend. `makeSpend` is skipped entirely (`if (swapSendActive) { setEdgeTransaction(null); … return }`) because the transaction comes from the quote. + +Stealth restricts the request to the privacy provider through a shared helper, `src/util/stealthSwap.ts`, used by both the send scene and the swap scene: + +[`src/util/stealthSwap.ts`](https://github.com/EdgeApp/edge-react-gui/blob/0d1074d79f46b50ab6a8c494a66dc476fc98074b/src/util/stealthSwap.ts) +```ts +export function makeStealthSwapRequestOptions( + account: EdgeAccount, + opts: EdgeSwapRequestOptions = {}, + flags: StealthSwapFlags = {} +): EdgeSwapRequestOptions { + const disabled: EdgePluginMap = { ...opts.disabled } + for (const swapPluginId of Object.keys(account.swapConfig)) { + if (swapPluginId !== 'houdini') disabled[swapPluginId] = true + } + return { + ...opts, + disabled, + forceEnabled: + flags.ignoreProviderSetting === true + ? { ...opts.forceEnabled, houdini: true } + : opts.forceEnabled, + preferPluginId: undefined, + preferType: undefined + } +} +``` + +Clearing `preferPluginId`/`preferType` matters: a user's saved provider preference would otherwise fight the restriction. `ignoreProviderSetting` is how the send path opts out of the account's exchange settings, per [Provider availability versus exchange settings](#provider-availability-versus-exchange-settings); the Exchange scene leaves it unset. + +Every send-to-address quote goes through these options, stealth toggle on or off: send-to-any is a privacy feature and is Houdini-exclusive by operator direction. Making the restriction conditional on the toggle (`stealth ? ... : undefined`) breaks that guarantee, whatever a stale description elsewhere may say; the reasoning is in [Decision: send-to-any is Houdini-exclusive, toggle or no toggle](#send-to-any-is-houdini-exclusive-toggle-or-no-toggle). + +### Linked amounts + +"You send" and "Recipient gets" are linked through one piece of state, `guaranteedSide`. The edited side is guaranteed and the other tracks the live quote as an estimate. Each row says which it is in its own title rather than under the amount: `You send (Guaranteed)` with the state word in green, `Recipient gets (Estimated)` with it in warning orange, so the amount is the last thing on the row and the pair reads as one line each. The estimated side keeps the `~` prefix on its number. `EdgeRow` renders the word from a `titleState` node, which is how a row tints part of its header without rebuilding the shared header style. Editing "Recipient gets" issues `quoteFor: 'to'`, a reverse quote. + +Both amounts are entered through the standard crypto/fiat flip input (`FlipInputModal2`), committed on close so quotes still fire per commit rather than per keystroke; a zero or untouched amount is a dismissal. Max is hidden because max spend is not offered in swap-send mode. The destination side has no wallet, so its modal borrows the user's own wallet on the destination chain for denominations and rates (with a plain text modal as the fallback when no such wallet exists); the borrowed wallet's balance row reads as that wallet's balance, which is cosmetic noise accepted for reusing the standard modal. + +Both sides open on **fiat**. That is what the Exchange scene's two inputs and the plain send both do, so opening on crypto made this scene the only amount entry in the app that did not; it is also the denomination the decision is actually made in here, since the provider states its floors in USD and a cross-asset pair has no common crypto unit to compare its two sides in. The one entry still denominated in crypto is the no-destination-wallet fallback: a plain text modal has no rate to price against, and the account holding no wallet on the destination chain is exactly the case where no rate is guaranteed to be loaded. `FlipInputModalResult` does not report which side the user finished on, so nothing remembers a per-session preference; the Exchange scene does not either, and one fixed opening denomination beats two divergent memories. + +### Cross-chain address entry + +A destination on another chain cannot go through the source wallet's `parseUri`, so `AddressTile2` takes two hooks. The first validates a known-cross-chain address against the destination chain's own regex. The second, `onUnparsedAddress`, is the one that makes the feature discoverable: + +[`src/components/tiles/AddressTile2.tsx`](https://github.com/EdgeApp/edge-react-gui/blob/0d1074d79f46b50ab6a8c494a66dc476fc98074b/src/components/tiles/AddressTile2.tsx) +```ts + onUnparsedAddress?: ( + address: string, + addressEntryMethod: AddressEntryMethod + ) => Promise +``` + +It fires when this wallet's chain cannot read the input, immediately before the invalid-address toast. Because it hangs off `changeAddress`, which every entry affordance funnels through, one hook covers Paste, Enter address, and Scan at once. + +`SendScene2`'s handler detects the chain, adopts it as the recipient asset, and applies the address. Chain detection lives in `src/util/houdiniChains.ts`: + +[`src/util/houdiniChains.ts`](https://github.com/EdgeApp/edge-react-gui/blob/0d1074d79f46b50ab6a8c494a66dc476fc98074b/src/util/houdiniChains.ts) +```ts +export function detectHoudiniChains( + text: string, + opts: { + /** The sending wallet's chain. */ + sourcePluginId: string + /** The sending wallet's token, or `null` for the chain's own coin. */ + sourceTokenId: string | null + /** Whether the account has a currency plugin for this chain. */ + isSupported: (pluginId: string) => boolean + } +): HoudiniChain[] +``` + +A URI scheme names its chain outright and wins. A bare address is matched against each served chain's regex; several chains share a format, so every match is returned and the caller disambiguates. The source chain is dropped from the candidates only when the source IS that chain's coin: from a TOKEN it is a real destination, since USDC on Ethereum paying out native ETH is a cross-asset route no plain send can make, and dropping it left a pasted `0x` address offering every other [EVM](#evm) network but not the one the recipient holds. The chain table entry is: + +[`src/util/houdiniChains.ts`](https://github.com/EdgeApp/edge-react-gui/blob/0d1074d79f46b50ab6a8c494a66dc476fc98074b/src/util/houdiniChains.ts) +```ts +export interface HoudiniChain { + pluginId: string + houdiniShortName: string + memoNeeded: boolean + hasSelfPrivate: boolean + addressValidation: RegExp +} +``` + +`HOUDINI_CHAINS` is a snapshot of Houdini's mainnet native tokens (v2 partner API, re-fetched 2026-07-30) intersected with Edge pluginIds. The table holds 34 chains, 5 of them [memo](#memo)-required (`cosmoshub`, `hedera`, `ripple`, `stellar`, `thorchainrune`). Two of the provider's published regexes are corrected in the table with the reason inline; see [Decision: correct the provider's address regexes rather than route around them](#correct-the-providers-address-regexes-rather-than-route-around-them). `hasSelfPrivate` is covered in [Same-asset private capability](#same-asset-private-capability). + +Because `setRecipientPluginId` has not re-rendered when the address is applied, the detected chain is threaded through the result object rather than read back from state: + +[`src/components/scenes/SendScene2.tsx`](https://github.com/EdgeApp/edge-react-gui/blob/0d1074d79f46b50ab6a8c494a66dc476fc98074b/src/components/scenes/SendScene2.tsx) +```ts + // A destination detected from the address itself makes this a cross-asset + // send. `setRecipientPluginId` has not re-rendered yet, so the routing + // below reads the detected chain rather than the stale render-time state. + const uriGuaranteesReceiveSide = + detectedDestPluginId != null || (swapSendActive && !sameAsset) +``` + +### Payment URI amounts + +A scanned QR carries a payment URI, not a bare address. `src/util/paymentUri.ts` splits one generically, with no chain-specific parser, because the destination chain has no wallet whose `parseUri` could do it: + +[`src/util/paymentUri.ts`](https://github.com/EdgeApp/edge-react-gui/blob/0d1074d79f46b50ab6a8c494a66dc476fc98074b/src/util/paymentUri.ts) +```ts +export interface ParsedPaymentUri { + addressCandidates: string[] + displayAmount?: string + scheme?: string +} +``` + +Candidates are returned in priority order (raw trimmed text, scheme-prefixed path, naked path) so [cashaddr](#cashaddr)-style addresses that keep their `prefix:` on chain still validate. + +A URI amount is what the recipient should **receive**, so for a cross-asset destination it sets the receive side as guaranteed and lets the quote price the send side. Same-asset (stealth) sends keep it on the send side, because the provider offers no receive-priced route when source and destination assets match; guaranteeing the receive side there would make every same-asset payment URI unquotable. + +### Availability fallbacks + +Whether the provider offers a private route, or a receive-priced (fixed) route, is a live property of each pair. The scene learns it from real quote failures. A `SwapCurrencyError` while the receive side is guaranteed flips the guarantee to the send side, seeds the send amount from display exchange rates so the send stays actionable, and raises a warning card that clears on the next amount edit. One while stealth is on for a **same-asset** pair turns the toggle off (toast, persistent info line) and degrades the swap into the plain direct send the toggle had upgraded. A **cross-asset** pair with no route keeps the plain error card: the request is Houdini-only with or without the toggle, so flipping it changes nothing and there is no other provider to degrade into. Learned capabilities are cached per pair in session state (`routeCaps`), so a later attempt to re-arm the toggle or re-fix the receive amount answers with a pre-emptive toast instead of another doomed quote. The full branch structure is the flowchart in [Section 8](#8-the-send-scene-ux-end-to-end). + +Two effects write the scene's single `error` state, the quote effect and the plain-send `makeSpend` effect, and each retracts only its own message when the other takes over. Entering swap-send mode clears a plain-send failure so an insufficient-funds message cannot sit over a valid quote; leaving it clears the swap's failure so a minimum-amount message cannot sit over the plain send the user switched to. Provenance is tracked rather than guessed, because clearing unconditionally in either direction wipes the other effect's answer. + +The toggle is a dependency of the quote effect, so flipping it always invalidates the held quote and re-fetches. It has to be: the request now carries `privacy: 'required'` only when stealth is on, and the floor that applies changes with it, so the two states genuinely ask the provider different questions. An earlier revision left `stealth` out on the grounds that the request did not vary with it, which was true then and is not now; a cross-asset pair would have shown standard-route pricing under a Stealth label. + +### Minimum order sizes + +Houdini enforces its own minimum per route type, in USD. The figures live as named constants (`HOUDINI_MIN_USD` in `src/util/houdiniChains.ts`) with the provider's guidance quoted beside them, rather than as literals at the call sites: + +| Route | Minimum | Used by | +|---|---|---| +| private | 25 USD | every Stealth flow, including same-asset | +| standard | 10 USD | plain Swap & Send | +| [dex](#dex) | 5 USD | assets carrying `hasDex` | + +These were confirmed against the live v2 API before being written down, not taken on the provider's word. Cross-asset TRX to LTC answered `422 Amount is too low, minimum is 10 USD` at 8 USD, returned standard routes only from 12 through 24 USD, and added private routes from 25 up. Same-asset TRX to TRX answered `422 Amount is too low, minimum is 25 USD` below 25 and returned private routes only above it. Both match the stated floors. + +The scene enforces them before any request goes out. Under the private floor the Stealth toggle refuses to arm and explains why; under the applicable floor the quote effect returns an under-minimum error instead of calling the API. Pre-empting matters for more than tidiness: a user thumbing through small amounts would otherwise spend the provider's rate limit on requests whose refusal is already known, and those 429s come back looking like unavailable routes. + +Floors are not the whole story. Individual tokens carry higher server-side minimums that cannot be known upfront (Polygon private is effectively 60 USD against a 25 USD floor). Those arrive as quote errors carrying the real figure, and the error card shows the provider's own message, per [Phase 9](#phase-9-real-failures-in-the-error-ui). Clearing the floor is necessary, never sufficient. + +### Destination assets are route-derived + +Every surface that decides whether an asset can be a send-to-address destination reads the route metadata (`HOUDINI_CHAINS` through `getHoudiniChain`), never a hardcoded asset shape. That covers address detection, the recipient-asset picker, quote gating, and the "Myself" picker. Tokens are absent from all of them for one reason: `getHoudiniChain` returns undefined for a non-null `tokenId`. When the provider serves token destinations, relaxing that one function surfaces them everywhere at once. + +### What the recipient receives + +The "Recipient receives" row and the picker that edits it name one asset, computed once: + +[`src/util/houdiniChains.ts`](https://github.com/EdgeApp/edge-react-gui/blob/0d1074d79f46b50ab6a8c494a66dc476fc98074b/src/util/houdiniChains.ts) +```ts + return swapSendActive + ? { pluginId: destPluginId, tokenId: null } + : { pluginId: sourcePluginId, tokenId: sourceTokenId } +``` + +A swap-send pays out the destination chain's native asset, because the quote asks for `toTokenId: null` and token destinations are not offered; a plain send delivers the source asset verbatim, token included. Deciding that separately in the row and in the picker is what let them disagree: the picker offered a USDT wallet "Tether (USDT)" and the row underneath then read "Ethereum (ETH)". + +The picker's rows are keyed on the asset rather than on its label. A display name is not an identity: several chains share a currency code (ETH on Ethereum, Base and Arbitrum), and the POL ERC-20 on Ethereum shares its display name AND its currency code with the Polygon chain, so a name-keyed list marked both rows selected and resolved either tap to the same destination, leaving Polygon unreachable from a POL wallet. `RadioListModal` rows therefore carry an optional `value`, defaulting to the name so existing callers are unchanged, which is also what the row's `testID` follows. The same opt-in shape adds the search box the 34-chain list wants, reusing the filtering `ListModal` already provides. + +The "Myself" picker follows the same rule. It offers the source asset plus every chain the provider pays out to, filtered to assets the account actually has a `currencyConfig` for. Same-asset wallets pin to the top of the modal through an opt-in grouping prop on `WalletListModal` (`pinnedAssets`, `pinnedTitle`, `otherTitle`); callers that omit it keep the default recent/all ordering. The source wallet stays excluded, since sending an asset to itself is not a transfer. The control's own visibility follows the same rule: it appears when the account holds ANY wallet among those routable assets. The plain-send test it used to share, another wallet of the same type, is right for a plain send and wrong here, because it hides the picker from exactly the account it exists for: one wallet on the source chain and the rest elsewhere. A cross-asset pick runs through `adoptCrossChainDestination`, the same path address detection uses, so the recipient asset, the destination tag, and the held quote all reset identically. + +Private-route availability does not filter this list. A pair whose private route is missing is still a legal destination; the Stealth toggle is what turns itself off, per [Availability fallbacks](#availability-fallbacks), evaluated per selection rather than cached per asset. + +Every chain in the table resolves to a native token the provider actually serves, which is a stronger claim than it sounds. `celo`, `fantom`, `polkadot` and `ton` were listed for a while and are not served: the API returns no mainnet native for them, so the UI offered four destinations whose every quote threw. They are gone. Verifying that also turned up the empty-string defect described in [Retrospective item 6](#where-this-document-was-wrong-or-silent). + +### Same-asset private capability + +Sending an asset to itself privately is a per-asset capability, not a per-pair one, and Houdini publishes it directly: `hasSelfPrivate` on the token query. It is mirrored onto each `HoudiniChain` entry, so `getHoudiniChain(pluginId, tokenId)?.hasSelfPrivate` answers without a quote and without a request. Of the 34 chains in the table, one (`rsk`, RBTC) is false; the rest are true. + +This is Houdini's dominant flow, around 60% of their traffic, which is why it gets a table lookup instead of a learned failure. Cross-asset private capability is the opposite case and stays quote-reactive: it fluctuates per pair, so it is learned from a real attempt and never cached beyond the session, per [Availability fallbacks](#availability-fallbacks). + +### Provider availability versus exchange settings + +The send-scene stealth and swap-send path **ignores** the global exchange-settings enable flag for HoudiniSwap. The Exchange scene keeps honoring it. That setting governs which providers the swap aggregator is allowed to use, so it is the user's answer about swapping, not about sending: a private send is a send feature that happens to be powered by Houdini, and switching off a swap provider should not silently remove it. + +Mechanically the core skips any plugin whose `swapSettings[pluginId].enabled` is false, so the scene opts out of that check for one request through `EdgeSwapRequestOptions.forceEnabled`, set by `makeStealthSwapRequestOptions` only when the caller passes `ignoreProviderSetting`. An explicit `disabled` entry still wins, so the same helper's provider restriction cannot be defeated by it. + +### Transaction identity + +Three send shapes reach the transaction list, and each carries its own title: + +| Flow | Title | Recipient | +|---|---|---| +| Cross-asset send, stealth off | Swap & Send | shown | +| Same-asset send, stealth on | Stealth Send | hidden | +| Cross-asset send, stealth on | Stealth Swap & Send | hidden | + +The flow is named on the saved action, not inferred in the GUI: `EdgeTxActionSwap` carries an optional `swapType` (`swapSend`, `stealthSend`, `stealthSwapSend`), and `getTxActionDisplayInfo` maps it to the title through `SWAP_SEND_LABEL_MAP`. Only the send scene knows which shape ran, so it stamps the field with `saveTxAction` right after `approve()` resolves; a failure there costs the transaction its title and nothing else, so it is logged rather than surfaced over a completed send. + +Hiding the recipient is a display rule, not a storage rule. `swapData` keeps `orderId` and `payoutAddress` intact so support can trace a stuck order. What changes is what renders: the broadcast path skips the `payeeName` write into `metadata.name` for a stealth send, and `SwapDetailsCard` takes `hidePayoutAddress` and substitutes a placeholder in its details text. The transaction list's own fallback needs no change, because a swap-send's spend target is the provider's deposit address, never the recipient's. + +That last fact is worth naming on screen rather than leaving implied. The details scene's spend-target row is titled "Recipient Addresses", which on a send-shaped swap names the wrong party: the row holds the provider's deposit address, and the pasted recipient never reaches `spendTargets` at all. On a private send it reads as precisely the disclosure the flow exists to prevent, which is how it was reported. The row is therefore titled from the action: a `swapType` on the saved action means the title is "Exchange Deposit Address", and every other transaction keeps the original wording. The row stays, because the deposit address is the one address that makes a stuck order traceable from the app. Ordinary Exchange-scene swaps carry the same mislabel and are deliberately left alone here, since they are not this branch's flows. + +The privacy rules bind on **every** row a flow produced, not just the one the send scene holds. A token send pays its fee in the chain's own coin, so `makeSwapPluginQuote` files a second action under `tokenId: null` built from the plugin's own copy of the saved action, which has no `swapType` in it. Every rule keyed on `swapType` then reads false on that row, and the fee row renders the payout address the token row hides. `stampSwapSendAction` stamps it too, under the same condition the plugin writes it (`hasParentFeeRow`: a token id and a parent network fee), so no parent-currency entry is invented for a mainnet send that has none. The row is the fee and not the send, so it keeps the network-fee title while still obeying the name-suppression rule: `getTxActionDisplayInfo` applies `SWAP_SEND_LABEL_MAP` only when the asset action is not a `*NetworkFee`, and `forceSavedName` stays bound to the private flavors regardless of row. + +That stamp is best effort, like the one on the send itself, and a privacy rule may not rest on a write the flow declines to fail on. So the suppression fails **closed** independently of it: the details scene hides the payout address on any network-fee row, stamped or not. Nothing is lost by that, because the fee row is not the payment and the row it accompanies carries the identical order. A stamp that never lands therefore costs the fee row its title, never the recipient, and the rule also holds for the fee rows of transactions that predate the stamp. + +The exchange order details themselves stay **visible** for a stealth transaction: order id, provider, and both sides' assets and amounts. Only the payout address is hidden. The support-traceability argument for keeping the data cuts no ice if the person reading the screen cannot see the order id, so the two rules are separate. Getting there required a fix: `SwapDetailsCard` resolved the payout denomination through the destination wallet and returned `null` without one, and a swap-send's `payoutWalletId` names a synthetic wallet that is not in `currencyWallets`. Every swap-send therefore rendered no card at all. The payout asset's currency config now comes off the saved action's `toAsset.pluginId`, which exists for exactly the case that has no wallet. + +### Multi-recipient gating + +Gated in both directions. Stealth on or a mismatched recipient hides "Add Another Address"; with multiple recipients present the stealth toggle is disabled, the card expands with an explanation, and the recipient-asset selector locks. Multi-recipient sends also gained a Total Amount row, which the task had left open. + +### Stealth Swap + +`SwapCreateScene` gets the same treatment at a smaller scale: a toggle whose state feeds `makeStealthSwapRequestOptions` into the quote request, with the restriction surviving re-quotes on `SwapConfirmationScene`. `PoweredByCard.onPress` became optional so the provider renders as fixed (no chevron, no "tap to change provider"). + +### Saying that a swap is running + +A send routed through Houdini looks like a send and behaves like a swap: the wallet pays the provider's deposit address, and the recipient is paid later by a second transaction the provider broadcasts. The table below lists what tells the user so, and each row answers a different question. + +| Surface | Where | Fires when | Persistence | +|---|---|---|---| +| Terms modal | `SwapConfirmationScene`, through `swapVerifyTerms` | the dedicated swap scene confirms a quote Houdini won | the provider's own `agreedToTerms` user setting | +| Swap-send modal | `SendScene2`, through `showSwapSendWarningModal` | the send scene first becomes a swap | `swapSendWarning.json` in the account disklet | +| Warning card | `SendScene2`, in the warning cluster | `swapSendActive`, for as long as it holds | none, it is scene state | + +The terms modal is the pre-existing centralized-provider acknowledgement, keyed by pluginId in `SwapVerifyTermsModal`'s `pluginData` table. Houdini's entry gives it the same three links every other centralized provider gets. Declining calls `changeEnabled(false)` on the provider, and an explicit disabled entry outranks the send scene's `forceEnabled`, so declining the terms turns Stealth Send off too. That is the intended reading of a declined provider. + +The swap-send modal is the send scene's own, because the send scene never reaches `SwapConfirmationScene` and so never runs `swapVerifyTerms`. It follows the send scam warning beside it: a disklet key, `runOnce` against a double-fire within one app run, and a `ConfirmContinueModal`. The provider names itself off `account.swapConfig[STEALTH_SWAP_PLUGIN_ID].swapInfo.displayName`, so the copy survives a provider change. + +[`src/actions/SwapSendWarningActions.tsx`](https://github.com/EdgeApp/edge-react-gui/blob/0d1074d79f46b50ab6a8c494a66dc476fc98074b/src/actions/SwapSendWarningActions.tsx) +```ts +export const showSwapSendWarningModal = async ( + disklet: Disklet, + providerName: string +): Promise => { +``` + +The card is the recurring half. A modal shown once cannot warn the user on their fortieth stealth send, and the wait is a property of every one of them, so `renderSwapSendWarning` sits with the fixed-to fallback and Nym cards and reads off `swapSendActive` alone. Private routing gets its own copy, since the sentence a user needs is about a private swap when Stealth is on. + +### Shared price impact + +The prototype recreated the price-delta UI. It is instead extracted from the swap confirmation scene into `src/components/themed/PriceImpactText.tsx` and reused by both: + +[`src/components/themed/PriceImpactText.tsx`](https://github.com/EdgeApp/edge-react-gui/blob/0d1074d79f46b50ab6a8c494a66dc476fc98074b/src/components/themed/PriceImpactText.tsx) +```ts +export const PRICE_IMPACT_WARNING_THRESHOLD = 0.05 +export function calculateQuotePriceImpact(…) +export const PriceImpactText: React.FC = props => { +``` + +## 8. The send scene UX, end to end + +Every branch a send-to-address user can hit, from address entry to an armed slider. Three rules organize it: every quote is Houdini-only (no other provider is ever consulted), the UI reflects what the provider actually offers (a capability the pair lacks turns its control off, with a toast saying why), and a degraded state is always recoverable where a degradation exists (the fixed-to fallback re-quotes the send side, the same-asset stealth fallback is the plain send, and pre-emptive refusals explain themselves on tap). + +Nodes tagged `[API]` are the only ones that reach the network. Everything else is decided from local state or the chain table, which is the point: the provider rate-limits tight traffic, so a branch that can be settled without asking is settled without asking. + +```mermaid +flowchart TD + A[Address entered by\npaste, type, or scan] --> B{Source wallet\nparses it?} + B -- yes --> C[Same-chain send,\nunchanged behavior] + B -- no --> D{Matches a served\ndestination chain?\nHOUDINI_CHAINS, no request} + D -- none --> E[Invalid address toast] + D -- exactly one --> F[Chain adopted as\nRecipient receives] + D -- several --> G[Network picker modal] + G -- picks --> F + G -- cancels --> H[Entry cancelled] + F --> I{URI carries\nan amount?} + I -- no --> J[User enters amount,\nsend side guaranteed] + I -- yes --> K[Receive side guaranteed,\nfixed to] + K --> L{Houdini offers a\nreceive-priced route?\n[API] GET /tokens, GET /quotes} + L -- yes --> M[Houdini quote arms,\nreceive amount locked\n[API] POST /exchanges] + L -- no --> N[Falls back to fixed from:\ntoast, warning card, send\namount seeded from rates] + N --> O[Card clears when the\nuser edits an amount] + O --> J + J --> T{Clears the floor\nfor this route?\n25 USD private, 10 standard\nlocal, no request} + T -- no --> T2[Under-minimum error card,\nno request sent] + T2 -. user raises amount .-> J + T -- yes --> P{Cross-asset\ndestination?} + P -- yes --> P3{Stealth on?} + P3 -- yes --> U[Houdini-only forward quote,\nprivacy required\n[API] GET /quotes] + P3 -- no --> U2[Houdini-only forward quote,\nstandard routes allowed\n[API] GET /quotes] + U -- route exists --> W[Quote arms:\nSlide to Confirm\n[API] POST /exchanges] + U2 -- route exists --> W + U -- no route --> Z[Error card:\nprovider's own message] + U2 -- no route --> Z + U -- rate limited --> RL[Backoff behind retryAfter,\nthen rate-limit error.\nNo routeCaps entry written] + P -- no --> P2{Stealth on?} + P2 -- no --> V[Plain single-asset send] + P2 -- yes --> Q0{hasSelfPrivate\nfor this asset?\ntable lookup, no request} + Q0 -- no --> S2[Toggle refuses to arm,\ntoast names the asset] + Q0 -- yes --> Q{Private route\nfor this pair?\n[API] GET /quotes} + Q -- yes --> R[Stealth quote arms:\nSlide to send stealthily\n[API] POST /exchanges] + Q -- no --> S[Stealth turns itself off:\ntoast, info line under the\ntoggle, pair remembered] + S --> V + S -. later toggle taps .-> X[Refuses to arm,\npre-emptive toast] + N -. later Recipient gets taps .-> Y[Editor refuses to open,\npre-emptive toast] + J -. toggle flipped either way .-> RQ[Held quote invalidated,\nre-quote with the new\nprivacy and floor] + RQ --> T +``` + +Which requests each action produces: + +| User action | Requests | +|---|---| +| Address entered or chain picked | none (chain table) | +| Amount committed, under the floor | none (pre-empted) | +| Amount committed, clears the floor | `GET /tokens` per asset (memoized per chain), then `GET /quotes` | +| Stealth toggled either way | the same pair, re-requested with the new privacy and floor | +| Slider confirmed | `POST /exchanges`, then the normal send broadcast | +| Any of the above, rate limited | the same call retried behind `retryAfter`, up to three times | +| Order status after broadcast | none in-app; the details scene links out to Houdini's order page | + +None of the branches above tell the user that the send is a swap, which is the one thing the scene's own layout hides: the amounts, the address and the slider all read like a send. The modal and the card in [Saying that a swap is running](#saying-that-a-swap-is-running) sit across this flow rather than inside it. The modal fires once per account, at whichever node first sets `swapSendActive` (adopting a cross-asset recipient at `F`, or arming Stealth at `P2`), and the card holds from that node until the flow leaves swap mode. Neither issues a request, and neither gates the slider. + +One error slot is shared by two owners, and every bug in this area came from ignoring that. The plain-send `makeSpend` effect owns its failures, the quote effect owns the swap's, and expiry belongs to neither: it is a property of the REQUEST, so it survives whichever mode the scene is in and is retracted only by replacing the address. Each owner clears through a helper that refuses to touch what it does not own, which is what keeps an insufficient-funds message from vanishing under a live quote, keeps a minimum-amount message from sitting over a plain same-asset send, and keeps an expired request from leaving a disabled slider with nothing on screen explaining it. + +The same discipline governs what may still be approved. A quote is retired the instant anything it was priced against moves: the amount, the address, the source wallet, the privacy toggle, or its own expiry timer. Retiring means dropping the quote, not merely asking for a new one, because the slider gates on a quote being present and a re-quote takes a render to start. A source-wallet or asset switch resets the whole spend rather than the first recipient alone, and retires the toggle, the learned route capabilities, and any error on screen, all of which describe the wallet the user just left. Retirement is read through a ref at the one place that resumes after an await, the PIN spending-limit check on the way to approving, because a render's closed-over quote cannot see a retirement that happened while it was suspended. + +Learned capabilities are per pair and per session (`routeCaps` in `SendScene2`), because availability is a live provider property: the same pair can regain its private route an hour later, so nothing is persisted. Route limits above the floor (a token's own server-side minimum, or a maximum) never enter this flow; they keep the plain error card carrying the provider's figure, because the route exists and the amount is the problem. + +## 9. Testing + +### Unit tests + +Nine files across three repos hold 121 tests, all passing. + +In the gui, 76 across five files: + +1. `src/__tests__/util/paymentUri.test.ts` (11): bare address passthrough, whitespace, [BIP-21](#bip-21) with and without a query, [cashaddr](#cashaddr) prefix retention, [EIP-681](#eip-681) `pay-` prefix and `@chainId` suffix stripping, Monero `tx_amount`, non-decimal amount rejection, `value=` wei ignored, leading-slash stripping, malformed percent-encoding. +2. `src/__tests__/util/houdiniChains.test.ts` (31): the 16 address-detection cases (single-chain detection, all-[EVM](#evm) fan-out for a bare `0x`, scheme resolution including a scheme differing from the pluginId, source chain never offered from that chain's own coin but offered from a token on it, unsupported chains skipped, Solana and Dogecoin and legacy Bitcoin formats, non-address text rejected, unknown scheme falling back to format matching, a mislabeled scheme not trusted, the Cardano catch-all regression), plus lookup and table invariants: `getHoudiniChain` resolving a served chain, refusing an unserved one, refusing the four chains with no mainnet native, and refusing a token id on a served chain; no duplicate plugin ids or provider chain names; every entry carrying a boolean `hasSelfPrivate`; every address regex rejecting the empty string and free text, which is the shape the Cardano catch-all had; the [memo](#memo)-required set; and the floor constants ordered [dex](#dex) < standard < private, shaped as biggystring-comparable strings, and equal to the values the provider published. +3. `src/__tests__/util/stealthSwap.test.ts` (11): every other provider disabled, a preferred provider cleared so it cannot fight the restriction, the exchange setting left alone by default, Houdini force-enabled only when the caller asks to ignore that setting, a caller's own `forceEnabled` and `disabled` entries preserved, unrelated options passed through, and an account holding Houdini alone; plus the parent-fee-row predicate answering yes for a token send with a parent fee and no for both a mainnet send and a token send without one, which is what keeps the fee-row stamp from inventing a parent-currency entry. +4. `src/__tests__/util/swapErrorDisplay.test.ts` (17): a missing error, minimums and maximums rendered in the units of whichever side was fixed, the limit-free fallback when the bound is zero, both assets named on an unroutable pair including the swap-to-address case where the payout code has to be supplied, insufficient funds from both the typed error and the stringified shape some plugins throw, pending transactions, a geographic restriction, an unrecognized error surfacing the provider's own text, a rate limit never rewritten into a pair error, a thrown non-error stringified, and the original error preserved for the caller to log. + +5. `src/__tests__/actions/CategoriesActions.test.ts` (6): a private send titled by its flow rather than its asset, that title outranking a stored metadata name shaped like a recipient address, a plain swap-send leaving a stored name alone, and the parent network-fee row keeping both its own title and its own category while still refusing a stored recipient-style name. The fee-row title case fails on the pre-fix code, which is what makes it worth having. + +In edge-core-js, 14: `test/core/synthetic-wallet.test.ts` (4) for the synthetic wallet's shape and bridge survival, the plugin-selection truth table in `test/core/swap.test.ts` (8), which pins that a caller can reach a provider the user switched off and can never reach one it disabled itself in the same call, and `test/core/swap-quote-close.test.ts` (2) for the synthetic wallet's reference-counted release, including a double-close that must not free it twice. + +In edge-exchange-plugins, 31 in `test/houdini.test.ts`: 4 acceptance tests replaying recorded fixtures (quote retrieval both directions, order creation, destination-tag threading), and 27 offline behaviors driven from local responses. The offline half exists because a recorded fixture replays one canned answer per URL, which cannot express a specific SEQUENCE of statuses or a route mix the live API will not produce on demand. It covers native-token resolution for both spellings of "no contract address", the private-only filter declining when a pair offers transparent routes alone, a transparent route taken when privacy was not requested, private preferred over a better-priced standard route, dex routes never taken, same-asset allowed, a chain with no native declined before any quote goes out, the fixed-versus-floating label on forward and reverse quotes, a rate-limited call retried behind the window the API reports, the retry budget running out with a message that names the rate limit rather than the route, an unserved chain asked about once and then remembered, a lookup the provider failed to answer deliberately left uncached and reported as a provider failure rather than an unsupported pair, both legs of a same-asset quote sharing one lookup, a reported retry window longer than our own cap honored while the cap still bounds growth when none is reported, a backoff that would outlive its quote failing fast instead, the Unix-seconds `validUntil` the API actually sends parsed correctly, a max quote creating one exchange rather than two, a `VALIDATION_ERROR` surfacing its field message instead of the generic "Validation Failed", the max probe setting `skipChecks` and clamping an above-limit balance rather than throwing, the trust boundary refusing an inflated deposit amount on both a forward and a reverse quote, minimums rounding up and maximums down, a numeric and a blank deposit tag both surviving the cleaner, and the 409 deposit-address-in-use fallthrough to the next route. + +Full-repo verification: `verify-repo.sh` PASSED on all three repos, covering install, prepare, lint, and the full jest and mocha suites. + +### Maestro suite + +[`maestro](#maestro)/14-stealth/` holds a flow per user-visible branch, built from reusable +subflows in `maestro/common/stealth-*.yaml`. The point of the split is that a +later session can drive one specific state (a live private quote on some other +pair, say) in a handful of `runFlow` steps rather than walking the simulator by +hand. `maestro/14-stealth/README.md` documents how to run the suite whole or one +flow at a time, what each env var needs from the signed-in account, and the two +gotchas that cost the most time to find: the confirm slider is a pan gesture +that ignores coordinate swipes entirely, and notification cards float over the +bottom of every scene including the slider. + +The flows drive rather than assert. Each asserts only enough to gate its next +step, because behavioral claims belong in the unit tests and in this document. +The two flows that move funds carry the `stealth-spend` tag and nothing else, so +a run of the suite by its ordinary tag can never spend. + +On-device, iOS simulator, account `edge-funds`, against the live provider: + +5. **Executed cross-chain send.** ETH wallet, Litecoin address pasted, destination auto-adopted, 0.25 LTC guaranteed on the receive side, live quote 1 ETH = 39.59988278 LTC, executed to the success scene. Broadcast `0xa87fd77e1a64310d565e462cbc91e5f3e0e748ff1bbb62857455aef37e4044e7`, 0.00631315 ETH ($11.93), category `Exchange:To LTC`. Source wallet moved 0.0175191 to 0.0111629 ETH. +6. **Executed cross-chain send from a scanned URI**, plain and with Stealth on: `176c833c6f0ef09ea9c2ba5eb6a39e079a13262aadd362d87d195350114a54dc` and `53f03d92da63c9b20dc29a0c296b4043cefb71e334066bf78529d6cec2b11cb6`. +7. **Executed plain cross-asset send**, on the phase 2 pre-followup code state where a stealth-off send fanned out to every provider (ChangeNOW won): `0xfd51a5c5d4ba44267d257506c977a8e88952f56987e653d2b812502fa739cf8b`. It covers the send-to-address broadcast path, not the provider restriction. +8. **A 10-case entry-path and chain matrix**: typed Ethereum address into a Bitcoin wallet (picker to Ethereum, and separately to Polygon); typed Solana address into an Ethereum wallet; scanned `bitcoin:` URI from an Ethereum wallet; scanned `ethereum:` URI from a Bitcoin wallet; pasted Bitcoin address into a Litecoin wallet; typed Ethereum address from a USDC (Algorand) and a USDT (Tron) token wallet; deep link confirming unchanged same-chain behavior. Chains exercised: BTC, LTC, ETH, POL, SOL. +9. **Regression:** plain same-asset sends, multi-recipient [UTXO](#utxo) sends, and the multi-recipient gating, all verified on device. +10. **Stealth auto-disable mechanics, live**, on the phase 5 code state: ETH wallet, pasted LTC address, stealth armed, amount entered. The quote failed on the missing private route, the toggle turned itself off with the toast, the info line pinned, and the fallback re-quote of that code state executed through ChangeNOW (`0x2f281f4ea143a775355da7876290aaa2a9a7d44160a68eeac89b1ebe92ac284c`). It covers the toast, info line and `routeCaps` mechanics, which are the same lines the same-asset branch runs today; the cross-asset fan-out it re-quoted through belongs to that code state alone. +11. **Fixed-to fallback, live.** PIVX wallet ($89), stealth on, own PIVX address pasted, "Recipient gets" set to 1000 PIVX: the reverse quote failed (no receive-priced same-asset route), the toast fired, the send side became guaranteed at the rate-seeded 1000 PIVX, and the warning card appeared. Tapping "Recipient gets" afterwards refused with the pre-emptive toast; editing "You send" cleared the card. The post-fallback forward quote then surfaced the provider's PIVX deposit-address defect (retrospective item 4), which is unrelated to the fallback mechanism. +12. **Pre-emptive stealth refusal, live.** Re-tapping the stealth toggle on the known-unavailable ETH to LTC pair refused to arm and toasted, without issuing another quote. +13. **Houdini exclusivity, live (phase 6).** ETH wallet, pasted LTC address, 0.006 ETH, Stealth OFF: the quote went Houdini-only and surfaced `SwapCurrencyError: HoudiniSwap does not support ethereum:null to litecoin:null` in the error card, where the phase 5 code had produced an armed ChangeNOW quote on the same pair and amount. Repeated with Stealth ON: the toggle stayed on and the same error card appeared, with no auto-disable toast and no re-quote. Both amount entries went through the new flip-input modals (You send in ETH/USD, Recipient gets in LTC/USD via a borrowed Litecoin wallet). Amount errors were also confirmed unchanged: 0.002 ETH (below the provider minimum) produced the plain error card with the toggle still armed. The same-asset auto-disable degrade was NOT drivable this phase: the provider's route availability flapped during testing (private routes present on one probe, absent minutes later) and no funded same-asset pair lacked a private route at a fundable amount; the toast, `routeCaps`, and degrade mechanics are the same lines phase 5 drove to execution. + +14. **Amount-row title states, live (phase 13).** XLM wallet, "My Sonic" adopted as the destination, 158 XLM entered on the send side: the send row read `You send (Guaranteed)` in green with a bare amount and the receive row `Recipient gets (Estimated)` in orange with the `~` prefix, on a live quote of 1 XLM = 7.67561925 S. Editing "Recipient gets" to 1200 S swapped both, `Recipient gets (Guaranteed)` green and bare against `You send (Estimated)` orange with the tilde, and the reverse quote resolved at 1 XLM = 7.56043868 S with the slider armed. Nothing was sent. + +15. **Executed a private Stealth Send priced in fiat (phase 16).** Litecoin wallet, own Sonic wallet adopted through the Myself picker, `28` typed into the "You send" flip input with USD as its open field: the row committed `0.61556 LTC`, the private quote came back at `1 LTC = 1948.10181555 S` (1.63%) for `~1199.17355358 S ($27.54)` with a `0.00052969 LTC` network fee, and the slide reached the success scene. The transaction's details read `Amount in USD $28.04` against `Ł 0.61556 (+0.000529 fee)`, category `Exchange:To S`, deposit address `MG4xVKRzGkYcyHuFBdxNzvBwg2gG1GAdWy`. Both rows were also opened on a second pair (XLM to Sonic) purely to see which field they present: each opened on USD with the crypto amount above it, and `30` on the send side resolved to `185.176 XLM ($30.00)`. + +## 10. Phase history + +### Phase 1: prototype and the bridge verdict + +- **Sketched:** prove a swap-to-address flow end to end. +- **Shipped:** prototype PRs [#6054](https://github.com/EdgeApp/edge-react-gui/pull/6054) and [#6031](https://github.com/EdgeApp/edge-react-gui/pull/6031), with a parallel scene, a four-chain hardcode, and a GUI-built fake destination wallet. +- **Diverged:** the fake wallet did not survive the [yaob](#yaob) bridge, which moved the synthetic wallet into the core and set the shape of the whole design. + +### Phase 2: production implementation + +- **Sketched:** replace every prototype hack with real wiring. +- **Shipped:** the core `toAddressInfo` seam, the HoudiniSwap plugin, and `SendScene2` integrated in place with a 38-chain metadata table, linked amounts, expiry re-quoting, destination tags, and both Stealth toggles. +- **Diverged:** the prototype's reroute of the wallet Send button and its `HoudiniSendScene` re-skin were deleted rather than adapted, and the price-delta UI was extracted for reuse instead of recreated. The provider restriction also changed inside the phase. The first cut restricted stealth sends alone and fanned plain cross-asset sends out to every provider; send-to-any became Houdini-exclusive instead. That fix was autosquashed into the feature commit, and neither the PR body nor this document recorded it, which set up the phase 5 regression. + +### Phase 3: scanned payment URIs + +- **Sketched:** a scanned QR carries a URI, not a bare address, so the cross-chain branch has to read one. +- **Shipped:** `paymentUri.ts` plus URI handling in the cross-chain branch, driven to execution on funded wallets in both plain and stealth modes. +- **Diverged:** the first cut routed a URI amount to the guaranteed receive side unconditionally. Provider probing showed no receive-priced route exists for same-asset pairs at any amount, which would have made every same-asset payment URI unquotable, so the routing was gated on the destination being cross-asset. + +### Phase 4: cross-chain address entry + +- **Sketched:** an Ethereum address could not be pasted or typed when sending from a Bitcoin wallet, and the full URL failed too. Cover the top chains, USDC and USDT, and every entry path. +- **Shipped:** `detectHoudiniChains` and the `onUnparsedAddress` hook, the disambiguation modal, the two regex corrections, and the 10-case matrix in [Section 9](#9-testing). +- **Diverged:** the bug was assumed to be a parsing gap and turned out to be an ordering gap. The cross-chain override worked correctly but only engaged once "Recipient receives" had been changed, which nobody does before entering an address. + +### Phase 5: route availability in the UI + +- **Sketched:** the UI reflects what the provider actually offers. The stealth toggle turns itself off with a toast on a pair with no private route, a fixed receive amount falls back to a guaranteed send amount (toast plus a warning card that clears on edit) when no receive-priced route exists, and disabled controls explain themselves on tap. +- **Shipped:** the `routeCaps` mechanism, both fallbacks, the pre-emptive refusals, and the warning card, driven live on ETH to LTC (stealth auto-disable through to an executed swap) and PIVX to PIVX (fixed-to fallback on a funded wallet). +- **Diverged** twice, and the second divergence was a regression. The unconditional Houdini restriction had been intended behavior since phase 2, but the PR body still described the original fan-out and the autosquash had made the restriction look original, so it read as drift. This phase made the restriction conditional on the toggle and re-routed the stealth fallback through a provider fan-out. The executed swap in [Section 9](#9-testing) item 10 ran on that code state. Phase 6 reverted it. + +### Phase 6: Houdini exclusivity restored + +- **Sketched:** all send and swap functionality is Houdini-exclusive, the PR body is out of date, and the documentation has to make the confusion unrepeatable. The swap-send amount modals become flip inputs. +- **Shipped:** the unconditional `makeStealthSwapRequestOptions` restored at the quote call, the stealth auto-disable narrowed to same-asset pairs (cross-asset, the toggle does not change a Houdini-only request, so a missing route keeps the error card), the `stealth` quote-effect dependency dropped again, every fan-out claim purged from this document and the PR body, and both amount rows moved from plain text modals to `FlipInputModal2` ([Linked amounts](#linked-amounts)). +- **Diverged:** nothing. The retrospective gained the doc-drift item this regression earned. + +### Phase 7: routable Myself picker and transaction identity + +- **Sketched:** two features and a standing rule. The rule is that supported-destination logic is route-derived on every surface, with the branch audited for hardcoded assumptions. Feature A: the "Myself" picker lists every routable destination asset, same-asset pinned to the top through an opt-in grouping prop, with private availability handled by the toggle rather than by filtering the list. Feature B: swap-sends, stealth sends and stealth swap-sends are distinguishable in the transaction list and details, through a first-class action field rather than a metadata convention, with the recipient suppressed in the UI but preserved in storage. +- **Shipped:** all of it, plus the [Destination assets are route-derived](#destination-assets-are-route-derived) and [Transaction identity](#transaction-identity) sections and three decisions recording the rationale in the same turn the code landed. +- **Diverged:** `swapType` extends the existing swap action instead of becoming its own `actionType`, because these flows are swaps to every existing consumer and a new action type would drop them out of all of it. + +### Phase 8: PIN gate and quote-state hardening + +- **Sketched:** nothing new. A finalize-gate re-confirmation found the PR sitting in draft, which had suppressed the reviewer bots entirely; this repo runs no typecheck on pull requests, so the draft bought nothing and hid everything. Marking it ready produced seven findings across two review rounds. +- **Shipped:** the stale-quote clear on every amount commit plus a generation guard on the quote effect, the fixed-to fallback no longer stranding the scene when rates are missing, the [PIN spending limit](#pin-spending-limit) gating swap-send at all three points it was bypassed, a null check on an empty quote list, a full swap-send reset when the address is cleared, and the stealth degrade wired into the confirmation scene's re-quote. +- **Diverged:** the deferred-work table said PIN limits on stealth sends were "not doing". That decision is reversed here, and the reasoning behind it is recorded as a mistake worth keeping. + +### Phase 9: real failures in the error UI + +- **Sketched:** swap and send failures must show the real cause (provider message, limit floor, network error) instead of a catch-all alert. +- **Shipped:** the swap flow's error mapping moved out of `SwapProcessingScene` into `src/util/swapErrorDisplay.ts` and the send scene now uses it, so a failed send-to-address quote renders the limit that was crossed, the pair that cannot route, or the provider's own message rather than "Unexpected Error". The review rounds that followed produced six more fixes. Three are behavioral: the PIN spending-limit flag became a derived value instead of effect-written state (it could lag a render behind a live quote and leave the slider armed), a source-wallet change now clears the Stealth toggle and learned route capabilities, and a private send's title now outranks any stored metadata name. Three are corrections: `upgradeSwapData` receives the payout wallet rather than the source wallet, so a payout currency code resolves against its own chain; `trackSwapError` reports the destination wallet type instead of repeating the source; and the Hedera `addressValidation` pattern escapes its dots, which previously made them wildcards that accepted `0X0Y12345`. +- **Diverged:** one finding was rejected rather than fixed, that the limit ignores the network fee, because `origin/develop` computes it the same way for every plain send and changing only this path would make the two disagree. + +### Phase 10: the provider feedback round + +- **Sketched:** a feedback round from the Houdini team plus two internal reviewers, carrying a standing rule (every supported-destination decision is route-derived) and two hard constraints (no availability probing or per-pair caching of any kind, and graceful rate-limit handling). +- **Shipped:** the Myself picker's same-asset capability reads Houdini's own `hasSelfPrivate` flag instead of assuming it, and the Stealth toggle refuses to arm for an asset that lacks it; the toggle re-quotes on every flip, which it did not before, because the request now genuinely differs by privacy and floor; the three minimums were confirmed against the live API and became named constants enforced before any request leaves the app; the plugin honors `privacy: 'required'`, so a plain Swap & Send can use the standard routes that are the only ones on offer between 10 and 25 USD, while a Stealth send declines rather than silently taking one; every call backs off behind the API's own `retryAfter` and reports a rate limit as a rate limit; the exchange order details render on a stealth transaction's detail scene, which they never did; the send scene ignores the global exchange-provider setting while the Exchange scene keeps honoring it; and the Houdini amount rows show their fiat value inline in parentheses through the shared `FiatText` component. +- **Diverged:** nothing was dropped, but the standing-rule audit found more than the asks did. Four chains were offered as destinations that Houdini serves no native for at all, and six more could never resolve a token id because the API spells "no contract address" as an empty string on those chains while the plugin tested only for null. Both are fixed; see [Retrospective item 6](#where-this-document-was-wrong-or-silent). + +The review round on that work produced nine findings across five passes, and two were privacy holes of the same shape as the one the round set out to close. The Exchange scene's Stealth Swap restricted the provider but never demanded a private route, so a wallet-to-wallet stealth swap could be served a transparent standard route under a private label; it now sets `privacy: 'required'` on the initial quote and on the expiry re-quote. A held quote survived a switch to another wallet on the same asset, leaving an order created against the previous wallet's refund address armed and approvable. The rest were state hygiene: the floor guard raced an in-flight quote until every run of the effect began retiring its predecessor, the two effects were clearing each other's errors, a token send to its own chain was titled "Stealth Send" when it pays out native and so crosses assets, the fixed-to fallback read a stale rate snapshot, and forward quotes claimed to be fixed. One finding was rejected in part: adopting `checkInvalidTokenIds` wholesale would have rejected same-asset swaps outright, which is the feature. + +### Phase 11: the follow-up sweep + +- **Sketched:** work the previous round's own follow-up list rather than carrying it, with three of its items answered directly. Funding is not a precondition to wait on: a wallet a test needs is funded by swapping into it, and only the spread and fees count against the budget, since the principal stays in the account. The dependency-publish item was wrong to write down at all, because this feature is pinned to our own core and exchange-plugin changes. And a product question left open for two phases was to be decided, not re-deferred. +- **Shipped:** the six chains whose native coin the API reports with an empty contract address were driven for real, on a wallet funded by swapping into it, ending in an executed private Stealth Send between two of them; the spending-limit question decided against the pre-Houdini arithmetic; unit coverage roughly tripled, with the plugin's route filter, native-token matching and rate-limit backoff moved onto deterministic local responses; a composable [maestro](#maestro) suite covering every user-visible branch; and the token lookup taught to remember a miss, so a chain the provider serves no native for declines on one call per session instead of one per quote. +- **Diverged** twice. The funding swap chose its source badly first: the provider handed back a PIVX deposit address the PIVX plugin cannot spend to, which cost an attempt and is recorded as a provider defect rather than ours. And the audit's own finding was mis-fixed. Four chains the provider serves no native for were hardcoded to `null`, on the reasoning that a mapped name surfaced an error where a `null` would decline; both paths raise the same `SwapCurrencyError`, so the reasoning was wrong and the fix was a snapshot of a live fact. A follow-up round reverted it and cached the token-lookup miss instead, which costs the same single call without asserting anything the provider might change. + +### Phase 12: the suite green, and a reviewer that came back + +- **Sketched:** replace the hardcoded chain nulls with the dynamic decline the previous round should have written, correct the overstated claim recorded alongside that fix, and get every flow in `maestro/14-stealth/` passing end to end while swapping only for assets a flow actually needs. Two more items arrived mid-phase: the send scene's provider-floor card was hardcoded to dollars, and the reviewer bot had run out of quota, which the harness should handle without treating a silent reviewer as a clean one. +- **Shipped:** all ten flows pass, verified one at a time with the driver killed between, because a failing flow takes the driver down and every later flow then reports a connection error that reads as a suite-wide break. Getting there fixed seven defects in the suite rather than in the app: a search-field focus race that dropped the typed filter, rows never satisfying the default full-visibility requirement, a transaction row whose accessible label is the whole row joined, so a text match resolved to a container and tapped nothing, a PIN loop paying for six passes after the gate cleared, a picker missing the wait its shared subflows already had, a [memo](#memo)-chain default naming a display name the picker does not use, and two funded flows running the same direction so they could not run back to back. The floor message now converts through the rates the scene already holds and formats with the shared helper, verified on device by switching the account to EUR and reading back "Private routing needs at least EUR 21.80". +- **Diverged** once, and it was the harness. The reviewer-availability classifier written this phase read the check-run bucket, and Bugbot reports `skipping` on a HEAD it has in fact just reviewed. Trusting the bucket would have filed a real finding as "reviewer unavailable" and walked past it. The classifier now asks whether a review exists pinned to the head commit, which is the only proof of coverage, and the bucket merely raises the question. + +That correction mattered immediately: the reviewer's quota returned mid-phase and it filed seven findings across three pushes, six of which were real provider-interaction bugs (a failed token lookup indistinguishable from an unsupported pair, both legs of a same-asset quote spending two calls on one answer, a provider retry window truncated by our own cap, a retry that then outlived the quote it would resend, a `validUntil` the parse could not read, a max quote spending two of the one-per-minute exchange slots, and a validation failure reporting "Validation Failed" instead of its real reason). One was rejected with reasoning. Each plugin fix carries a test. + +A second review round followed on the gui, four more findings, all real: a quote surviving the toggle that repriced it, a quote surviving its own expiry while the terms modal deferred the navigation away, an asset change whose reset was overwritten by a stale spread of the pre-reset value, a superseded `makeSpend` landing after the scene had moved to quoting and clearing an error it did not own, and the expiry message being retracted as though the plain send owned it. They are one theme, and [Section 8](#8-the-send-scene-ux-end-to-end) now states it: one error slot has two owners plus a request-scoped case, and a quote is retired the instant anything it was priced against moves. + +### Phase 13: the state word moves into the row title + +- **Sketched:** the state word sat on its own line under the amount, so each of the two linked rows read as three stacked lines and the number was not the last thing on it. Put the word in the row's title instead, parenthesised, green for the guaranteed side and warning orange for the estimated one, on both rows. +- **Shipped:** `EdgeRow` grew a `titleState` node it renders after the title, and `EdgeText` grew `PositiveText` to sit beside the existing `WarningText`. Both colour components set colour only, so a span nested in a 0.75rem header keeps the header's size rather than jumping to the 1rem body size an `EdgeText` would have forced. The scene passes the word and drops the third line; the estimated side keeps its `~` prefix, which marks the approximation next to the number rather than away from it. +- **Diverged** once, and the linter found it. The first cut widened `EdgeRow.title` to a node so the scene could compose the whole header itself. That put raw text in a fragment outside any ``, which `react-native/no-raw-text` rejects, and it would have made every caller wanting a tinted word rebuild the shared header style. Moving the parentheses into `EdgeRow` fixed both, and phase 15 then moved them back out to the caller for a different reason. + +The suite needed two changes for the new titles, both of which say something about the old ones. The shared amount-row subflow matched the row by its exact title, which the state word now breaks, so it matches a prefix; and it now erases the flip input before typing, because the input opens pre-filled and `inputText` appends, which silently commits a concatenated amount on any re-edit. The payment-URI walk asserted a bare `Guaranteed` that no longer stands alone as its own element, and now asserts the whole title, which also binds the guarantee to the row that should hold it. + +### Phase 14: the row that named the wrong party + +- **Sketched:** a tester reported the recipient visible in a private send, with a screenshot of the transaction details scene and no further detail. +- **Shipped:** the row is titled from the action, "Exchange Deposit Address" whenever the saved action carries a `swapType`. The audit that went with it found a real leak the report had not: a token send's parent network-fee row carries the plugin's unstamped action, so `swapType` is absent there and its Exchange Details renders the payout address the token row hides. Both are covered in [Transaction identity](#transaction-identity). +- **Diverged** twice, both inside the fix. Stamping the fee row makes it a send-shaped swap to every consumer, and `getTxActionDisplayInfo` titles those from `SWAP_SEND_LABEL_MAP` before it looks at the asset action, so the first cut retitled the fee row "Private Send" and put what looked like a second private send in the list. The title map now applies only when the asset action is not a network fee, while name suppression stays bound to the flow. The second divergence came from review: the fee-row stamp is a second `saveTxAction` inside a `try` the send deliberately does not fail on, so a stamp that fails leaves the row unstamped and the send still reports success, which is a privacy rule failing OPEN on a best-effort write. Rather than harden the write, the rule moved off it. The payout address is now suppressed on any network-fee row whether or not it carries a `swapType`, which costs nothing (the fee row is not the payment) and holds for pre-stamp transactions too. + +The report was half right, and the half that was wrong is the interesting one. The screenshot pointed at the "Recipient Addresses" row, which holds `spendTargets[0].publicAddress`. On a send-shaped swap that is the provider's deposit address; the payee rides on `savedAction.payoutAddress` alone and was already suppressed everywhere it renders. The transaction settled it without argument: the row's address received 38,693 sats, the gross send side the scene showed as "Exchange 0.00038693", while the payee was owed the 37,580 net shown as "To 0.0003758 BTC". The address has two transactions and forwarded the full amount, which is what a single-use deposit address looks like. So the data was right and the label was wrong, and a label that says "recipient" over an address the user did not choose is not a cosmetic complaint on a flow whose whole promise is that no such address exists. + +### Phase 15: the credentials came back, and the label stopped needing a hack + +- **Sketched:** redo phase 14's testing against working provider credentials, plus anything redoing it turns up. +- **Shipped:** the first genuinely stamped transaction on the test host, made deliberately from a **token** source since that is the only shape that files a second action under `tokenId: null`: 30 USDT on Ethereum to the account's own Litecoin wallet, private route, through to the success scene. It confirms three things the previous phase could only argue: the stamp lands, the details title reads "Stealth Swap & Send", and the spend-target row reads "Exchange Deposit Address" over Houdini's real deposit address. The parentheses around the amount-row state words also moved to the caller, having taken the title's colour rather than the word's, so `Recipient gets (Estimated)` had rendered in three colours. `EdgeRow` had been writing the brackets itself around whatever node it was handed, which necessarily put them outside the caller's colour component; the row now contributes only the separating space. +- **Diverged:** phase 14 shipped its label fix with a hack-forced screenshot, on the reading that no transaction in the test account carried a `swapType` because the provider was unavailable. That reading was wrong. `HOUDINI_INIT` was present in `env.json` and set to `false`, so `corePlugins` never registered the plugin and `fetchSwapQuotes` queried zero of them, which surfaced as `reduce of empty array with no initial value` from `pickBestError`. A missing credential and a disabled plugin are the same symptom through that code path. What is still not covered on device is the parent fee row's suppression, whose unit tests and unconditional code path are the standing evidence. + +### Phase 16: the amount entry opens on fiat + +- **Sketched:** the amount entry defaults to fiat instead of crypto. +- **Shipped:** `forceField="fiat"` on both rows, and the maestro suite's defaults restated in fiat. +- **Diverged:** nothing. The one-word change in two places is the whole of it, and the interesting part is that the scene had been the app's only dissenter. The Exchange scene forces fiat on both of its inputs and the plain send seeds its remembered side to fiat, so opening on crypto was this branch's own invention rather than a house convention it had inherited. The reason to prefer fiat here is stronger than consistency: the provider's floors are quoted in USD, and the two sides of a cross-asset send have no crypto unit in common, so fiat is the only denomination in which both rows and both floors can be read against each other. The suite's stealth flows had used source-asset default amounts picked to straddle the 10 and 25 USD floors, with a README caveat warning that a price move would quietly push them onto the wrong side; the defaults are now 30 and 15 USD, which sit where they are meant to sit at any price. + +### Phase 17: the branch made reviewable + +- **Sketched:** the commit history reads as a straight-line progression rather than the path development took, and this document matches the current standards and the current code. +- **Shipped:** 63 gui commits rebuilt as 15, 7 exchange-plugin commits as 2, and the core's CHANGELOG moved out of all three of its commits into the last. Each commit is independently lint-clean, each string lands in the commit that uses it, and each file leaves the legacy lint exclusions in the commit that brings it onto the strict ruleset. The tree matches the pre-rewrite branch exactly apart from this document, the CHANGELOG, and three blank lines in the lint config. This document lost the commissioning narration from its phase entries, gained [Section 12](#12-glossary), and had every count and code block re-checked against the branch. +- **Also shipped:** a deep review pass over all three PRs plus the reviewer bots that ran on each push, whose confirmed findings are fixed here. Grouped by what they were: + + | Defect | Fix | + |---|---| + | Three published address patterns matched addresses of other chains: eCash spelled its prefix-less form as `[0-9A-Za-z]{42}`, exactly an `0x` [EVM](#evm) address, and Solana's floor of 32 base58 characters reached into the 33-34 band the Bitcoin-family legacy forms occupy | Both narrowed to the encodings those chains actually use, joining the Cardano and PIVX corrections | + | A scanned code's chain was read from its scheme alone, so `ethereum:…@137` paid Ethereum: every EVM network writes `ethereum:` and only the [EIP-681](#eip-681) chain id names the network | The parser reports the chain id, the detector reads it before the scheme, and a chain id nothing serves resolves to nothing rather than falling back | + | An EIP-681 token-transfer code (`ethereum:@1/transfer?address=`) put the token CONTRACT where the payout address is read | Function-call codes yield no address candidate, so they are refused rather than paying a contract | + | A scanned destination memo was dropped on both entry paths, so a tag-required exchange deposit was paid with nothing to credit it by | The parser reads `dt`/`memo`/`tag`/`message`, both paths carry it to the Destination Tag row, and only on chains that need one | + | The quote outlived terms it was priced against: a replaced recipient address, and an edited destination tag | Both retire it, through one `changeDestinationTag` writer for the tag | + | A slide arriving after the quote was retired mid-check left the slider latched on its spinner | That path hands the slider back | + | "Recipient receives" named the source token while the quote asked for `toTokenId: null`, so a token sender was told the recipient receives the token while the order paid out native | The row names the chain's native asset whenever no recipient asset is picked | + | Switching the source wallet cleared a still-valid same-asset recipient | The reset is scoped to what the new wallet cannot pay | + | In the core, a fresh `CurrencyConfig` was bridged into every swap-to-address quote and never closed | The account's own long-lived config is used | + | In the plugin, the token query sent Edge's checksummed contract while matching lowercased, and `floatToDecimalString` still returned exponential notation at or above 1e21 | The query sends the form it matches on, and the expansion is done by hand above that threshold | + +- **Diverged:** the counts were the divergence. The chain table had shrunk from 38 chains to 34 and from 6 memo-required chains to 5 when the four unserved chains were dropped, the test suite had grown past what the testing section claimed, and two code blocks (the activation predicate and `makeStealthSwapRequestOptions`) were quoting superseded code that the surrounding prose already described correctly. The rebase onto the current core master also carried the old branch's CHANGELOG wholesale, deleting two already-released sections; the tree comparison that checked the rewrite could not see it, because it compared against the pre-rewrite branch rather than against master. + +### Upstream, on Houdini's side + +Not our work, tracked so it is not rediscovered: + +- [ ] **Exact-out fixed-rate min-max bug.** Houdini acknowledged it and has developers on it. Re-verify exact-out min-max behavior after they ship. Do **not** build a workaround in this scope: the [fixed-to fallback](#availability-fallbacks) already degrades gracefully, and a workaround would have to be unpicked. +- [ ] **Private routes on fixed-rate quotes.** Until these exist, a privacy request priced by the receive side cannot be served, which is why the fallback re-prices from the send side. Worth re-checking whenever their routing changes. +- [ ] **Token destinations.** Blocked on token payout metadata through the swap plugin surface, not on us; `getHoudiniChain` returning undefined for a non-null `tokenId` is the single line that gates every surface. +- [ ] **Rate-limit headers, and which tier these credentials are on.** Free tier is ruled out behaviorally (six quote calls in 27 seconds drew no 429 against a 5/min free limit), but the API returns no `x-ratelimit-*` or `retry-after` header on success, so "pro" cannot be read off a response. At 500 quote requests a minute the app's per-user traffic is irrelevant; at 5 it is not. Worth both confirming the tier and asking them to expose the headers so a client can self-pace instead of guessing. +- [ ] **Deposit addresses that the source chain rejects.** A PIVX order returns a deposit address starting `EXMD…`, which Edge's PIVX plugin refuses with "unable to convert address to script pubkey", so the send fails at spend time with an opaque wallet error. Reproduced twice, on separate rounds. Until they fix it, the plugin could validate `order.depositAddress` against the source chain's own rules and throw a provider-named error instead; see the deferred-work table. + +### Deferred work + +| Item | Disposition | Reason | +|---|---|---| +| Token destinations | Deferred | Provider metadata for token payouts is not exposed through the swap plugin; native destinations cover the reported use cases. | +| Max spend in swap-send mode | Deferred | Needs the plugins' `getMaxSwappable`; plain-mode max is unaffected. | +| Dynamic chain metadata from the API | Deferred | Requires chain metadata through the swap plugin surface; the snapshot is dated in the module. Constrained further by the no-probing rule: a refresh would have to be a single cold fetch, never a loop. | +| `SwapDetailsCard` on a stealth send's tx detail | Reversed, now shipped | The card did not need a payout wallet, only the payout asset's currency config, which the saved action already carries. See [Transaction identity](#transaction-identity). | +| PIN spending limits on stealth sends | Reversed, now shipped | The original reasoning compared this to a swap. It is a send. See [Gate swap-send behind the PIN spending limit](#gate-swap-send-behind-the-pin-spending-limit). | +| [EIP-681](#eip-681) `value=` (wei) amounts | Deferred | Address is accepted, amount ignored; no reported user impact yet. | +| Validate provider deposit addresses against the source chain | Deferred | A PIVX order returns an address the PIVX plugin cannot spend to, and it surfaces as an opaque wallet error rather than a provider problem. A check after `asHoudiniOrder` would name the real cause; any chain the provider gets wrong fails the same way. | +| HoudiniSwap provider icon | Deferred | No `pluginIdIcons` entry exists, so the provider's rows render without a logo where every other provider has one. Needs the asset uploaded to the CDN. | +| Whether the PIN spending limit should count fees | Decided: it does not | Settled against the pre-Houdini behavior rather than carried as a question. See [Match the pre-existing spending-limit arithmetic](#match-the-pre-existing-spending-limit-arithmetic). | + +### Phase 18: one answer to what the recipient receives + +- **Sketched:** the "Recipient receives" row and its picker disagree for a token source, and the picker has no search across 34 chains. +- **Shipped:** both surfaces read one `getRecipientAsset`, so the row can no longer name an asset the picker does not offer, and it follows the Stealth toggle rather than only the picked chain, so a plain USDT send stops claiming the recipient receives ETH. The source chain is listed once instead of twice. `RadioListModal` rows carry an optional value, which fixes selection for labels that are not unique and gives the row a stable `testID`; the picker's maestro subflow now names its chain by pluginId. The search box reuses the filtering `ListModal` already had. +- **Also shipped:** the slider reads the live quote through a ref rather than the value its render closed over. The PIN spending-limit check awaits, and a quote retired during that await left the closed-over binding pointing at an order the scene had already dropped, so the hand-back added in [Phase 17](#phase-17-the-branch-made-reviewable) could not see it. +- **Diverged:** the row fix from [Phase 17](#phase-17-the-branch-made-reviewable) was half a fix. Branching on whether a chain had been picked was right while a swap was active and wrong otherwise, so correcting the stealth case moved the same mislabel onto the plain send. The predicate was never "did the user pick something", it was "is this a swap". +- **Also diverged:** two rows for the source chain looked like a filter bug and were a modelling one. They encoded a real distinction, an implicit destination versus an adopted one, that no user could see and that only changed how a missing private route recovered. Removing the row removed the distinction rather than hiding it. + +### Phase 19: the branch rebased, and read against the repo's own checklist + +- **Sketched:** rebase all three branches onto their bases and review them, checking the Houdini plugin against `AGENTS.md`, `.cursor/BUGBOT.md`, `docs/API_REQUIREMENTS.md` and `docs/CREATING_AN_EXCHANGE_PLUGIN.md`, which upstream had filled out in the meantime. +- **Shipped:** six checklist items the plugin was missing. The max probe sets `skipChecks: true`, without which an EVM engine rejects a probe aimed at the user's own address and fails every max swap from an EVM wallet. The probe clamps instead of throwing `SwapAboveLimitError`, so an above-limit balance still makes a max swap once the fee is out. A `from` quote refuses a deposit amount above what the user requested. Rounding acquired a direction: minimums up, maximums and receive and deposit amounts down. The deposit memo is cleaned with `asOptionalBlank(asNumberString)`, so a numeric tag no longer takes the order down and a blank one no longer becomes an empty memo. The token-id cache expires after ten minutes. Alongside them: sorting and limit selection moved off JS floats onto `biggystring`, a cleaner failure logs the payload, and the 409 deposit-address-in-use fallthrough matches the envelope's `code` rather than searching the response text. +- **Also shipped:** the [synthetic destination wallet](#synthetic-destination-wallet) is released. It is bridgified per `fetchSwapQuotes` call and reached through `quote.request.toWallet`, so it now closes by reference count once the last quote carrying it is closed; before, every quote refresh left one in yaob's object table for the life of the account. The same reasoning was already written down one line away, for `currencyConfig`. +- **Also shipped:** the swap-send path delays its success navigation through `InteractionManager.runAfterInteractions`, matching the plain-send path beside it and the repo rule about navigating out of a completed gesture. +- **Diverged:** the rebase auto-merged a duplicate `testID` onto `SafeSlider`'s thumb. This branch had turned the hardcoded id into a prop that defaults to the same value, and upstream had independently added the hardcoded one; git merged both attributes onto the element and only `tsc` caught it. Rebasing 18 commits over 155 is where a semantic conflict hides behind a textual non-conflict. +- **Also shipped:** the maestro suite, which the rebase had broken. `walletListRow` ids were identical across a wallet's seven token rows, so a walk asking for "My Sonic" could land on any of them; token rows now carry their currency code. The picker modal reused those same ids over a scene whose rows repeat the names, so a tap resolved to the COVERED row and dismissed the sheet; picker rows are now `walletPickerRow.`. And the Send scene is reached through Home -> Send -> "To Another Wallet/Exchange" rather than the Assets tab, whose wallet row no longer lands on the wallet's transaction list. +- **Held:** three findings from the core review were rejected with evidence rather than fixed. `payoutWalletId` becoming optional in the disk cleaners is correct, because the public type is genuinely optional now. `forceEnabled` reaching a provider the user switched off is the documented intent, and is set on the send scene alone. The synthetic wallet's id is deliberately stable rather than unique per request: nothing keys on it, and the GUI already treats a `synthetic://` id as naming no wallet. + +### Phase 20: the swap under the send is stated out loud + +- **Sketched:** a one-time warning modal on the send scene when Houdini is detected, a one-time modal on the dedicated swap scene in the pattern the other centralized providers already use, and a warning card at the bottom of the send scene for stealth, swap-and-send, or both. +- **Shipped:** all three, per [Saying that a swap is running](#saying-that-a-swap-is-running). The swap scene needed one table entry, since `SwapConfirmationScene` was already calling `swapVerifyTerms` and Houdini was the only routed provider with no `pluginData` row. The send scene got its own modal and its own disklet key. The card reads off `swapSendActive`, the same predicate the rest of the swap-send UI keys on. +- **Diverged:** the send scene cannot reuse the terms modal. `swapVerifyTerms` runs on `SwapConfirmationScene` alone, and a send-to-address quote never visits that scene, so a single acknowledgement would have covered the dedicated swap and silently skipped every stealth send. That is why the operator's two asks stayed two implementations rather than collapsing into one. +- **Also shipped:** the three PRs went back to draft. gui CI cannot pass against published `edge-core-js` 2.48.0, whose `SwapCurrencyError` dereferences `request.toWallet` unconditionally, which is the change core#730 carries. + +## 11. Decisions + +### Two acknowledgements rather than one + +The send scene and the dedicated swap scene each get their own one-time modal, with their own persistence. + +The alternative was to call `swapVerifyTerms` from the send scene too, so one `agreedToTerms` covered both. It loses on what the two modals are for. The terms modal is a provider consent gate: it names the provider, links its terms, privacy and know-your-customer pages, and disables the provider on a decline. The send-scene modal answers a different question, which is why this send now takes two transactions and longer than the user expects, and it must not disable anything, because a user who dismisses it still wants to send. Folding them would have meant one of the two texts always being wrong for the scene it appeared on. + +A second alternative was to show only the card and drop the send modal. The card is passive and lives beneath the amounts, so it can be scrolled past on the one send where the shape is genuinely new to the user. Reopen either if the two-modal sequence turns out to fire back to back for a user who reaches the swap scene and the send scene in the same session. + +### Build the synthetic destination wallet in the core + +Chosen: the core builds and bridgifies the destination wallet from a `toAddressInfo` descriptor. + +Evidence: a GUI-built fake was implemented first in the prototype. Its function properties do not survive the [yaob](#yaob) wire format, so plugin method calls on it fail once the object crosses into the core. + +Rejected: **GUI-built fake wallet** lost on the bridge finding above. **A new plugin-facing API** (`fetchSwapQuoteToAddress` or similar) lost because it forks every swap plugin's entry point to serve one provider; the synthetic wallet lets unmodified plugins participate. **Passing the address as a loose parameter alongside `toWallet`** lost because every plugin would need to know which of the two to trust. + +Reopen if: the bridge gains structured-object support that preserves methods, which would make a caller-built destination viable and remove the core dependency. + +### Integrate into SendScene2 rather than a parallel scene + +Chosen: the feature renders inside the existing send scene, gated by `swapSendAllowed`. + +Evidence: the task's UI proposal A reads "SendScene2 becomes a send-to-address swap". The send scene has many entry points beyond the wallet Send button. + +Rejected: **a feature-flagged parallel scene** (the prototype's approach, rerouting `TransactionListTop`) lost because it forks the send flow and leaves every other send entry point without the feature, and because two send scenes diverge in maintenance. + +Reopen if: the gate predicate grows past what one boolean can express clearly, which would signal the two flows really are different scenes. + +### Ask the user when the address format is ambiguous + +Chosen: when several served chains match a bare address, show a modal listing them and let the user choose. + +Evidence: a bare `0x` address matches roughly 14 [EVM](#evm) chains in the shipped table; a 42-character bech32 Bitcoin address also matches eCash. There is no information in the address itself that resolves this. + +Rejected: **pick the highest-priority match** lost because a wrong guess sends real funds to a chain the recipient does not control, which is unrecoverable. **Reject ambiguous addresses** lost because it would refuse the single most common case, an Ethereum address, which is the exact bug being fixed. **Infer from the source chain** lost because there is no correlation. + +Reopen if: the provider exposes a chain-resolution endpoint, or Edge gains an address-book that already knows the recipient's chain. + +### Correct the provider's address regexes rather than route around them + +Chosen: fix the Cardano and PIVX entries in `HOUDINI_CHAINS` with the reason recorded inline. + +Evidence: the published Cardano pattern ends in `|^[a-zA-z0-9]*|[0-9A-Za-z]{45,65}$`. The first alternative is unanchored and zero-length, so it matches every string including empty. An audit script over every entry found this was the only catch-all, and that PIVX writes `A-z`, a character class that also spans the six punctuation characters between the alphabet halves. + +Rejected: **exclude Cardano from detection only** lost because it leaves the validation bug live on the shipped feature, where the pattern also gates pasted destination addresses. **Wait for the provider to fix it** lost because detection is unusable in the meantime and the corrections are strictly narrowing. + +Reopen if: the provider publishes corrected patterns, at which point the local table should re-sync and drop the overrides. + +### Learn route availability from live failures, not probes or tables + +Chosen: the scene marks a pair's missing capability when a real quote fails with `SwapCurrencyError`, keeps it in session state, and reflects it pre-emptively from then on. + +Evidence: a 24-pair sweep on 2026-07-28 showed private-route availability differing by pair and changing between sessions (Litecoin lost its cross-asset private routes in two days while keeping same-asset ones), so no static table can be right for long. Probe quotes on pair selection were tried by hand against the API: a below-minimum probe amount returns HTTP 422 rather than the route list, so a nominal-amount probe misreports unsupported pairs, and a realistic-amount probe doubles quote traffic for every destination change. + +Rejected: **a static availability table** goes stale the same way the chain table's regexes did. **Probe quotes on pair selection** per the 422 behavior above. **Persisting learned caps** was rejected because availability recovers, and a stale negative would hide a working route indefinitely. + +Reopen if: the provider exposes a route-availability endpoint, which would make pre-emptive knowledge cheap and exact. + +### Restrict stealth to the privacy provider per request, not per account + +Chosen: `makeStealthSwapRequestOptions` disables every other provider for that one request and clears any saved preference. + +Evidence: users have provider preferences that would otherwise win; the toggle is per-send, not a setting. + +Rejected: **flipping account-level swap config** lost because it leaks a per-transaction choice into persistent state and would need reverting on every exit path. **Filtering the returned quotes** lost because it wastes every other provider's quote round-trip and can leave the user with nothing after a slow fan-out. + +Reopen if: more than one privacy provider exists, at which point the helper takes a set rather than a hardcoded id. + +### Send-to-any is Houdini-exclusive, toggle or no toggle + +Chosen: every send-to-address quote applies `makeStealthSwapRequestOptions`, whether the Stealth toggle is on or off. + +Evidence: operator direction, given as a phase 2 followup and reaffirmed after the phase 5 regression. Send-to-any exists as a privacy feature; fanning a destination address out to every enabled swap provider defeats that, whatever the toggle says. + +Accepted cost: a pair Houdini cannot route hard-errors instead of finding another provider. That is the intended trade ("Houdini-only, period"), and the phase 5 availability UI exists to make the refusal legible rather than to escape it. + +Rejected: **restricting only stealth sends** (the phase 1 shape, accidentally restored in phase 5) because it silently shops the user's destination to every provider the moment the toggle is off. + +Reopen if: product decides plain cross-asset sends should be a general aggregator feature rather than part of the privacy surface. That is an operator call, not a code-archaeology call. + +### Derive supported destinations from route metadata, never an asset list + +Chosen: every surface that answers "can this asset be a destination" reads `HOUDINI_CHAINS` through `getHoudiniChain`. + +Evidence: the same question is asked in four places (address detection, the recipient picker, quote gating, the "Myself" picker). A hardcoded native-only rule in any one of them goes stale the day the provider serves token payouts, and the staleness is invisible until a user reports it. + +Rejected: **a native-only check at each call site**, which is what the "Myself" picker effectively had. It reads as correct today and silently diverges later. + +Reopen if: a surface needs a destination rule the route metadata cannot express, at which point the metadata gains the field rather than the call site gaining a special case. + +### Name the send flow on the swap action, not in metadata + +Chosen: `EdgeTxActionSwap.swapType`, an optional core type, stamped by the send scene after approval. + +Evidence: the three flows are indistinguishable downstream. They all carry `swapInfo`, `orderId`, `payoutAddress` and a from/to asset pair, and with every send-to-address quote restricted to the privacy provider, even the winning plugin cannot tell a stealth send from a plain one. Only the scene knows, because only the scene has the toggle. + +Rejected: **a metadata-name or category convention**, which is a magic string a user edit can destroy and no type can enforce. **A separate `actionType`** lost because these are swaps: a new action type drops them out of every existing swap consumer (the details card, the exchange category, the savedAction sweep) and each one would need re-teaching. + +Reopen if: a non-GUI caller starts producing these transactions, which would move the stamping into whatever creates the order. + +### Suppress the recipient in the UI, keep it in storage + +Chosen: a stealth send keeps `payoutAddress` on `swapData` and hides it in every rendered surface. + +Evidence: support traces stuck orders by payout address, and losing it would make a failed private send unrecoverable. The privacy boundary this feature defends is the on-chain link between source and destination, which storing the address locally does not weaken: device-level access to the transaction file already implies access to the keys. + +Rejected: **not storing the address**, which buys no privacy against any attacker who is not already inside the device, and costs every future support ticket. + +Reopen if: the threat model grows to include an attacker with read access to wallet files but not keys. + +### Gate swap-send behind the PIN spending limit + +Chosen: the [PIN spending limit](#pin-spending-limit) applies to a swap-send exactly as it applies to a plain send. The limit is computed in one handler that both the makeSpend path and the swap-send path call, the check runs before either submit path, and the swap-send slider carries the same gate and the same prompt. + +Evidence: this reverses the earlier "not doing" entry, which reasoned that swap-send should match the existing swap flow because it is built on a swap quote. That comparison was wrong at the level that matters. A wallet-to-wallet swap moves funds between two wallets the user already controls, so a PIN prompt buys nothing; a swap-send moves funds to an arbitrary external address and is the exact operation the limit exists to gate. Reasoning from the implementation (it uses a swap quote) instead of from the user-visible operation (it is a send) is what produced the hole. Three separate gates were missing as a result: the submit-path check, the flag computation, and the slider's prompt, each bypassed by a different early return. + +Rejected: **matching the swap flow for consistency**, which is consistency with the wrong sibling. The plain-send path next to it in the same scene is the correct reference. + +Reopen if: the swap flow itself gains send-to-address destinations, at which point it needs this gate too rather than the reverse. + +### Share the swap error mapping instead of a catch-all card + +Chosen: the send scene maps a failed quote through the same `processSwapQuoteError` the wallet-to-wallet swap flow uses, extracted to `src/util/swapErrorDisplay.ts`, and wraps the result in an `I18nError` so `ErrorCard` renders the specific title and body. + +Evidence: `ErrorCard` renders anything that is not an `I18nError` as "Unexpected Error" with a canned "an unexpected error occurred, check your network connection" body and a Report Error button. Every swap-send failure took that path, so a user 0.1 TRX under the floor, a user on an unroutable pair, and a user with a genuine bug all saw the same card. The swap flow already had the mapping; the send scene simply was not using it. Verified on device: a 10 TRX send-to-address against a 10 USD provider floor now reads "Exchange Error / HoudiniSwap: Amount is too low, minimum is 10 USD". + +Rejected: **a bespoke error map for the send scene**, which would drift from the swap flow's within a release, and **passing the raw error through**, which loses the localized limit formatting the swap flow already does. + +Reopen if: the provider starts returning structured limit data on the error responses, which would let the plugin raise a typed `SwapBelowLimitError` for the 422 band instead of a plain message. + +### Ask the request for privacy, do not infer it from the route + +Chosen: `EdgeSwapRequest` carries an optional `privacy: 'required'`, and the Houdini plugin filters to private routes when it is set. A plain Swap & Send omits it and may take a standard route. + +Evidence: Houdini returns both route types above 25 USD and standard only between 10 and 25. With a single filter there is no correct setting: private-only breaks every plain swap-send in the 10-to-25 band, and accepting standard hands a Stealth send a single-leg route it cannot detect. A quote carries no route type back to the caller, so the downgrade would be invisible. Making the caller state its requirement puts the decision where the intent lives, and the plugin's obligation becomes explicit: decline rather than substitute. + +Rejected: **inferring privacy from whether the destination is same-asset**, which is wrong in both directions (a cross-asset stealth send needs privacy; a same-asset plain send does not exist). **A Houdini-specific `userSettings` flag**, which is account-wide where the requirement is per-request. **Reading the route type off the returned quote and re-requesting**, which spends two calls against a rate limit to answer what one flag settles. + +Reopen if: a second privacy provider appears, which would probably promote `'required'` into a small enum (`'required' | 'preferred'`) so an aggregator could rank rather than filter. + +### Validate the provider's stated minimums before hardcoding them + +Chosen: the three floors were probed against the live v2 API, then written down as named constants with the provider's own guidance quoted beside them, and enforced client-side before any request. + +Evidence: the numbers came from a feedback email, and an email is not a contract. The probe confirmed all three, which is the outcome that makes the constants trustworthy rather than the outcome that makes them interesting. Enforcing before the request also protects the rate limit, which the same feedback round identified as the thing that poisoned earlier availability readings. + +Rejected: **trusting the stated figures unverified**, which would have shipped an untested assumption into a gate that blocks sends. **Discovering the floor from quote errors alone**, which spends a request to learn a constant and gets throttled for it. **Scattering the numbers at the call sites**, which is how the next reader ends up with two different answers for the private floor. + +Reopen if: the floors move, or the provider exposes them per pair on the token or chain metadata, at which point they should be read rather than declared. + +### Show the order details on a stealth transaction, hide only the address + +Chosen: `SwapDetailsCard` renders for every swap-send, resolving the payout asset from the saved action when there is no payout wallet. `hidePayoutAddress` continues to mask the address alone. + +Evidence: the reason for keeping `payoutAddress` in storage is that support must be able to trace a stuck order. That argument requires the order id, provider, and amounts to be readable, so hiding them defeats the thing the storage rule was protecting. The card was in fact rendering nothing at all for every swap-send, private or not, because it bailed when the destination wallet lookup failed, and a synthetic payout wallet id never resolves. + +Rejected: **hiding the whole card for stealth transactions**, which is the outcome the bug produced by accident and which no one wanted. **Keeping the source wallet as the fallback config**, which resolves the payout currency code against the wrong chain and was the reason `payoutTokenId` stayed unset. + +Reopen if: order ids themselves become privacy-sensitive, which would argue for masking them in screenshots rather than removing them. + +### Scope the exchange-provider setting to the Exchange scene + +Chosen: the send-scene stealth and swap-send path ignores the global HoudiniSwap enable flag; the Exchange scene keeps honoring it. + +Evidence: the setting lives in Exchange settings and reads as a list of providers the swap aggregator may use. A private send is a send that happens to be powered by Houdini, so a user turning off a swap provider is not asking for private sends to disappear, and would have no way to connect the two if they did. + +Rejected: **honoring the flag on both paths**, which makes a send feature vanish with no explanation reachable from the send scene. **A second, send-specific toggle**, which is a settings row asking users to understand our provider topology. + +Reopen if: Houdini becomes one of several privacy providers, at which point the send path needs its own notion of which to use and the question changes shape. + +### Match the pre-existing spending-limit arithmetic + +Chosen: the PIN spending limit compares the sum of the spend targets against the limit and ignores the network fee, on the swap-send path exactly as on every plain send. + +Evidence: `origin/develop` computes it this way for every send in the app. The limit is a setting about how much a user may move without re-authenticating, and users read the amount they typed as the amount they are moving. Making one path add a fee the user never entered would give the same setting two meanings depending on which screen they were on, and the disagreement would surface as an unexplained PIN prompt just under a round number. + +Rejected: **adding the fee on the swap-send path only**, which is the version that creates the inconsistency. **Changing every path to include the fee**, which is a real product change to a security setting, affects flows this work does not touch, and would want its own task rather than riding in on a private-send feature. + +Reopen if: the limit is deliberately redefined as total wallet outflow, in which case both paths change together. + +### Let the token lookup decide what is served, and cache its misses + +Chosen: the chain table stays a pure name map, and whether a chain is actually served is answered at runtime by `resolveTokenId`, which memoizes misses as well as hits. + +Evidence: both routes already end in the same place. `checkWhitelistedMainnetCodes` throws `SwapCurrencyError` for an unmapped chain, and `resolveTokenId` finding no match throws the same error, so the provider declines correctly either way and the aggregator moves on. The only real difference was cost: the miss was never cached, so a chain the provider serves no native for spent a `GET /tokens` call on every quote against a rate-limited API. Caching the miss removes that, and it removes the reason to encode servedness in the table at all. + +Rejected: **hardcoding `null` for `celo`, `fantom`, `polkadot` and `ton`**, which is what shipped first. It reads as a fix but it is a snapshot of a live fact, so it rots the day Houdini adds a native and nothing tells us. The claim recorded for it was also wrong: it said a mapped-but-unserved chain surfaced an error where a `null` would have declined, and both paths raise the same error. **Caching failed lookups too**, which is cheaper still and wrong: a 429 would mark a perfectly good chain dead for the session. + +Reopen if: the provider exposes chain metadata through the swap plugin surface, which would let the name map itself stop being a snapshot. + +### Retry only as long as the thing you are retrying survives + +Chosen: a rate-limited call waits the window the provider asked for, unless the wait would land past the expiry of what it is sending, in which case it fails as a rate limit right away. + +Evidence: the two halves only look contradictory. Our own exponential cap must never truncate the provider's `retryAfter`, because retrying inside the window just draws another 429 and burns the budget, which is what a 30s ceiling did to Houdini's roughly 60s exchange window. But a quote id lives about 60 seconds, so honoring that same window and then re-POSTing the same quote id hangs the user for a minute and fails as an expired quote, which blames the wrong thing. Passing the quote's own expiry into the fetch resolves both: a short window with time left still retries, a long one fails immediately with the accurate reason. `validUntil` arrives as Unix seconds inside a string, which `new Date` reads as an invalid date, so the parse reads the number first or the guard can never fire. + +Rejected: **never retrying create-exchange**, which throws away the cases where the window is short and the quote has time left. **Capping the wait and retrying anyway**, which is the shipped-then-fixed version: it produces a wrong error message after a wasted wait. + +Reopen if: the provider issues quote ids that outlive their pricing, or exposes a re-quote endpoint cheap enough to re-price inside the retry. + +## 12. Glossary + +### BIP-21 + +Bitcoin Improvement Proposal 21, the `bitcoin:
?amount=` payment URI scheme that every Bitcoin-family QR encodes. Its `amount=` parameter is one of the two the URI splitter reads, and its `:` prefix is what lets a scanned code name its own chain instead of leaving the app to guess from the address format. [Specification](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki). + +### cashaddr + +Bitcoin Cash and eCash address encoding, whose canonical on-chain form keeps its `bitcoincash:` or `ecash:` prefix rather than dropping it the way a BIP-21 URI does. The prefix therefore cannot be stripped blindly, which is why the URI splitter returns the raw text as its first address candidate before it returns the stripped path. [Specification](https://reference.cash/protocol/blockchain/encoding/cashaddr). + +### CORS + +Cross-Origin Resource Sharing, the browser rule that attaches `Origin` and `Sec-Fetch-*` headers to a request and lets the server refuse it on that basis. Swap plugins run inside the core's WebView, so `io.fetch` looks like a browser to Houdini's server-to-server partner API, which answers 403; every call passes `corsBypass: 'always'` to route through the native fetch instead. [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS). + +### DEX + +Decentralized exchange, a swap venue that settles on chain through a contract rather than through a custodial order book. Houdini offers dex routes as a third route type below `private` and `standard`, with its own 5 USD floor; this feature never takes one, since a dex route cannot break the on-chain link the feature exists to break. See [Route selection](#route-selection) and [ethereum.org](https://ethereum.org/en/decentralized-exchanges/). + +### EIP-681 + +Ethereum Improvement Proposal 681, the `ethereum:
@?value=` payment URI scheme. Its `pay-` prefix and `@chainId` suffix are stripped to recover the bare address, and its `value=` parameter is deliberately ignored because it is denominated in wei rather than in display units. [Specification](https://eips.ethereum.org/EIPS/eip-681). + +### EVM + +Ethereum Virtual Machine, the execution environment Ethereum and the chains compatible with it share. Compatibility includes the 20-byte `0x` address format, so roughly fourteen chains in the chain table accept an identical-looking address and a bare `0x` string cannot name its own chain. That is the ambiguity the network picker exists for; see [Decision: ask the user when the address format is ambiguous](#ask-the-user-when-the-address-format-is-ambiguous). [Specification](https://ethereum.org/en/developers/docs/evm/). + +### FIO + +Foundation for Interwallet Operability, a protocol whose payment requests Edge can fulfil from the send scene. A FIO request pre-fills the send and takes over parts of its completion, so `swapSendAllowed` excludes it and those sends keep today's behavior untouched. [Protocol documentation](https://dev.fio.net/). + +### IBC + +Inter-Blockchain Communication, the Cosmos-ecosystem transfer protocol. Houdini reports no `memoNeeded` flag and a permissive `^.*$` address validation for its IBC-family chains, so their payout semantics are not trustworthy enough to offer and the plugin maps them to `null`. See [Chain mapping](#chain-mapping) and the [protocol documentation](https://ibcprotocol.dev/). + +### Maestro + +The YAML-driven UI test runner the repo drives the simulator with. `maestro/14-stealth/` holds one flow per user-visible branch of this feature, composed from subflows in `maestro/common/`. [Documentation](https://docs.maestro.dev/). + +### Memo + +A short payload some chains require alongside a payment so the receiving exchange can credit the right account, called a destination tag on XRP. Five chains in the table need one, the send scene shows a tag row for them, and the value rides `toAddressInfo.toMemos` to the core and reaches the provider as `destinationTag` on order creation. See [The request contract](#the-request-contract) and the XRP Ledger's [destination tag documentation](https://xrpl.org/docs/concepts/transactions/source-and-destination-tags). + +### PIN spending limit + +An Edge account setting that re-prompts for the PIN once a single send exceeds a configured fiat amount. A swap-send moves funds to an arbitrary external address, so it is gated exactly like a plain send; see [Decision: gate swap-send behind the PIN spending limit](#gate-swap-send-behind-the-pin-spending-limit). The setting itself is [`src/reducers/SpendingLimitsReducer.ts`](https://github.com/EdgeApp/edge-react-gui/blob/develop/src/reducers/SpendingLimitsReducer.ts). + +### Synthetic destination wallet + +The core-built object that stands in for a destination the user does not own. It is backed by the real `EdgeCurrencyConfig`, so `currencyInfo` and `allTokens` are authentic, while its address accessors return the pasted address and its `getMemos` returns the descriptor's memos. Swap plugins receive it as an ordinary `EdgeCurrencyWallet` and need no knowledge of addresses-instead-of-wallets. Defined in [The synthetic wallet](#the-synthetic-wallet), from [`src/core/swap/synthetic-wallet.ts`](https://github.com/EdgeApp/edge-core-js/blob/master/src/core/swap/synthetic-wallet.ts). + +### UTXO + +Unspent transaction output, the accounting model Bitcoin-family chains use, in which a transaction spends whole prior outputs and may pay several recipients at once. Multi-recipient sends therefore exist only on these chains, and they are gated against swap-send in both directions; see [Multi-recipient gating](#multi-recipient-gating) and the [Bitcoin developer guide](https://developer.bitcoin.org/devguide/transactions.html). + +### yaob + +Yet Another Object Bridge, the RPC layer that carries objects between the app's JavaScript context and the core's WebView. It transports data, not behavior: a plain object's function properties do not survive the wire format. That is why a GUI-built destination wallet fails once it crosses into the core, and why the synthetic wallet is built on the core side instead. [Package](https://github.com/swansontec/yaob). + +## 13. References + +- [Asana task 1216251688512498](https://app.asana.com/0/1215088146871429/1216251688512498) +- [edge-react-gui#6066](https://github.com/EdgeApp/edge-react-gui/pull/6066), [edge-core-js#730](https://github.com/EdgeApp/edge-core-js/pull/730), [edge-exchange-plugins#469](https://github.com/EdgeApp/edge-exchange-plugins/pull/469) +- Prototypes: [edge-react-gui#6054](https://github.com/EdgeApp/edge-react-gui/pull/6054), [edge-react-gui#6031](https://github.com/EdgeApp/edge-react-gui/pull/6031) +- HoudiniSwap v2 partner API, mainnet native-token snapshot dated 2026-07-30 in `src/util/houdiniChains.ts` + +## 14. Post-implementation retrospective + +### Estimate vs. actuals + +| Phase | Sketched as | Actual | +|---|---|---| +| Core seam | One optional request field | One field plus a synthetic wallet module, a request resolver, and an error-path fix (435 lines, 7 files) | +| Plugin | A standard central-exchange plugin | Standard shape plus two non-obvious transport constraints (no-`Bearer` auth, forced [CORS](#cors) bypass) and a route-type filter | +| Send scene | A selector and a toggle | 847 lines changed in one file: linked amounts, expiry re-quoting, destination tags, gating, and two address-entry hooks | +| Address entry | Reuse `AddressTile2` unchanged | Two new hooks and a chain-detection module, after a user report | +| Chain metadata | Four chains | 34 chains, two corrected regexes | + +### Where this document was wrong or silent + +1. **Address entry was treated as a solved sub-problem.** [Section 7](#7-detailed-design-edge-react-gui) originally described only `crossChainAddressValidation`, which validates an address once the destination is known. It said nothing about how the destination becomes known, and the implicit answer, that the user sets "Recipient receives" first, is not what users do. The bug was reported from the field, not caught in design. The corrective is the `onUnparsedAddress` hook now documented in the same section. +2. **Route availability is a live dependency, not a static one.** Nothing in the design treated "the provider offers a private route for this pair" as a variable. It is: a sweep of 24 pairs on 2026-07-28 found private routes offered only from Bitcoin and Monero sources, where Litecoin had worked two days earlier. Forward swap-to-address sends from other chains therefore fail with `SwapCurrencyError` and a generic error card. The [route selection](#route-selection) filter is correct; the gap was that there was no user-facing distinction between "no route right now" and "something went wrong". Phase 5 closed the UI half of this: a missing route now turns its control off with an explanation ([Availability fallbacks](#availability-fallbacks)). Raising the availability change itself with the provider remains open. +3. **The provider's published metadata was assumed correct.** The chain table was written as a faithful snapshot. Two of its regexes are defective, one of them so permissive it matches every string. Snapshotting external validation data needs an audit pass, not just a transcription. +4. **PIVX payouts are unusable and the design cannot tell.** A PIVX order returns a deposit address that is not a PIVX address (`EXMD…` rather than base58 `D…`), so the send fails at spend time with an opaque wallet error. Reproduced directly against the API with the plugin's own payload shape. The design has no validation of provider-returned deposit addresses against the from-chain. +5. **Undocumented intent regressed in code.** The phase 2 followup made send-to-any Houdini-exclusive, but the change was autosquashed into the feature commit and neither the PR body nor this document was updated. Phase 5 then read the unconditional restriction as drift against the documented fan-out and "fixed" it, shipping a live regression that phase 6 had to revert on operator correction. The lesson: an operator-directed behavior change must update the PR body and this document in the same turn it lands, because both are treated as behavior contracts by later work, and a squashed history cannot testify to intent. +6. **The chain table was never checked against the routes it claims.** Item 3 caught bad regexes by reading them. Nobody asked the prior question: does the provider serve each of these chains at all? Phase 10 asked it and got two answers, both bad. Four chains (`celo`, `fantom`, `polkadot`, `ton`) have no mainnet native in the API, so every quote naming one spent a token lookup to be told nothing, on a rate-limited API, with the miss uncached. Six more (`algorand`, `ecash`, `hyperevm`, `sonic`, `stellar`, `zcash`) had natives the plugin could never find, because the API returns `address: ""` for those chains rather than `null` and the native lookup tested `address == null`, which is false for an empty string. Ten of 38 advertised destinations were dead, and nothing in the type system, the tests, or a code review could have seen it: the defect lives in the agreement between a snapshot and a live API. A table transcribed from an external source needs a periodic reconciliation against that source, and every field the code branches on needs one live case exercising each branch. + +7. **Missing funds were treated as a precondition instead of a task.** Phase 10 found the six empty-string chains, fixed them, confirmed the fix was present in the installed bundle, and then wrote "drive them once one is funded" as a follow-up, because none of those assets held a balance. That was the wrong shape of answer. Funding a wallet is a swap away, the principal stays inside the account, and only the spread and network fee are spent, so "unfunded" is a step to perform rather than a blocker to report. The cost of getting it wrong was a full extra phase before the highest-value fix of the round was exercised at all. Phase 11 funded Sonic by swapping into it and executed a private send between two of the six, which took under twenty minutes and about a dollar. The general rule this leaves behind: when a test needs an asset the account does not hold, acquire it and continue, and treat any follow-up phrased as "once X is funded" as a task that was skipped rather than one that was blocked. + +### What held + +- The `toAddressInfo` seam. Three phases of GUI change and a user-reported bug fix landed without a single change to the core contract or the plugin's destination handling. +- Routing memos through `getMemos` on the wallet rather than as a descriptor field the plugin reads. Plugins kept one code path for destination memos. +- The `swapSendAllowed` predicate. Every constrained caller was excluded by construction, and no regression in payment protocol, [FIO](#fio), or deep-link sends appeared across four phases of testing. +- Extracting `PriceImpactText` instead of recreating it. The swap confirmation scene and the send scene have not drifted. + +### Verification highlights + +- Four real on-chain executions across the phases, txids in [Section 9](#9-testing), including the reported cross-chain address-entry path end to end. +- 41 unit tests covering the URI splitter and chain detection, including a regression test that fails if the Cardano catch-all pattern returns. +- 10-case entry-path and chain matrix on device covering BTC, LTC, ETH, POL, SOL, plus USDC and USDT token sources, with screenshots attached to [#6066](https://github.com/EdgeApp/edge-react-gui/pull/6066). +- `verify-repo.sh` green on the gui: 605 tests, 95 suites, 107 snapshots. diff --git a/src/util/stealthSwap.ts b/src/util/stealthSwap.ts new file mode 100644 index 00000000000..9426bf71698 --- /dev/null +++ b/src/util/stealthSwap.ts @@ -0,0 +1,21 @@ +import type { EdgeCurrencyWallet, EdgeSwapRequest } from 'edge-core-js' + +/** + * The destination wallet of a wallet-to-wallet swap request. + * + * `EdgeSwapRequest.toWallet` became optional when swap-to-address arrived, but + * only that flow omits it and that flow has its own scenes, so every + * wallet-to-wallet surface needs the same narrowing before it can read the + * destination. Six copies of this guard had already drifted into two different + * messages; one helper keeps the narrowing, the message and the reason for + * both in one place. + */ +export function requireDestinationWallet( + request: EdgeSwapRequest +): EdgeCurrencyWallet { + const { toWallet } = request + if (toWallet == null) { + throw new Error('Swap request is missing a destination wallet') + } + return toWallet +} From 13f5d80e2e22d67756154334351b274726355746 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:21:43 -0700 Subject: [PATCH 02/18] Let a row show a tinted state word in its title A row that wants one word of its header in a state color had to give up the shared header styling and rebuild it. Add a titleState prop the row renders after its title, and add the green PositiveText beside the existing orange WarningText. Both set color only, so a span nested in a header keeps the header's size. The caller supplies its own punctuation inside the node, which is how parentheses take the state colour rather than the title's. --- eslint.config.mjs | 11 ++++++++--- src/components/rows/EdgeRow.tsx | 15 ++++++++++++--- src/components/themed/EdgeText.tsx | 22 ++++++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 04eee3db64c..03eecc236e4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -34,7 +34,14 @@ export default [ 'react-native/no-raw-text': [ 'error', { - skip: ['B', 'EdgeText', 'Paragraph', 'SmallText', 'WarningText'] + skip: [ + 'B', + 'EdgeText', + 'Paragraph', + 'PositiveText', + 'SmallText', + 'WarningText' + ] } ], 'react-native/sort-styles': 'off', @@ -251,8 +258,6 @@ export default [ 'src/components/rows/CryptoFiatAmountRow.tsx', - 'src/components/rows/EdgeRow.tsx', - 'src/components/rows/PaymentMethodRow.tsx', 'src/components/rows/TxCryptoAmountRow.tsx', diff --git a/src/components/rows/EdgeRow.tsx b/src/components/rows/EdgeRow.tsx index 669ca5e4bfc..8b3cf9936b3 100644 --- a/src/components/rows/EdgeRow.tsx +++ b/src/components/rows/EdgeRow.tsx @@ -46,6 +46,13 @@ interface Props { maximumHeight?: 'small' | 'medium' | 'large' rightButtonType?: RowActionIcon title?: string + + /** A state node for the title, rendered after it. Supply it wrapped in a + * color component to tint it without tinting the title, and include any + * punctuation you want tinted with it: the row adds only the separating + * space, so parentheses supplied here take the state colour rather than the + * title's. */ + titleState?: React.ReactNode testID?: string onLongPress?: () => Promise | void onPress?: () => Promise | void @@ -54,7 +61,7 @@ interface Props { marginRem?: number[] | number } -export const EdgeRow = (props: Props) => { +export const EdgeRow: React.FC = (props: Props) => { const { body, children, @@ -65,6 +72,7 @@ export const EdgeRow = (props: Props) => { maximumHeight = 'medium', testID, title, + titleState, // Handlers: onLongPress, @@ -122,12 +130,13 @@ export const EdgeRow = (props: Props) => { {title == null ? null : ( {title} + {titleState == null ? null : <> {titleState}} )} - {loading ? ( + {loading === true ? ( = (props: { ) } +/** Makes the contents of an `EdgeText` or `Paragraph` green, for affirmative + * states. Unless used within a `Paragraph` block, provides no outer spacing. */ +export const PositiveText: React.FC<{ children: React.ReactNode }> = (props: { + children: React.ReactNode +}) => { + const { children } = props + const theme = useTheme() + const styles = getStyles(theme) + + return ( + + {children} + + ) +} + /** Makes the contents of an `EdgeText` or `Paragraph` large (1.5rem). * Unless used within a `Paragraph` block, provides no outer spacing. */ export const HeaderText: React.FC<{ children: React.ReactNode }> = (props: { @@ -169,6 +188,9 @@ const getStyles = cacheStyles((theme: Theme) => ({ includeFontPadding: false }, + colorPositive: { + color: theme.positiveText + }, colorWarning: { color: theme.warningText }, From 406056cf53918ecfbf2ac1d6dc8d8c66be86a861 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:22:53 -0700 Subject: [PATCH 03/18] Add the testIDs the stealth UI walks target A UI walk cannot reliably target these surfaces by their visible text: a search field's placeholder disappears as soon as the field holds a query, and a wallet row repeats its own name inside the picker's search field, so a text selector matches the field instead of the row. Key the wallet rows, transaction rows, radio list items, search footers, the slider thumb, and the text-input and scan modals by id instead. RadioListModal also gains an optional message, so a picker that has to explain why it is asking can say so between its title and its list. --- eslint.config.mjs | 3 --- .../TransactionListRow.test.tsx.snap | 1 + .../TextInputModal.test.tsx.snap | 10 ++++---- src/components/modals/RadioListModal.tsx | 8 +++++-- src/components/modals/ScanModal.tsx | 1 + src/components/modals/TextInputModal.tsx | 1 + src/components/themed/SearchFooter.tsx | 4 ++++ src/components/themed/TransactionListRow.tsx | 8 ++++++- src/components/themed/WalletList.tsx | 3 +++ .../themed/WalletListCurrencyRow.tsx | 24 +++++++++++++++++++ 10 files changed, 53 insertions(+), 10 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 03eecc236e4..b0fe84c25b3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -224,10 +224,8 @@ export default [ 'src/components/modals/PasswordReminderModal.tsx', 'src/components/modals/PermissionsSettingModal.tsx', - 'src/components/modals/RadioListModal.tsx', 'src/components/modals/RawTextModal.tsx', 'src/components/modals/ScamWarningModal.tsx', - 'src/components/modals/ScanModal.tsx', 'src/components/modals/StateProvinceListModal.tsx', 'src/components/modals/TransferModal.tsx', @@ -370,7 +368,6 @@ export default [ 'src/components/themed/SceneHeader.tsx', - 'src/components/themed/SearchFooter.tsx', 'src/components/themed/SelectableRow.tsx', 'src/components/themed/ShareButtons.tsx', diff --git a/src/__tests__/components/__snapshots__/TransactionListRow.test.tsx.snap b/src/__tests__/components/__snapshots__/TransactionListRow.test.tsx.snap index 3eee088d225..68feb924666 100644 --- a/src/__tests__/components/__snapshots__/TransactionListRow.test.tsx.snap +++ b/src/__tests__/components/__snapshots__/TransactionListRow.test.tsx.snap @@ -44,6 +44,7 @@ exports[`TransactionListRow should render with loading props 1`] = ` "paddingTop": 11, } } + testID="txListRow_Sent Bitcoin" > @@ -616,7 +617,7 @@ exports[`TextInputModal should render with a blank input field 1`] = ` "paddingVertical": 28, } } - testID="undefined.clearIcon" + testID="textInputModal.clearIcon" > @@ -1470,7 +1472,7 @@ exports[`TextInputModal should render with a populated input field 1`] = ` "paddingVertical": 28, } } - testID="undefined.clearIcon" + testID="textInputModal.clearIcon" > = props => { + const { bridge, items, message, selected, title } = props const theme = useTheme() const styles = getStyles(theme) @@ -59,6 +61,7 @@ export function RadioListModal(props: Props) { return ( { bridge.resolve(name) }} @@ -87,6 +90,7 @@ export function RadioListModal(props: Props) { = props => { diff --git a/src/components/modals/TextInputModal.tsx b/src/components/modals/TextInputModal.tsx index a8660007aee..91ff0cf0a6c 100644 --- a/src/components/modals/TextInputModal.tsx +++ b/src/components/modals/TextInputModal.tsx @@ -131,6 +131,7 @@ export const TextInputModal: React.FC = props => { /> ) : null} = props => { = props => { // HACK: Handle 100% of the margins because of SceneHeader usage on this scene return isCard === true ? ( - + <> @@ -297,6 +302,7 @@ const TransactionViewInner: React.FC = props => { ) : ( = (props: Props) => { token={token} tokenId={tokenId} wallet={wallet} + // This list only ever renders inside the picker modal, which + // floats over a scene whose rows carry the same names. + testIdPrefix="walletPickerRow" onPress={handlePress} /> ) diff --git a/src/components/themed/WalletListCurrencyRow.tsx b/src/components/themed/WalletListCurrencyRow.tsx index 1c365e221ff..82e00b4b462 100644 --- a/src/components/themed/WalletListCurrencyRow.tsx +++ b/src/components/themed/WalletListCurrencyRow.tsx @@ -29,6 +29,15 @@ interface Props { tokenId: EdgeTokenId wallet: EdgeCurrencyWallet + /** + * Namespace for the row's `testID`, so a walk can name the row it means on + * the surface it means. This component renders BOTH the wallet-list scene + * and the wallet-picker modal, and a modal sits over the scene: one shared + * namespace made a picker row indistinguishable from the row behind it, and + * a tap resolved to the covered one and dismissed the sheet. + */ + testIdPrefix?: string + // Callbacks: onLongPress?: () => void onPress?: ( @@ -44,6 +53,7 @@ const WalletListCurrencyRowComponent: React.FC = props => { token, tokenId, wallet, + testIdPrefix = 'walletListRow', // Callbacks: onLongPress, @@ -186,6 +196,20 @@ const WalletListCurrencyRowComponent: React.FC = props => { ) : null } + // Keyed by wallet name so a UI test can target one specific row. Rows + // repeat their name inside a picker's search field, so a plain text + // selector matches the field instead of the row a walk means to tap. + // + // A TOKEN row carries its currency code as well, because one wallet + // renders one row per enabled token and a name-only id named all of them + // at once: a walk asking for "My Sonic" could land on any of its seven + // rows. The chain's own coin keeps the bare name, which is what every + // walk means when it names a wallet. + testID={ + tokenId == null + ? `${testIdPrefix}.${walletName}` + : `${testIdPrefix}.${walletName}.${currencyCode}` + } onLongPress={handleLongPress} onPress={handlePress} paddingRem={0.5} From b5eaaafd2746792385c9b073390ad07cac135723 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:23:19 -0700 Subject: [PATCH 04/18] Read a fiat rate from a rates snapshot getFiatExchangeRate reaches into the whole store for a fiat-to-fiat rate, which a caller that already holds a GuiExchangeRates snapshot has no reason to do. Move the arithmetic into getFiatRate over the snapshot and leave the store-shaped function as a one-line wrapper, so existing callers are untouched. --- src/selectors/WalletSelectors.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/selectors/WalletSelectors.ts b/src/selectors/WalletSelectors.ts index bb74ba67d20..f927e6f86b5 100644 --- a/src/selectors/WalletSelectors.ts +++ b/src/selectors/WalletSelectors.ts @@ -46,18 +46,24 @@ export const getExchangeRate = ( return foundRate } -export const getFiatExchangeRate = ( - state: RootState, +/** + * Fiat-to-fiat rate off a rates snapshot, for callers that already hold + * `GuiExchangeRates` and have no reason to reach for the whole store. Returns + * `0` when neither a direct rate nor a USD pivot is known, which callers must + * treat as "no rate" rather than as a real conversion. + */ +export const getFiatRate = ( + exchangeRates: GuiExchangeRates, fromIsoCode: string, toIsoCode: string ): number => { // Use the direct rate if we have it: - const rate = state.exchangeRates.fiat[fromIsoCode]?.[toIsoCode] + const rate = exchangeRates.fiat[fromIsoCode]?.[toIsoCode] if (rate?.current != null) return rate.current // Convert via USD as a fallback: - const fromUSD = state.exchangeRates.fiat?.[fromIsoCode]?.['iso:USD']?.current - const toUSD = state.exchangeRates.fiat?.[toIsoCode]?.['iso:USD']?.current + const fromUSD = exchangeRates.fiat?.[fromIsoCode]?.['iso:USD']?.current + const toUSD = exchangeRates.fiat?.[toIsoCode]?.['iso:USD']?.current if (fromUSD == null) return 0 if (toUSD == null || toUSD === 0) return 0 @@ -65,6 +71,12 @@ export const getFiatExchangeRate = ( return foundRate } +export const getFiatExchangeRate = ( + state: RootState, + fromIsoCode: string, + toIsoCode: string +): number => getFiatRate(state.exchangeRates, fromIsoCode, toIsoCode) + export const convertCurrency = ( exchangeRates: GuiExchangeRates, pluginId: string, From 6e50cf38b0e1126aea76a83da8338f8d271aa13c Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:23:36 -0700 Subject: [PATCH 05/18] Split a payment URI into its chain, address, and amount A scanned QR carries a payment URI (ethereum:0x...?amount=0.5), never a bare address, and the sending wallet's own parseUri cannot read one addressed to another chain. Add a generic splitter that reports the scheme, the address, and the requested amount without knowing anything about which chains exist. --- src/__tests__/util/paymentUri.test.ts | 166 ++++++++++++++++++++++++++ src/util/paymentUri.ts | 123 +++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 src/__tests__/util/paymentUri.test.ts create mode 100644 src/util/paymentUri.ts diff --git a/src/__tests__/util/paymentUri.test.ts b/src/__tests__/util/paymentUri.test.ts new file mode 100644 index 00000000000..a73692c7e38 --- /dev/null +++ b/src/__tests__/util/paymentUri.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from '@jest/globals' + +import { parsePaymentUri } from '../../util/paymentUri' + +describe('parsePaymentUri', () => { + it('passes a bare address through as its own candidate', () => { + const address = '0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372' + expect(parsePaymentUri(address)).toEqual({ + addressCandidates: [address] + }) + }) + + it('trims surrounding whitespace', () => { + expect( + parsePaymentUri(' bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq \n') + ).toEqual({ + addressCandidates: ['bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'] + }) + }) + + it('splits a BIP-21 URI with an amount', () => { + const uri = + 'bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.0123' + expect(parsePaymentUri(uri)).toEqual({ + addressCandidates: [ + uri, + 'bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', + 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' + ], + displayAmount: '0.0123', + scheme: 'bitcoin' + }) + }) + + it('splits a BIP-21 URI without a query', () => { + expect( + parsePaymentUri('bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq') + ).toEqual({ + addressCandidates: [ + 'bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', + 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' + ], + displayAmount: undefined, + scheme: 'bitcoin' + }) + }) + + it('keeps the scheme-prefixed candidate for cashaddr-style addresses', () => { + const result = parsePaymentUri( + 'bitcoincash:qqkv9wr69ry2p9l53lxp635va4h86wv435995w8p2h?amount=1.5' + ) + expect(result.addressCandidates).toContain( + 'bitcoincash:qqkv9wr69ry2p9l53lxp635va4h86wv435995w8p2h' + ) + expect(result.addressCandidates).toContain( + 'qqkv9wr69ry2p9l53lxp635va4h86wv435995w8p2h' + ) + expect(result.displayAmount).toBe('1.5') + }) + + it('strips EIP-681 chain suffix and pay- prefix', () => { + const result = parsePaymentUri( + 'ethereum:pay-0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372@1?amount=0.5' + ) + expect(result.addressCandidates).toContain( + '0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372' + ) + expect(result.displayAmount).toBe('0.5') + }) + + it('reads a Monero-family tx_amount parameter', () => { + const result = parsePaymentUri( + 'monero:46byoyaW?tx_amount=2.25&tx_description=x' + ) + expect(result.addressCandidates).toContain('46byoyaW') + expect(result.displayAmount).toBe('2.25') + }) + + it('ignores a non-decimal amount', () => { + const result = parsePaymentUri('bitcoin:bc1qtest?amount=abc') + expect(result.displayAmount).toBeUndefined() + expect(result.addressCandidates).toContain('bc1qtest') + }) + + it('ignores an EIP-681 wei value parameter', () => { + const result = parsePaymentUri( + 'ethereum:0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372?value=2e18' + ) + expect(result.displayAmount).toBeUndefined() + expect(result.addressCandidates).toContain( + '0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372' + ) + }) + + it('strips leading slashes from the path', () => { + const result = parsePaymentUri( + 'ripple://rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh?amount=20' + ) + expect(result.addressCandidates).toContain( + 'rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh' + ) + expect(result.displayAmount).toBe('20') + }) + + it('survives malformed percent-encoding in the query', () => { + const result = parsePaymentUri('bitcoin:bc1qtest?label=%E0%A4%A&amount=0.1') + expect(result.displayAmount).toBe('0.1') + }) +}) + +describe('parsePaymentUri EIP-681 chain id and memos', () => { + it('reports the @chainId suffix without it reaching the address', () => { + const parsed = parsePaymentUri( + 'ethereum:0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e@137' + ) + expect(parsed.evmChainId).toEqual('137') + expect(parsed.scheme).toEqual('ethereum') + expect(parsed.addressCandidates).toContain( + '0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e' + ) + }) + + it('reads only the leading digits of an EIP-681 function suffix', () => { + const parsed = parsePaymentUri( + 'ethereum:0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e@8453/transfer' + ) + expect(parsed.evmChainId).toEqual('8453') + }) + + it('leaves evmChainId unset when the URI names no chain', () => { + const parsed = parsePaymentUri( + 'ethereum:0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e' + ) + expect(parsed.evmChainId).toBeUndefined() + }) + + it('keeps a destination tag or memo the URI carries', () => { + // The tag is what credits the recipient at a memo-required exchange, so + // dropping it pays the deposit address with nothing to attribute it to. + expect(parsePaymentUri('ripple:rABC123?dt=987654').memo).toEqual('987654') + expect(parsePaymentUri('stellar:GABC?memo=hello').memo).toEqual('hello') + expect(parsePaymentUri('cosmos:cosmos1abc?tag=42').memo).toEqual('42') + expect(parsePaymentUri('bitcoin:bc1qxyz').memo).toBeUndefined() + }) +}) + +describe('parsePaymentUri EIP-681 function calls', () => { + it('offers no address for a token-transfer code', () => { + // The path holds the token CONTRACT and the payee rides in a parameter, so + // adopting the path address would send native funds to a contract. + const parsed = parsePaymentUri( + 'ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e&uint256=1e6' + ) + expect(parsed.addressCandidates).toEqual([]) + expect(parsed.evmChainId).toEqual('1') + }) + + it('still offers the address for a plain chain-id code', () => { + const parsed = parsePaymentUri( + 'ethereum:0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e@137' + ) + expect(parsed.addressCandidates).toContain( + '0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e' + ) + }) +}) diff --git a/src/util/paymentUri.ts b/src/util/paymentUri.ts new file mode 100644 index 00000000000..346cc4d5d8d --- /dev/null +++ b/src/util/paymentUri.ts @@ -0,0 +1,123 @@ +export interface ParsedPaymentUri { + /** + * Possible bare-address readings of the scanned text, in priority order: + * the raw text itself (bare addresses, and cashaddr-style addresses whose + * on-chain form keeps the `prefix:`), the scheme-prefixed path with the + * query stripped, and the naked path with scheme, EIP-681 `pay-` prefix, + * and `@chainId` suffix removed. + */ + addressCandidates: string[] + + /** + * Payment amount in display (exchange-denomination) units of the URI's + * chain, from a BIP-21 `amount=` or Monero-family `tx_amount=` parameter. + */ + displayAmount?: string + + /** + * The URI scheme (`ethereum` in `ethereum:0x...`), absent for plain text. + * Names the destination chain outright, which is how a cross-chain paste can + * be resolved without guessing between chains that share an address format. + */ + scheme?: string + + /** + * The EIP-681 `@chainId` suffix (`137` in `ethereum:0x...@137`), as written. + * + * Every EVM network's payment code uses the `ethereum:` scheme and states + * which network it means here, so the scheme alone identifies the FAMILY and + * this identifies the CHAIN. Reading the scheme without it sends a Polygon, + * Arbitrum or Base code to Ethereum mainnet. + */ + evmChainId?: string + + /** + * A destination memo carried by the URI: a `dt` destination tag (XRP), or a + * `memo`, `tag` or `message` parameter. Memo-required payout chains credit + * the recipient by this value, so a scanned exchange deposit code that + * carries one has to keep it. + */ + memo?: string +} + +const schemeRegex = /^([a-zA-Z][a-zA-Z0-9+.-]*):(.*)$/s +const displayAmountRegex = /^\d+(\.\d+)?$/ + +/** + * Splits a scanned payment URI (BIP-21 / EIP-681 style) into bare-address + * candidates and a display-units amount, without any chain-specific parser. + * Used for send-to-address swap destinations, where the destination chain has + * no wallet whose `parseUri` could do this properly. Plain text that is not a + * URI passes through as its own single candidate. + */ +export function parsePaymentUri(text: string): ParsedPaymentUri { + const trimmed = text.trim() + const schemeMatch = schemeRegex.exec(trimmed) + if (schemeMatch == null) return { addressCandidates: [trimmed] } + + const [, scheme, rest] = schemeMatch + const queryIndex = rest.indexOf('?') + const path = queryIndex < 0 ? rest : rest.slice(0, queryIndex) + const query = queryIndex < 0 ? '' : rest.slice(queryIndex + 1) + + // Parse the query parameters: + const params = new Map() + for (const pair of query.split('&')) { + if (pair === '') continue + const eqIndex = pair.indexOf('=') + if (eqIndex < 0) continue + try { + params.set( + decodeURIComponent(pair.slice(0, eqIndex)).toLowerCase(), + decodeURIComponent(pair.slice(eqIndex + 1)) + ) + } catch (error: unknown) { + // Malformed percent-encoding; skip the parameter. + } + } + + const memo = + params.get('dt') ?? + params.get('memo') ?? + params.get('tag') ?? + params.get('message') + const amountParam = params.get('amount') ?? params.get('tx_amount') + const displayAmount = + amountParam != null && displayAmountRegex.test(amountParam.trim()) + ? amountParam.trim() + : undefined + + // The naked address: no scheme or `//`, and without EIP-681's optional + // `pay-` prefix and `@chainId` suffix: + let bareAddress = path.replace(/^\/\//, '') + if (bareAddress.startsWith('pay-')) bareAddress = bareAddress.slice(4) + const atIndex = bareAddress.indexOf('@') + let evmChainId: string | undefined + let isFunctionCall = bareAddress.includes('/') + if (atIndex >= 0) { + const suffix = bareAddress.slice(atIndex + 1) + // EIP-681 allows a function-call suffix after the chain id; only the + // leading digits name the chain. + const chainIdMatch = /^\d+/.exec(suffix) + if (chainIdMatch != null) evmChainId = chainIdMatch[0] + if (suffix.includes('/')) isFunctionCall = true + bareAddress = bareAddress.slice(0, atIndex) + } + + // An EIP-681 function call (`ethereum:@1/transfer?address=`) + // puts the TOKEN CONTRACT in the path and the payee in a parameter, so the + // path address is the one thing that must not be offered as a destination: + // adopting it would send the chain's native coin to a contract. No candidate + // is offered rather than reading the payee out, because a token destination + // is not something this flow can pay anyway, and an invalid-address refusal + // is the honest answer to a code the app cannot honor. + const addressCandidates: string[] = [] + if (!isFunctionCall) { + for (const candidate of [trimmed, `${scheme}:${path}`, bareAddress]) { + if (candidate === '' || addressCandidates.includes(candidate)) continue + addressCandidates.push(candidate) + } + } + + return { addressCandidates, displayAmount, scheme, evmChainId, memo } +} From 31043461bb72a439333f93bc71b3573900660642 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:23:57 -0700 Subject: [PATCH 06/18] Add Houdini destination-chain metadata and plugin registration HOUDINI_CHAINS snapshots Houdini's GET /chains intersected with Edge currency pluginIds, carrying each chain's address-validation regex and whether it needs a memo, and mirroring the edge-exchange-plugins chain mapping. Two of the provider's published regexes are corrected here rather than routed around: the Cardano pattern ends in an unanchored zero-length alternative that matches every string including the empty one, and the PIVX class writes A-z, which also spans the six punctuation characters between the alphabet halves. Register the houdini swap plugin through HOUDINI_INIT env config like every other provider. --- src/__tests__/util/houdiniChains.test.ts | 406 ++++++++++++++++++++ src/envConfig.ts | 6 + src/util/corePlugins.ts | 1 + src/util/houdiniChains.ts | 461 +++++++++++++++++++++++ 4 files changed, 874 insertions(+) create mode 100644 src/__tests__/util/houdiniChains.test.ts create mode 100644 src/util/houdiniChains.ts diff --git a/src/__tests__/util/houdiniChains.test.ts b/src/__tests__/util/houdiniChains.test.ts new file mode 100644 index 00000000000..b119bd5aa83 --- /dev/null +++ b/src/__tests__/util/houdiniChains.test.ts @@ -0,0 +1,406 @@ +import { describe, expect, it } from '@jest/globals' +import { lt } from 'biggystring' + +import { + detectHoudiniChains, + getHoudiniChain, + HOUDINI_CHAINS, + HOUDINI_MIN_USD, + isValidHoudiniAddress, + schemeNamesChain +} from '../../util/houdiniChains' + +// Real mainnet-format addresses for the chains the send scene offers: +const ADDRESSES = { + bitcoin: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', + bitcoinLegacy: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + ethereum: '0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e', + litecoin: 'MQMcJhpWHYVeQArcZR3sBgyPZxxRtnH441', + solana: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + dogecoin: 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L' +} + +const supportAll = (): boolean => true + +describe('detectHoudiniChains', () => { + it('detects the chain a bare address belongs to', () => { + const found = detectHoudiniChains(ADDRESSES.litecoin, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('litecoin') + }) + + it('returns every EVM chain for a bare 0x address', () => { + const found = detectHoudiniChains(ADDRESSES.ethereum, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + const pluginIds = found.map(chain => chain.pluginId) + expect(pluginIds).toContain('ethereum') + expect(pluginIds).toContain('polygon') + expect(pluginIds.length).toBeGreaterThan(2) + }) + + it('resolves an ambiguous address outright when the URI names the chain', () => { + const found = detectHoudiniChains(`ethereum:${ADDRESSES.ethereum}`, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toEqual(['ethereum']) + }) + + it('honors a URI scheme that differs from the plugin id', () => { + const found = detectHoudiniChains(`polygon:${ADDRESSES.ethereum}`, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toEqual(['polygon']) + }) + + it('carries the amount through to the caller-visible candidates', () => { + const found = detectHoudiniChains( + `ethereum:${ADDRESSES.ethereum}?amount=0.007`, + { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + } + ) + expect(found.map(chain => chain.pluginId)).toEqual(['ethereum']) + }) + + it('offers the source chain when the source asset is a token', () => { + // USDC on Ethereum paying out native ETH is a real cross-asset route, so a + // pasted Ethereum address must offer Ethereum. Excluding it unconditionally + // left the picker naming every OTHER EVM network and not the one the + // recipient actually holds. + const found = detectHoudiniChains(ADDRESSES.ethereum, { + sourcePluginId: 'ethereum', + sourceTokenId: 'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('ethereum') + }) + + it('never offers the sending wallet own chain as a destination', () => { + const found = detectHoudiniChains(ADDRESSES.bitcoin, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).not.toContain('bitcoin') + }) + + it('skips chains the account has no plugin for', () => { + const found = detectHoudiniChains(ADDRESSES.ethereum, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: pluginId => pluginId === 'polygon' + }) + expect(found.map(chain => chain.pluginId)).toEqual(['polygon']) + }) + + it('detects Solana, whose format overlaps no EVM chain', () => { + const found = detectHoudiniChains(ADDRESSES.solana, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('solana') + }) + + it('detects Bitcoin from a Litecoin wallet', () => { + const found = detectHoudiniChains(ADDRESSES.bitcoin, { + sourcePluginId: 'litecoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('bitcoin') + }) + + it('detects a legacy Bitcoin address', () => { + const found = detectHoudiniChains(ADDRESSES.bitcoinLegacy, { + sourcePluginId: 'litecoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('bitcoin') + }) + + it('detects Dogecoin', () => { + const found = detectHoudiniChains(ADDRESSES.dogecoin, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('dogecoin') + }) + + it('returns nothing for input that addresses no served chain', () => { + const found = detectHoudiniChains('not an address', { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found).toEqual([]) + }) + + it('falls back to format matching when the scheme is unknown', () => { + const found = detectHoudiniChains(`madeupchain:${ADDRESSES.litecoin}`, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('litecoin') + }) + + it('ignores a scheme whose address does not validate on that chain', () => { + // A mislabeled URI must not be trusted into sending to the wrong chain: + const found = detectHoudiniChains(`ethereum:${ADDRESSES.litecoin}`, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + const pluginIds = found.map(chain => chain.pluginId) + expect(pluginIds).toContain('litecoin') + expect(pluginIds).not.toContain('ethereum') + }) + + it('rejects a Cardano regex catch-all that would accept any text', () => { + // Houdini's published Cardano regex matches every string; detection is + // meaningless unless that is corrected. + const found = detectHoudiniChains('hello world', { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: pluginId => pluginId === 'cardano' + }) + expect(found).toEqual([]) + }) + + it('resolves an EIP-681 chain id to that chain, not to the scheme', () => { + // Every EVM network's payment code writes `ethereum:`, so reading the + // scheme alone sent a Polygon code to Ethereum mainnet. + const found = detectHoudiniChains(`ethereum:${ADDRESSES.ethereum}@137`, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: () => true + }) + expect(found.map(chain => chain.pluginId)).toEqual(['polygon']) + }) + + it('resolves nothing for a chain id no served chain claims', () => { + // Falling back to the scheme here would pay Ethereum for a code that named + // some other network, which is the misdirection the chain id exists to stop. + const found = detectHoudiniChains(`ethereum:${ADDRESSES.ethereum}@999999`, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: () => true + }) + expect(found).toEqual([]) + }) + + it('does not offer Solana for a legacy UTXO address', () => { + // Solana's published pattern reaches down to 32 base58 characters, which is + // the band the Bitcoin-family legacy forms sit in, so a Litecoin address + // offered Solana as a network to pay. + const found = detectHoudiniChains(ADDRESSES.litecoin, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: pluginId => pluginId === 'solana' + }) + expect(found).toEqual([]) + }) + + it('does not offer eCash for an EVM address', () => { + // Houdini's published eCash regex spells the prefix-less cashaddr form as + // `[0-9A-Za-z]{42}`, which is exactly the shape of an `0x` EVM address, so + // every EVM paste offered eCash as a candidate network to pay. + const found = detectHoudiniChains( + '0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e', + { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: pluginId => pluginId === 'ecash' + } + ) + expect(found).toEqual([]) + }) + + it('still detects a real eCash address, prefixed or bare', () => { + const opts = { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: (pluginId: string) => pluginId === 'ecash' + } + const bare = detectHoudiniChains( + 'qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a', + opts + ) + const prefixed = detectHoudiniChains( + 'ecash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a', + opts + ) + expect(bare.map(chain => chain.pluginId)).toEqual(['ecash']) + expect(prefixed.map(chain => chain.pluginId)).toEqual(['ecash']) + }) +}) + +describe('getHoudiniChain', () => { + it('finds a served chain by its Edge plugin id', () => { + const chain = getHoudiniChain('litecoin', null) + expect(chain?.houdiniShortName).toEqual('litecoin') + }) + + it('returns nothing for a chain Houdini does not serve', () => { + expect(getHoudiniChain('piratechain', null)).toBeUndefined() + }) + + it('returns nothing for the chains with no mainnet native coin', () => { + // Houdini publishes no mainnet native for these, so a quote naming one can + // never be built. The plugin declines them at runtime either way; keeping + // them out of this table is about not OFFERING a destination the provider + // cannot pay out to. + for (const pluginId of ['celo', 'fantom', 'polkadot', 'ton']) { + expect(getHoudiniChain(pluginId, null)).toBeUndefined() + } + }) + + it('returns nothing for a token, even on a served chain', () => { + // Only chain-native assets are offered as destinations today. A token id + // must not silently resolve to its parent chain and pay out the wrong + // asset. + expect(getHoudiniChain('ethereum', 'a0b8...eb48')).toBeUndefined() + expect(getHoudiniChain('ethereum', null)).toBeDefined() + }) +}) + +describe('HOUDINI_CHAINS table', () => { + it('has no duplicate plugin ids', () => { + const pluginIds = HOUDINI_CHAINS.map(chain => chain.pluginId) + expect(new Set(pluginIds).size).toEqual(pluginIds.length) + }) + + it('has no duplicate Houdini chain names', () => { + const shortNames = HOUDINI_CHAINS.map(chain => chain.houdiniShortName) + expect(new Set(shortNames).size).toEqual(shortNames.length) + }) + + it('carries a same-asset private capability for every chain', () => { + // `hasSelfPrivate` decides whether the Stealth toggle can arm on a + // same-asset pick with no quote, so a missing value would read as false + // and silently remove the toggle. + for (const chain of HOUDINI_CHAINS) { + expect(typeof chain.hasSelfPrivate).toEqual('boolean') + } + }) + + it('rejects the empty string on every chain address regex', () => { + // An unanchored or zero-length alternative makes a regex match everything, + // which turns address detection into a coin flip about where funds go. + for (const chain of HOUDINI_CHAINS) { + expect(isValidHoudiniAddress(chain, '')).toEqual(false) + expect(isValidHoudiniAddress(chain, 'not an address at all')).toEqual( + false + ) + } + }) + + it('marks the memo chains that need a destination tag', () => { + const memoChains = HOUDINI_CHAINS.filter(chain => chain.memoNeeded).map( + chain => chain.pluginId + ) + expect(memoChains).toEqual( + expect.arrayContaining([ + 'cosmoshub', + 'hedera', + 'ripple', + 'stellar', + 'thorchainrune' + ]) + ) + expect(memoChains).not.toContain('bitcoin') + }) + + it('accepts a short Hedera account id', () => { + // Hedera ids are assigned sequentially, so the early ones are genuinely + // short. The provider's own pattern demands four digits and rejects them. + const hedera = getHoudiniChain('hedera', null) + expect(hedera).toBeDefined() + if (hedera == null) return + expect(isValidHoudiniAddress(hedera, '0.0.98')).toEqual(true) + expect(isValidHoudiniAddress(hedera, '0.0.1234567')).toEqual(true) + expect(isValidHoudiniAddress(hedera, '0X0Y12345')).toEqual(false) + }) + + it('accepts and rejects addresses on a chain that needs a memo', () => { + const ripple = getHoudiniChain('ripple', null) + expect(ripple).toBeDefined() + if (ripple == null) return + expect( + isValidHoudiniAddress(ripple, 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe') + ).toEqual(true) + expect(isValidHoudiniAddress(ripple, 'notanaddress')).toEqual(false) + }) + + it('trims surrounding whitespace before validating', () => { + const litecoin = getHoudiniChain('litecoin', null) + expect(litecoin).toBeDefined() + if (litecoin == null) return + expect( + isValidHoudiniAddress(litecoin, ' MQMcJhpWHYVeQArcZR3sBgyPZxxRtnH441 ') + ).toEqual(true) + }) +}) + +describe('HOUDINI_MIN_USD', () => { + it('orders the floors from the strictest route to the loosest', () => { + // Confirmed against the live API: a pair answers with no route at all + // below 10 USD, standard routes from 10 up, and private routes from 25. + expect(lt(HOUDINI_MIN_USD.dex, HOUDINI_MIN_USD.standard)).toEqual(true) + expect(lt(HOUDINI_MIN_USD.standard, HOUDINI_MIN_USD.private)).toEqual(true) + }) + + it('states the floors as biggystring-comparable decimal strings', () => { + // These are compared against a converted USD order value with `lt`, which + // needs plain decimal strings rather than numbers. + for (const floor of Object.values(HOUDINI_MIN_USD)) { + expect(typeof floor).toEqual('string') + expect(floor).toMatch(/^[0-9]+(\.[0-9]+)?$/) + } + }) + + it('holds the values Houdini published', () => { + expect(HOUDINI_MIN_USD).toEqual({ + private: '25', + standard: '10', + dex: '5' + }) + }) +}) + +describe('schemeNamesChain', () => { + const getChain = (pluginId: string): (typeof HOUDINI_CHAINS)[number] => { + const chain = HOUDINI_CHAINS.find(entry => entry.pluginId === pluginId) + if (chain == null) throw new Error(`no ${pluginId} in HOUDINI_CHAINS`) + return chain + } + + it('matches a scheme naming the chain, by plugin id or provider name', () => { + expect(schemeNamesChain('ethereum', getChain('ethereum'))).toEqual(true) + expect(schemeNamesChain('ETHEREUM', getChain('ethereum'))).toEqual(true) + expect(schemeNamesChain('litecoin', getChain('litecoin'))).toEqual(true) + }) + + it('rejects a scheme naming a different EVM chain', () => { + // The case that made an `ethereum:` code payable on a picked Polygon + // destination: the two share an address format, so the address alone + // cannot tell them apart and only the scheme can. + expect(schemeNamesChain('ethereum', getChain('polygon'))).toEqual(false) + expect(schemeNamesChain('polygon', getChain('ethereum'))).toEqual(false) + }) +}) diff --git a/src/envConfig.ts b/src/envConfig.ts index e590a27b17d..8e63aec74de 100644 --- a/src/envConfig.ts +++ b/src/envConfig.ts @@ -341,6 +341,12 @@ export const asEnvConfig = asObject({ ), HOLESKY_INIT: asCorePluginInit(asEvmApiKeys), HEDERA_INIT: asOptional(asBoolean, true), + HOUDINI_INIT: asCorePluginInit( + asObject({ + apiKey: asOptional(asString, ''), + apiSecret: asOptional(asString, '') + }).withRest + ), HYPEREVM_INIT: asCorePluginInit(asEvmApiKeys), LIBERLAND_INIT: asOptional(asBoolean, true), LIFI_INIT: asCorePluginInit( diff --git a/src/util/corePlugins.ts b/src/util/corePlugins.ts index 55c017dad00..66da9ed1d75 100644 --- a/src/util/corePlugins.ts +++ b/src/util/corePlugins.ts @@ -95,6 +95,7 @@ export const swapPlugins = { changelly: ENV.CHANGELLY_INIT, exolix: ENV.EXOLIX_INIT, godex: ENV.GODEX_INIT, + houdini: ENV.HOUDINI_INIT, lifi: ENV.LIFI_INIT, letsexchange: ENV.LETSEXCHANGE_INIT, nexchange: ENV.NEXCHANGE_INIT, diff --git a/src/util/houdiniChains.ts b/src/util/houdiniChains.ts new file mode 100644 index 00000000000..1e982bd4aa5 --- /dev/null +++ b/src/util/houdiniChains.ts @@ -0,0 +1,461 @@ +import type { EdgeTokenId } from 'edge-core-js' + +import { parsePaymentUri } from './paymentUri' + +/** + * A destination chain HoudiniSwap can pay out to, keyed by the Edge currency + * pluginId. `addressValidation` is Houdini's own per-chain regex, reused for + * client-side validation of pasted destination addresses. `memoNeeded` chains + * show a destination-tag row whose value rides `toAddressInfo.toMemos` to the + * plugin and onward as `destinationTag` on order creation. + */ +export interface HoudiniChain { + pluginId: string + houdiniShortName: string + memoNeeded: boolean + /** + * Whether Houdini can route this asset to ITSELF privately, from the + * `hasSelfPrivate` flag on their token query. Same-asset private is their + * dominant flow, and it is an asset capability rather than a per-pair + * verdict, so it reads off the token metadata with no quote and no probing. + */ + hasSelfPrivate: boolean + + /** + * The chain's EVM network id, for the chains that have one. A payment code + * for any EVM network is written `ethereum:
@`, so the + * scheme names only the family and this is what names the chain. Absent for + * everything that is not an EVM network, which is why a chain id matching + * nothing here resolves to nothing rather than to Ethereum. + * + * Values come from each chain's own currency plugin (`chainParams.chainId`), + * which the GUI cannot read at runtime: `EdgeCurrencyInfo.defaultSettings` is + * deprecated and always empty. + */ + evmChainId?: number + addressValidation: RegExp +} + +/** + * Snapshot of Houdini's mainnet native tokens (v2 partner API, re-fetched + * 2026-07-30) intersected with Edge's currency pluginIds, mirroring the + * edge-exchange-plugins Houdini chain mapping. IBC-family chains are excluded + * there (no trustworthy memo metadata), so they are absent here too. + * + * Every chain listed here resolves to a native token Houdini actually serves. + * `celo`, `fantom`, `polkadot` and `ton` were listed before and are not: the + * API returns no mainnet native for them, so every quote to those chains threw + * while the UI offered them as destinations. + * + * This is a snapshot on purpose. Houdini is an aggregator whose per-pair + * availability fluctuates too fast to track and whose Cloudflare blocks tight + * probing loops, so nothing here may be discovered at runtime: asset-level + * capability lives in this table, and pair-level capability is learned only + * from a real user-initiated quote (`pairCaps`). A follow-up can refresh the + * table from the API once chain metadata is exposed through the swap plugin. + */ +export const HOUDINI_CHAINS: HoudiniChain[] = [ + { + pluginId: 'algorand', + houdiniShortName: 'algorand', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^[A-Z0-9]{58,58}$/ + }, + { + pluginId: 'arbitrum', + houdiniShortName: 'arbitrum', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 42161, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'avalanche', + houdiniShortName: 'avalanche', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 43114, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'base', + houdiniShortName: 'base', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 8453, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'binancesmartchain', + houdiniShortName: 'bsc', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 56, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'bitcoin', + houdiniShortName: 'bitcoin', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: + /^([13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-z0-9]{39}|bc1[a-z0-9]{59})$/ + }, + { + pluginId: 'bitcoincash', + houdiniShortName: 'bitcoincash', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: + /^([13][a-km-zA-HJ-NP-Z1-9]{25,34})$|^((bitcoincash:)?(q|p)[a-z0-9]{41})$|^((BITCOINCASH:)?(Q|P)[A-Z0-9]{41})$/ + }, + { + pluginId: 'bitcoinsv', + houdiniShortName: 'bsv', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/ + }, + { + pluginId: 'cardano', + houdiniShortName: 'cardano', + memoNeeded: false, + hasSelfPrivate: true, + // Houdini's own regex ends in `|^[a-zA-z0-9]*|[0-9A-Za-z]{45,65}$`, whose + // first alternative is unanchored and zero-length and so matches EVERY + // string, including empty. Those two catch-alls are dropped here: they + // would accept any typo as a Cardano address, and they make any pasted + // address look like it could be paying Cardano. + addressValidation: + /^([1-9A-HJ-NP-Za-km-z]{59}|[0-9A-Za-z]{100,104}|[0-9a-fA-F]{64}|addr[0-9A-Za-z]{45,65})$/ + }, + { + pluginId: 'cosmoshub', + houdiniShortName: 'cosmoshub-4', + memoNeeded: true, + hasSelfPrivate: true, + addressValidation: /^(cosmos1)[0-9a-z]{38}$/ + }, + { + pluginId: 'dash', + houdiniShortName: 'dash', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^[X|7][0-9A-Za-z]{33}$/ + }, + { + pluginId: 'dogecoin', + houdiniShortName: 'doge', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^(D|A|9)[a-km-zA-HJ-NP-Z1-9]{33,34}$/ + }, + { + pluginId: 'ecash', + houdiniShortName: 'eCash', + memoNeeded: false, + hasSelfPrivate: true, + // The provider's published pattern spells the prefix-less cashaddr forms as + // `[0-9A-Za-z]`, which matches EVERY 42-character alphanumeric string and so + // claims every `0x` EVM address as a candidate eCash destination. Narrowed + // to the bech32 charset cashaddr actually uses (lowercase, no `1bio`) with + // its `q`/`p` type prefix, which is strictly narrowing and leaves real eCash + // addresses matching. Same class of defect as the Cardano catch-all above. + addressValidation: + /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$|^[qp][qpzry9x8gf2tvdw0s3jn54khce6mua7l]{41}$|^ecash:[qp][qpzry9x8gf2tvdw0s3jn54khce6mua7l]{29,69}$/ + }, + { + pluginId: 'ethereum', + houdiniShortName: 'ethereum', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 1, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'hedera', + houdiniShortName: 'hedera', + memoNeeded: true, + hasSelfPrivate: true, + // Dots escaped: unescaped they are wildcards, so the pattern accepted + // anything shaped like 0X0Y12345 as a Hedera account id. The length bound + // is 1 and not the provider's 4: account ids are assigned sequentially, so + // early ones are genuinely short (0.0.98) and a four-digit floor rejects + // real destinations. + addressValidation: /^0\.0\.[0-9]{1,20}$/ + }, + { + pluginId: 'hyperevm', + houdiniShortName: 'hyperevm', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 999, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'litecoin', + houdiniShortName: 'litecoin', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^(L|M|3)[A-Za-z0-9]{33}$|^(ltc1)[0-9A-Za-z]{39}$/ + }, + { + pluginId: 'monero', + houdiniShortName: 'monero', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^[48][a-zA-Z|\d]{94}([a-zA-Z|\d]{11})?$/ + }, + { + pluginId: 'opbnb', + houdiniShortName: 'opbnb', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 204, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'optimism', + houdiniShortName: 'optimism', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 10, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'pivx', + houdiniShortName: 'pivx', + memoNeeded: false, + hasSelfPrivate: true, + // Houdini writes `[0-9A-za-z]`, where `A-z` also spans `[ \ ] ^ _ \``. + // PIVX addresses are base58, so the strict class is used instead. + addressValidation: /^D[1-9A-HJ-NP-Za-km-z]{33}$/ + }, + { + pluginId: 'polygon', + houdiniShortName: 'polygon', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 137, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'pulsechain', + houdiniShortName: 'pulsechain', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 369, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'ripple', + houdiniShortName: 'ripple', + memoNeeded: true, + hasSelfPrivate: true, + addressValidation: /^r[1-9A-HJ-NP-Za-km-z]{25,34}$/ + }, + { + pluginId: 'rsk', + houdiniShortName: 'rootstock', + memoNeeded: false, + hasSelfPrivate: false, + evmChainId: 30, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'solana', + houdiniShortName: 'solana', + memoNeeded: false, + hasSelfPrivate: true, + // The provider's published lower bound of 32 reaches down into the length + // band the Bitcoin-family legacy forms occupy (P2PKH and P2SH are 33-34 + // base58 characters, as are Dogecoin, Litecoin and PIVX), so a legacy UTXO + // address offered Solana as a destination it is not an account on. A Solana + // address is a base58 32-byte ed25519 public key, which is 43-44 characters + // and 42 only for a value small enough to be vanishingly unlikely, so the + // floor moves to 42: strictly narrowing, and clear of every UTXO form. + addressValidation: /^[1-9A-HJ-NP-Za-km-z]{42,44}$/ + }, + { + pluginId: 'sonic', + houdiniShortName: 'sonic', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 146, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'stellar', + houdiniShortName: 'xlm', + memoNeeded: true, + hasSelfPrivate: true, + addressValidation: /^G[A-D]{1}[A-Z2-7]{54}$/ + }, + { + pluginId: 'sui', + houdiniShortName: 'sui', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^(0x)[0-9A-Za-z]{64}$/ + }, + { + pluginId: 'telos', + houdiniShortName: 'telos', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'thorchainrune', + houdiniShortName: 'thorchain', + memoNeeded: true, + hasSelfPrivate: true, + addressValidation: /^(thor1)[0-9a-z]{38}$/ + }, + { + pluginId: 'tron', + houdiniShortName: 'tron', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^T[1-9A-HJ-NP-Za-km-z]{33}$/ + }, + { + pluginId: 'zcash', + houdiniShortName: 'Zcash', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^t1[1-9A-HJ-NP-Za-km-z]{33}$/ + }, + { + pluginId: 'zksync', + houdiniShortName: 'zksync-era', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 324, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + } +] + +/** + * Houdini's own minimum order sizes, in USD. Their guidance, verbatim: "on your + * side you should stick to our hardcoded minimums that under 25 USD there are + * no routes for private." + * + * Confirmed against the live v2 API on 2026-07-30 rather than taken on faith. + * Cross-asset TRX to LTC answered 422 "Amount is too low, minimum is 10 USD" at + * 8 USD, returned standard routes only from 12 to 24 USD, and added private + * routes from 25 USD up. Same-asset TRX to TRX answered 422 "Amount is too low, + * minimum is 25 USD" below 25 and returned private routes only above it. + * + * These are floors, not the whole story: individual tokens carry higher + * server-side minimums that cannot be known upfront (Polygon private is + * effectively 60 USD), which arrive as quote errors carrying the real minimum. + */ +export const HOUDINI_MIN_USD = { + /** Private (multi-exchange) routes, which every stealth flow requires. */ + private: '25', + /** Standard (single-exchange) routes, used by a plain swap-and-send. */ + standard: '10', + /** On-chain DEX routes, offered only for assets with `hasDex`. */ + dex: '5' +} as const + +/** Look up the Houdini destination chain for an Edge asset, if served. */ +export function getHoudiniChain( + pluginId: string, + tokenId: EdgeTokenId +): HoudiniChain | undefined { + // Only native (chain) assets are offered as destinations for now: + if (tokenId != null) return undefined + return HOUDINI_CHAINS.find(chain => chain.pluginId === pluginId) +} + +/** Validate a pasted destination address against the chain's own regex. */ +export function isValidHoudiniAddress( + chain: HoudiniChain, + address: string +): boolean { + return chain.addressValidation.test(address.trim()) +} + +/** + * Find the destination chains a pasted string could be paying, for input the + * source wallet itself could not parse. An address belonging to another chain + * is not a typo: it is a cross-chain send whose recipient asset the user has + * not picked yet, so the caller can offer the swap instead of an error. + * + * An explicit URI scheme (`ethereum:0x...`) names the chain outright and wins. + * A bare address is matched against each served chain's own regex, which is + * ambiguous by construction for the EVM family, so every match is returned for + * the caller to disambiguate rather than guessing and misdirecting funds. + */ +export function detectHoudiniChains( + text: string, + opts: { + /** The sending wallet's chain. */ + sourcePluginId: string + /** + * The sending wallet's token, or `null` for the chain's own coin. + * + * The source chain is only excluded from the candidates when the source IS + * that chain's coin, because there the destination would be the same asset + * the user is already sending and the plain send path covers it. From a + * TOKEN the source chain is a real destination: USDC on Ethereum paying out + * native ETH is a cross-asset route no plain send can make, and dropping it + * left a pasted `0x` address offering every OTHER EVM network but not the + * one the recipient actually holds. + */ + sourceTokenId: string | null + /** Whether the account has a currency plugin for this chain. */ + isSupported: (pluginId: string) => boolean + } +): HoudiniChain[] { + const { sourcePluginId, sourceTokenId, isSupported } = opts + const { addressCandidates, scheme, evmChainId } = parsePaymentUri(text) + + const served = HOUDINI_CHAINS.filter( + chain => + (chain.pluginId !== sourcePluginId || sourceTokenId != null) && + isSupported(chain.pluginId) + ) + const matchesAddress = (chain: HoudiniChain): boolean => + addressCandidates.some(candidate => isValidHoudiniAddress(chain, candidate)) + + // An EIP-681 chain id names the network outright, and it has to be read + // BEFORE the scheme: every EVM network writes `ethereum:`, so the scheme + // alone would resolve a Polygon or Base code to Ethereum mainnet. A chain id + // that matches nothing served resolves to nothing rather than falling back to + // the scheme, since guessing here picks the wrong chain to pay. + if (evmChainId != null) { + const wanted = Number(evmChainId) + const named = served.find(chain => chain.evmChainId === wanted) + return named != null && matchesAddress(named) ? [named] : [] + } + + if (scheme != null) { + const named = served.find(chain => schemeNamesChain(scheme, chain)) + if (named != null && matchesAddress(named)) return [named] + } + + return served.filter(matchesAddress) +} + +/** + * Whether a payment URI's scheme names this chain, by either the Edge plugin id + * or the provider's own chain name (`ethereum:`, `bitcoincash:`). + * + * A scheme is the one part of a scanned code that states its chain outright, so + * it has to be checked even when a destination is already picked: chains that + * share an address format (the whole EVM family) validate each other's + * addresses, and accepting an `ethereum:` code against a picked Polygon + * destination would send to the wrong chain and price the URI's amount in the + * wrong asset. + */ +export function schemeNamesChain(scheme: string, chain: HoudiniChain): boolean { + const schemeLower = scheme.toLowerCase() + return ( + chain.pluginId === schemeLower || + chain.houdiniShortName.toLowerCase() === schemeLower + ) +} From 5749918392617758a8a40ceb50d4283fea7300e5 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:24:15 -0700 Subject: [PATCH 07/18] Restrict a send-shaped swap to the privacy provider Send-to-any-address is a privacy feature, so it never shops the destination address to other swap providers: every send-shaped quote request disables all providers except Houdini, stealth toggle on or off. The toggle instead decides what is asked for, setting privacy to required so the provider must offer a sender-unlinkable route rather than answer with a transparent one. The request also force-enables Houdini past the account's exchange settings, which govern swapping rather than sending. --- src/__tests__/util/stealthSwap.test.ts | 118 +++++++++++++++++++++++++ src/util/stealthSwap.ts | 77 +++++++++++++++- 2 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/util/stealthSwap.test.ts diff --git a/src/__tests__/util/stealthSwap.test.ts b/src/__tests__/util/stealthSwap.test.ts new file mode 100644 index 00000000000..a381cecfca2 --- /dev/null +++ b/src/__tests__/util/stealthSwap.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from '@jest/globals' +import type { EdgeAccount, EdgeTransaction } from 'edge-core-js' + +import { + hasParentFeeRow, + makeStealthSwapRequestOptions +} from '../../util/stealthSwap' + +// Only `swapConfig`'s key set is read, to find the plugins to switch off: +const fakeAccount = (swapPluginIds: string[]): EdgeAccount => { + const swapConfig: Record = {} + for (const pluginId of swapPluginIds) swapConfig[pluginId] = {} + return { swapConfig } as unknown as EdgeAccount +} + +const account = fakeAccount(['houdini', 'changenow', 'letsexchange', 'unizen']) + +describe('makeStealthSwapRequestOptions', () => { + it('disables every provider except Houdini', () => { + const { disabled } = makeStealthSwapRequestOptions(account) + expect(disabled).toEqual({ + changenow: true, + letsexchange: true, + unizen: true + }) + expect(disabled?.houdini).toBeUndefined() + }) + + it('clears a preferred provider that would fight the restriction', () => { + // A leftover `preferPluginId` cannot override `disabled`, but leaving it + // set makes the request self-contradictory and its intent unreadable. + const options = makeStealthSwapRequestOptions(account, { + preferPluginId: 'changenow', + preferType: 'CEX' + }) + expect(options.preferPluginId).toBeUndefined() + expect(options.preferType).toBeUndefined() + }) + + it('leaves the exchange setting alone by default', () => { + // The Exchange scene keeps honoring the user's provider settings, so a + // stealth swap started there must not force-enable anything. + const options = makeStealthSwapRequestOptions(account) + expect(options.forceEnabled).toBeUndefined() + }) + + it('force-enables Houdini when the caller ignores the provider setting', () => { + // The send scene's path: the swap setting governs which providers the + // aggregator may pick among, so it must not switch off a send feature that + // happens to be powered by one of them. + const options = makeStealthSwapRequestOptions(account, undefined, { + ignoreProviderSetting: true + }) + expect(options.forceEnabled).toEqual({ houdini: true }) + }) + + it('keeps a caller force-enabling other plugins', () => { + const options = makeStealthSwapRequestOptions( + account, + { forceEnabled: { changenow: true } }, + { ignoreProviderSetting: true } + ) + expect(options.forceEnabled).toEqual({ changenow: true, houdini: true }) + }) + + it('preserves unrelated options', () => { + const options = makeStealthSwapRequestOptions(account, { + promoCodes: { houdini: 'edge' }, + slowResponseMs: 1234 + }) + expect(options.promoCodes).toEqual({ houdini: 'edge' }) + expect(options.slowResponseMs).toEqual(1234) + }) + + it('keeps a caller own disabled entries alongside its own', () => { + // `disabled` wins over `forceEnabled` in the core, so a caller that + // disabled Houdini itself still gets no Houdini quote. + const options = makeStealthSwapRequestOptions( + account, + { disabled: { houdini: true } }, + { ignoreProviderSetting: true } + ) + expect(options.disabled?.houdini).toEqual(true) + }) + + it('handles an account with Houdini as its only provider', () => { + const { disabled } = makeStealthSwapRequestOptions(fakeAccount(['houdini'])) + expect(disabled).toEqual({}) + }) +}) + +describe('hasParentFeeRow', () => { + const makeTx = ( + tokenId: string | null, + networkFees: Array<{ tokenId: string | null; nativeAmount: string }> + ): EdgeTransaction => ({ tokenId, networkFees } as unknown as EdgeTransaction) + + it('reports a token send that paid its fee in the parent coin', () => { + const tx = makeTx('abcd', [ + { tokenId: 'abcd', nativeAmount: '0' }, + { tokenId: null, nativeAmount: '210000000000000' } + ]) + expect(hasParentFeeRow(tx)).toBe(true) + }) + + it('reports no fee row for a mainnet send', () => { + // A mainnet send's own fee is a `tokenId: null` entry too, so the token + // check has to come first. Stamping a `tokenId: null` action here would + // invent a parent-currency entry the swap plugin never filed. + const tx = makeTx(null, [{ tokenId: null, nativeAmount: '702' }]) + expect(hasParentFeeRow(tx)).toBe(false) + }) + + it('reports no fee row for a token send billed in the token itself', () => { + const tx = makeTx('abcd', [{ tokenId: 'abcd', nativeAmount: '1000' }]) + expect(hasParentFeeRow(tx)).toBe(false) + }) +}) diff --git a/src/util/stealthSwap.ts b/src/util/stealthSwap.ts index 9426bf71698..d1d34bb4dd9 100644 --- a/src/util/stealthSwap.ts +++ b/src/util/stealthSwap.ts @@ -1,4 +1,79 @@ -import type { EdgeCurrencyWallet, EdgeSwapRequest } from 'edge-core-js' +import type { + EdgeAccount, + EdgeCurrencyWallet, + EdgePluginMap, + EdgeSwapRequest, + EdgeSwapRequestOptions, + EdgeTransaction +} from 'edge-core-js' + +/** + * The swap provider that powers both Stealth flows. + * + * It is named once because two rules depend on it: the request restriction + * that keeps a stealth quote on this provider alone, and the + * transaction-details redaction that must fail CLOSED when the flow stamp did + * not persist. A provider-routed swap is privacy-routed by construction, so + * the pluginId is the durable half of that test. + */ +export const STEALTH_SWAP_PLUGIN_ID = 'houdini' + +interface StealthSwapFlags { + /** + * Query Houdini even when the user switched it off in their exchange + * settings. That setting governs which providers the swap aggregator may + * use, so it is the user's answer about swapping, not about sending: a send + * feature that happens to be powered by Houdini must not disappear because a + * swap provider was turned off. Set on the send scene only; the Exchange + * scene keeps honoring the setting. + */ + ignoreProviderSetting?: boolean +} + +/** + * Restricts a swap request to the Houdini privacy provider, for Stealth Swap + * and Stealth Send. Every other enabled swap provider is disabled for the + * request, and any preferred-provider override is cleared so it cannot fight + * the restriction. + */ +export function makeStealthSwapRequestOptions( + account: EdgeAccount, + opts: EdgeSwapRequestOptions = {}, + flags: StealthSwapFlags = {} +): EdgeSwapRequestOptions { + const disabled: EdgePluginMap = { ...opts.disabled } + for (const swapPluginId of Object.keys(account.swapConfig)) { + if (swapPluginId !== STEALTH_SWAP_PLUGIN_ID) disabled[swapPluginId] = true + } + return { + ...opts, + disabled, + forceEnabled: + flags.ignoreProviderSetting === true + ? { ...opts.forceEnabled, [STEALTH_SWAP_PLUGIN_ID]: true } + : opts.forceEnabled, + preferPluginId: undefined, + preferType: undefined + } +} + +/** + * Whether a broadcast swap-send also produced a parent-currency network-fee + * row. A token send pays its fee in the chain's own coin, so the swap plugin + * files a second action under `tokenId: null` alongside the token's. + * + * This mirrors the condition `makeSwapPluginQuote` writes that row under, and + * exists so the caller stamps a row the plugin really created rather than + * inventing a parent-currency entry for a mainnet send that has none. The + * plugin reads the deprecated `parentNetworkFee`, whose upgraded form is a + * `tokenId: null` entry in `networkFees` beside the token's own. + */ +export function hasParentFeeRow(tx: EdgeTransaction): boolean { + return ( + tx.tokenId != null && + tx.networkFees.some(networkFee => networkFee.tokenId == null) + ) +} /** * The destination wallet of a wallet-to-wallet swap request. From bc5b0388ca6d70d7bd787e7136e0990da648963a Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:24:33 -0700 Subject: [PATCH 08/18] Share the swap failure mapping between both swap flows ErrorCard renders anything that is not an I18nError as "Unexpected Error" with a canned body and a Report Error button, so a user under the provider's floor would be told nothing about the floor and an outage would look identical to a bug. The wallet-to-wallet flow already maps these properly, so that mapping moves out of SwapProcessingScene into swapErrorDisplay and both flows share it: the limit that was crossed and by how much, the pair that cannot route, the geo restriction, or the provider's own message for anything without a known shape. It takes a toCurrencyCode option because a send-to-address request carries no destination wallet to read a currency code from. --- src/__tests__/util/swapErrorDisplay.test.ts | 220 ++++++++++++++++++ src/components/scenes/SwapProcessingScene.tsx | 215 ++++------------- src/util/swapErrorDisplay.ts | 216 +++++++++++++++++ 3 files changed, 476 insertions(+), 175 deletions(-) create mode 100644 src/__tests__/util/swapErrorDisplay.test.ts create mode 100644 src/util/swapErrorDisplay.ts diff --git a/src/__tests__/util/swapErrorDisplay.test.ts b/src/__tests__/util/swapErrorDisplay.test.ts new file mode 100644 index 00000000000..7fa6319c360 --- /dev/null +++ b/src/__tests__/util/swapErrorDisplay.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from '@jest/globals' +import { + type EdgeCurrencyWallet, + type EdgeDenomination, + type EdgeSwapInfo, + type EdgeSwapRequest, + InsufficientFundsError, + SwapAboveLimitError, + SwapBelowLimitError, + SwapCurrencyError, + SwapPermissionError +} from 'edge-core-js' + +import { processSwapQuoteError } from '../../util/swapErrorDisplay' + +const swapInfo: EdgeSwapInfo = { + pluginId: 'houdini', + displayName: 'HoudiniSwap', + supportEmail: 'support@houdiniswap.com' +} + +const fakeWallet = ( + pluginId: string, + currencyCode: string +): EdgeCurrencyWallet => { + const currencyInfo = { pluginId, currencyCode } + return { + id: `${pluginId}-wallet`, + type: `wallet:${pluginId}`, + currencyInfo, + currencyConfig: { currencyInfo, allTokens: {} } + } as unknown as EdgeCurrencyWallet +} + +const tronWallet = fakeWallet('tron', 'TRX') +const litecoinWallet = fakeWallet('litecoin', 'LTC') + +const trxDenomination: EdgeDenomination = { + name: 'TRX', + multiplier: '1000000', + symbol: '' +} +const ltcDenomination: EdgeDenomination = { + name: 'LTC', + multiplier: '100000000', + symbol: 'Ł' +} + +/** + * A swap-to-address request, which is the shape the send scene builds: no + * destination wallet, a `toAddressInfo` descriptor in its place. + */ +const sendSceneRequest: EdgeSwapRequest = { + fromWallet: tronWallet, + fromTokenId: null, + toTokenId: null, + nativeAmount: '80000000', + quoteFor: 'from', + toAddressInfo: { + toPluginId: 'litecoin', + toAddress: 'MQMcJhpWHYVeQArcZR3sBgyPZxxRtnH441' + } +} as unknown as EdgeSwapRequest + +const describeError = ( + error: unknown, + toCurrencyCode?: string +): { title: string; message: string } | undefined => { + const info = processSwapQuoteError({ + error, + swapRequest: sendSceneRequest, + fromDenomination: trxDenomination, + toDenomination: ltcDenomination, + toCurrencyCode + }) + if (info == null) return undefined + return { title: info.title, message: info.message } +} + +describe('processSwapQuoteError', () => { + it('returns nothing for a missing error', () => { + expect(describeError(null)).toBeUndefined() + expect(describeError(undefined)).toBeUndefined() + }) + + it('names the minimum in the units of the side that was fixed', () => { + // The user is told the floor they missed, in their own send units, rather + // than a generic "unavailable". + const info = describeError( + new SwapBelowLimitError(swapInfo, '25000000', 'from') + ) + expect(info?.message).toContain('25') + expect(info?.message).toContain('TRX') + }) + + it('reads the receive denomination for a to-direction minimum', () => { + const info = describeError( + new SwapBelowLimitError(swapInfo, '50000000', 'to') + ) + expect(info?.message).toContain('0.5') + expect(info?.message).toContain('LTC') + }) + + it('falls back to a limit-free message when the minimum is zero', () => { + const info = describeError(new SwapBelowLimitError(swapInfo, '0', 'from')) + expect(info?.message).toContain('below the min limit') + expect(info?.message).not.toContain('TRX') + }) + + it('names the maximum for an above-limit error', () => { + const info = describeError( + new SwapAboveLimitError(swapInfo, '500000000', 'from') + ) + expect(info?.message).toContain('500') + expect(info?.message).toContain('TRX') + }) + + it('names both assets when the pair cannot route', () => { + // A swap-to-address request carries no destination wallet, so the caller + // supplies the payout currency code. Reading it off the request would name + // the SOURCE asset on both sides and tell the user TRX cannot reach TRX. + const info = describeError( + new SwapCurrencyError(swapInfo, sendSceneRequest), + 'LTC' + ) + expect(info?.message).toContain('TRX') + expect(info?.message).toContain('LTC') + }) + + it('does not claim the destination is the source when no code is supplied', () => { + const info = describeError( + new SwapCurrencyError(swapInfo, sendSceneRequest) + ) + // Without a supplied code there is only the source wallet to read, so the + // message degrades to naming it twice. That is the shape the caller must + // avoid by passing `toCurrencyCode`, and it is pinned here so a future + // change to the fallback is a deliberate one. + expect(info?.message).toContain('TRX') + }) + + it('reports insufficient funds from a real error instance', () => { + const info = describeError(new InsufficientFundsError({ tokenId: null })) + expect(info?.title).toEqual('Insufficient Funds') + }) + + it('reports insufficient funds from the stringified shape some plugins throw', () => { + const info = describeError(new Error('InsufficientFundsError')) + expect(info?.title).toEqual('Insufficient Funds') + }) + + it('maps pending transactions to the pending-funds message', () => { + const info = describeError(new Error('Unexpected pending transactions')) + expect(info?.title).toEqual('Insufficient Funds') + expect(info?.message).not.toEqual('Unexpected pending transactions') + }) + + it('reports a geographic restriction', () => { + const info = describeError( + new SwapPermissionError(swapInfo, 'geoRestriction') + ) + expect(info?.message).toContain('Location restricted') + }) + + it('passes a non-geographic permission error through to its own message', () => { + const info = describeError( + new SwapPermissionError(swapInfo, 'noVerification') + ) + expect(info?.title).toEqual('Exchange Error') + }) + + it('surfaces the provider own message for anything unrecognized', () => { + // Houdini's minimums above the shared floor arrive this way, carrying the + // real number, which beats any string we could substitute. + const info = describeError( + new Error('HoudiniSwap: Amount is too low, minimum is 60 USD') + ) + expect(info?.message).toEqual( + 'HoudiniSwap: Amount is too low, minimum is 60 USD' + ) + }) + + it('never surfaces a rate limit as an unavailable pair', () => { + // A 429 means the caller was too fast, not that the route is gone. The + // plugin phrases it, and this path must not rewrite it into a pair error. + const info = describeError( + new Error('HoudiniSwap: rate limit exceeded, please try again shortly') + ) + expect(info?.message).toContain('rate limit exceeded') + expect(info?.message).not.toContain('No enabled exchanges') + }) + + it('stringifies a thrown non-error', () => { + const info = describeError({ code: 500 }) + expect(info?.message).toEqual('{"code":500}') + }) + + it('keeps the original error for the caller to log', () => { + const thrown = new Error('boom') + const info = processSwapQuoteError({ + error: thrown, + swapRequest: sendSceneRequest, + fromDenomination: trxDenomination, + toDenomination: ltcDenomination + }) + expect(info?.error).toBe(thrown) + }) + + it('handles a wallet-to-wallet request with a real destination wallet', () => { + const info = processSwapQuoteError({ + error: new SwapCurrencyError(swapInfo, sendSceneRequest), + swapRequest: { + ...sendSceneRequest, + toWallet: litecoinWallet + } as unknown as EdgeSwapRequest, + fromDenomination: trxDenomination, + toDenomination: ltcDenomination + }) + expect(info?.message).toContain('LTC') + }) +}) diff --git a/src/components/scenes/SwapProcessingScene.tsx b/src/components/scenes/SwapProcessingScene.tsx index 3305f8a2458..d833cd58185 100644 --- a/src/components/scenes/SwapProcessingScene.tsx +++ b/src/components/scenes/SwapProcessingScene.tsx @@ -1,12 +1,6 @@ -import { captureException } from '@sentry/react-native' import { asMaybeInsufficientFundsError, - asMaybeSwapAboveLimitError, asMaybeSwapAddressError, - asMaybeSwapBelowLimitError, - asMaybeSwapCurrencyError, - asMaybeSwapPermissionError, - type EdgeDenomination, type EdgeSwapQuote, type EdgeSwapRequest, type EdgeSwapRequestOptions @@ -20,26 +14,40 @@ import { useSelector } from '../../types/reactRedux' import type { NavigationBase, SwapTabSceneProps } from '../../types/routerTypes' import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { getWalletName } from '../../util/CurrencyWalletHelpers' -import { convertNativeToDisplay, zeroString } from '../../util/utils' +import { requireDestinationWallet } from '../../util/stealthSwap' +import { processSwapQuoteError } from '../../util/swapErrorDisplay' import { ButtonsModal } from '../modals/ButtonsModal' import { showInsufficientFeesModal } from '../modals/InsufficientFeesModal' import { showPendingTxModal } from '../modals/PendingTxModal' import { CancellableProcessingScene } from '../progress-indicators/CancellableProcessingScene' import { Airship } from '../services/AirshipInstance' -import type { SwapErrorDisplayInfo } from './SwapCreateScene' export interface SwapProcessingParams { swapRequest: EdgeSwapRequest swapRequestOptions: EdgeSwapRequestOptions onCancel: () => void onDone: (quotes: EdgeSwapQuote[]) => void + /** + * First chance at a failed quote, before the scene's own handling. Return + * true when the error was handled (the caller navigated or recovered), so + * the generic error display is skipped. Lets the swap create scene react to + * capability failures, such as turning Stealth Swap off when the provider + * has no private route for the pair. + */ + onError?: (error: unknown) => boolean } type Props = SwapTabSceneProps<'swapProcessing'> export const SwapProcessingScene: React.FC = (props: Props) => { const { route, navigation } = props - const { swapRequest, swapRequestOptions, onCancel, onDone } = route.params + const { + swapRequest, + swapRequestOptions, + onCancel, + onDone, + onError: onErrorParam + } = route.params const account = useSelector(state => state.core.account) const countryCode = useSelector(state => state.ui.countryCode) @@ -49,20 +57,37 @@ export const SwapProcessingScene: React.FC = (props: Props) => { swapRequest.fromTokenId ) const toDenomination = useDisplayDenom( - swapRequest.toWallet.currencyConfig, + // Wallet-to-wallet swaps always have a destination wallet here; fall back to + // the source config only so this hook stays unconditional. + (swapRequest.toWallet ?? swapRequest.fromWallet).currencyConfig, swapRequest.toTokenId ) + // This scene only processes wallet-to-wallet swap requests, which always + // carry a destination wallet (swap-to-address has its own flow). + const toWallet = requireDestinationWallet(swapRequest) + const doWork = async (isCancelled: () => boolean): Promise => { const quotes = await account.fetchSwapQuotes( swapRequest, swapRequestOptions ) if (isCancelled()) return + if (quotes.length === 0) { + // fetchSwapQuotes usually throws when nothing can route, but it resolves + // empty when every plugin simply declines. Every onDone caller reads + // quotes[0], so hand this to the error path instead of the confirmation + // scene, which would dereference undefined. + throw new Error(lstrings.trade_option_no_quotes_body) + } onDone(quotes) } const onError = async (error: unknown): Promise => { + // The caller gets first chance, e.g. to degrade a capability toggle + // instead of showing the generic no-quotes error: + if (onErrorParam?.(error) === true) return + // Handle same-address requirement for swap flows requiring a split: const addressError = asMaybeSwapAddressError(error) if (addressError != null && addressError.reason === 'mustMatch') { @@ -70,7 +95,7 @@ export const SwapProcessingScene: React.FC = (props: Props) => { const fromWallet = swapRequest.fromWallet const fromAddresses = await fromWallet.getAddresses({ tokenId: null }) const fromAddress = fromAddresses[0]?.publicAddress - const targetPluginId = swapRequest.toWallet.currencyInfo.pluginId + const targetPluginId = toWallet.currencyInfo.pluginId let matchingWalletId: string | undefined for (const walletId of Object.keys(account.currencyWallets)) { @@ -89,8 +114,8 @@ export const SwapProcessingScene: React.FC = (props: Props) => { } } - let finalToWalletId: string = swapRequest.toWallet.id - let finalToWallet = swapRequest.toWallet + let finalToWalletId: string + let finalToWallet: typeof toWallet let isWalletCreated = false if (matchingWalletId == null) { // If not found, split from the source chain wallet to the destination @@ -165,7 +190,7 @@ export const SwapProcessingScene: React.FC = (props: Props) => { params: { fromWalletId: swapRequest.fromWallet.id, fromTokenId: swapRequest.fromTokenId, - toWalletId: swapRequest.toWallet.id, + toWalletId: toWallet.id, toTokenId: swapRequest.toTokenId } }) @@ -189,7 +214,7 @@ export const SwapProcessingScene: React.FC = (props: Props) => { params: { fromWalletId: swapRequest.fromWallet.id, fromTokenId: swapRequest.fromTokenId, - toWalletId: swapRequest.toWallet.id, + toWalletId: toWallet.id, toTokenId: swapRequest.toTokenId, errorDisplayInfo } @@ -219,163 +244,3 @@ export const SwapProcessingScene: React.FC = (props: Props) => { /> ) } - -function processSwapQuoteError({ - error, - swapRequest, - fromDenomination, - toDenomination -}: { - error: unknown - swapRequest: EdgeSwapRequest - fromDenomination: EdgeDenomination - toDenomination: EdgeDenomination -}): SwapErrorDisplayInfo | undefined { - // Basic sanity checks (should never fail): - if (error == null) return - - // Some plugins get the insufficient funds error wrong: - const errorMessage = - error instanceof Error ? error.message : JSON.stringify(error) - - // Track swap errors to sentry: - trackSwapError(error, swapRequest) - - // Check for known error types: - const insufficientFunds = asMaybeInsufficientFundsError(error) - if (insufficientFunds != null || errorMessage === 'InsufficientFundsError') { - return { - title: lstrings.exchange_insufficient_funds_title, - message: lstrings.exchange_insufficient_funds_message, - error - } - } - - if ( - error instanceof Error && - error.message === 'Unexpected pending transactions' - ) { - return { - title: lstrings.exchange_insufficient_funds_title, - message: lstrings.exchange_pending_funds_error, - error - } - } - - const aboveLimit = asMaybeSwapAboveLimitError(error) - if (aboveLimit != null) { - const currentCurrencyDenomination = - aboveLimit.direction === 'to' ? toDenomination : fromDenomination - - const { nativeMax } = aboveLimit - const nativeToDisplayRatio = currentCurrencyDenomination.multiplier - const displayMax = convertNativeToDisplay(nativeToDisplayRatio)(nativeMax) - - return { - title: lstrings.exchange_generic_error_title, - message: !zeroString(displayMax) - ? sprintf( - lstrings.amount_above_limit, - displayMax, - currentCurrencyDenomination.name - ) - : lstrings.no_amount_above_limit, - error - } - } - - const belowLimit = asMaybeSwapBelowLimitError(error) - if (belowLimit != null) { - const currentCurrencyDenomination = - belowLimit.direction === 'to' ? toDenomination : fromDenomination - - const { nativeMin } = belowLimit - const nativeToDisplayRatio = currentCurrencyDenomination.multiplier - const displayMin = convertNativeToDisplay(nativeToDisplayRatio)(nativeMin) - - return { - title: lstrings.exchange_generic_error_title, - message: !zeroString(displayMin) - ? sprintf( - lstrings.amount_below_limit, - displayMin, - currentCurrencyDenomination.name - ) - : lstrings.no_amount_below_limit, - error - } - } - - const currencyError = asMaybeSwapCurrencyError(error) - if (currencyError != null) { - const fromCurrencyCode = getCurrencyCode( - swapRequest.fromWallet, - swapRequest.fromTokenId - ) - const toCurrencyCode = getCurrencyCode( - swapRequest.toWallet, - swapRequest.toTokenId - ) - - return { - title: lstrings.exchange_generic_error_title, - message: sprintf(lstrings.ss_unable, fromCurrencyCode, toCurrencyCode), - error - } - } - - const permissionError = asMaybeSwapPermissionError(error) - if (permissionError?.reason === 'geoRestriction') { - return { - title: lstrings.exchange_generic_error_title, - message: lstrings.ss_geolock, - error - } - } - - // Anything else: - return { - title: lstrings.exchange_generic_error_title, - message: errorMessage, - error - } -} - -/** - * REVIEWER BEWARE!! - * - * No specific account/wallet information should be included within the - * scope for this capture. No personal information such as wallet IDs, - * public keys, or transaction details, amounts, should be collected - * according to Edge's company policy. - */ -function trackSwapError(error: unknown, swapRequest: EdgeSwapRequest): void { - captureException(error, scope => { - // This is a warning level error because it's expected to occur but not wanted. - scope.setLevel('warning') - // Searchable tags: - scope.setTags({ - errorType: 'swapQuoteFailure', - swapFromWalletKind: swapRequest.fromWallet.currencyInfo.pluginId, - swapFromCurrency: getCurrencyCode( - swapRequest.fromWallet, - swapRequest.fromTokenId - ), - swapToCurrency: getCurrencyCode( - swapRequest.toWallet, - swapRequest.toTokenId - ), - swapToWalletKind: swapRequest.toWallet.currencyInfo.pluginId, - swapDirectionType: swapRequest.quoteFor - }) - // Unsearchable context data: - scope.setContext('Swap Request Details', { - fromTokenId: String(swapRequest.fromTokenId), // Stringify to include "null" - fromWalletType: swapRequest.fromWallet.type, - toTokenId: String(swapRequest.toTokenId), // Stringify to include "null" - toWalletType: swapRequest.fromWallet.type, - quoteFor: swapRequest.quoteFor - }) - return scope - }) -} diff --git a/src/util/swapErrorDisplay.ts b/src/util/swapErrorDisplay.ts new file mode 100644 index 00000000000..d660603fc01 --- /dev/null +++ b/src/util/swapErrorDisplay.ts @@ -0,0 +1,216 @@ +import { captureException } from '@sentry/react-native' +import { + asMaybeInsufficientFundsError, + asMaybeSwapAboveLimitError, + asMaybeSwapBelowLimitError, + asMaybeSwapCurrencyError, + asMaybeSwapPermissionError, + type EdgeDenomination, + type EdgeSwapRequest +} from 'edge-core-js' +import { sprintf } from 'sprintf-js' + +import { lstrings } from '../locales/strings' +import { getCurrencyCode } from './CurrencyInfoHelpers' +import { convertNativeToDisplay, zeroString } from './utils' + +/** A swap failure, phrased for the user. */ +export interface SwapErrorDisplayInfo { + message: string + title: string + error: unknown +} + +interface ProcessSwapQuoteErrorOpts { + error: unknown + swapRequest: EdgeSwapRequest + fromDenomination: EdgeDenomination + toDenomination: EdgeDenomination + /** + * Destination currency code. A send-to-address request carries no + * destination wallet to read one from, so the caller supplies it. + */ + toCurrencyCode?: string +} + +/** + * Turn a failed swap quote into something worth showing a user: the provider's + * own message, the limit that was crossed, or the specific reason the pair is + * unavailable. Callers render the result rather than a catch-all string, so a + * user who is 0.1 LTC under the floor is told the floor. + */ +export function processSwapQuoteError({ + error, + swapRequest, + fromDenomination, + toDenomination, + toCurrencyCode +}: ProcessSwapQuoteErrorOpts): SwapErrorDisplayInfo | undefined { + // Basic sanity checks (should never fail): + if (error == null) return + + // Some plugins get the insufficient funds error wrong: + const errorMessage = + error instanceof Error ? error.message : JSON.stringify(error) + + // Track swap errors to sentry: + trackSwapError(error, swapRequest, toCurrencyCode) + + // Check for known error types: + const insufficientFunds = asMaybeInsufficientFundsError(error) + if (insufficientFunds != null || errorMessage === 'InsufficientFundsError') { + return { + title: lstrings.exchange_insufficient_funds_title, + message: lstrings.exchange_insufficient_funds_message, + error + } + } + + if ( + error instanceof Error && + error.message === 'Unexpected pending transactions' + ) { + return { + title: lstrings.exchange_insufficient_funds_title, + message: lstrings.exchange_pending_funds_error, + error + } + } + + const aboveLimit = asMaybeSwapAboveLimitError(error) + if (aboveLimit != null) { + const currentCurrencyDenomination = + aboveLimit.direction === 'to' ? toDenomination : fromDenomination + + const { nativeMax } = aboveLimit + const nativeToDisplayRatio = currentCurrencyDenomination.multiplier + const displayMax = convertNativeToDisplay(nativeToDisplayRatio)(nativeMax) + + return { + title: lstrings.exchange_generic_error_title, + message: !zeroString(displayMax) + ? sprintf( + lstrings.amount_above_limit, + displayMax, + currentCurrencyDenomination.name + ) + : lstrings.no_amount_above_limit, + error + } + } + + const belowLimit = asMaybeSwapBelowLimitError(error) + if (belowLimit != null) { + const currentCurrencyDenomination = + belowLimit.direction === 'to' ? toDenomination : fromDenomination + + const { nativeMin } = belowLimit + const nativeToDisplayRatio = currentCurrencyDenomination.multiplier + const displayMin = convertNativeToDisplay(nativeToDisplayRatio)(nativeMin) + + return { + title: lstrings.exchange_generic_error_title, + message: !zeroString(displayMin) + ? sprintf( + lstrings.amount_below_limit, + displayMin, + currentCurrencyDenomination.name + ) + : lstrings.no_amount_below_limit, + error + } + } + + const currencyError = asMaybeSwapCurrencyError(error) + if (currencyError != null) { + const fromCurrencyCode = getCurrencyCode( + swapRequest.fromWallet, + swapRequest.fromTokenId + ) + const toCode = + toCurrencyCode ?? + getCurrencyCode( + // Wallet-to-wallet swaps always have a destination wallet here; the + // fallback only keeps the type honest for swap-to-address requests. + swapRequest.toWallet ?? swapRequest.fromWallet, + swapRequest.toTokenId + ) + + return { + title: lstrings.exchange_generic_error_title, + message: sprintf(lstrings.ss_unable, fromCurrencyCode, toCode), + error + } + } + + const permissionError = asMaybeSwapPermissionError(error) + if (permissionError?.reason === 'geoRestriction') { + return { + title: lstrings.exchange_generic_error_title, + message: lstrings.ss_geolock, + error + } + } + + // Anything else. The provider's own message beats a catch-all string, since + // it is usually the only thing that says what actually went wrong: + return { + title: lstrings.exchange_generic_error_title, + message: errorMessage, + error + } +} + +/** + * Reports a swap error to Sentry, with searchable tags for the swap request + * according to Edge's company policy. + */ +function trackSwapError( + error: unknown, + swapRequest: EdgeSwapRequest, + toCurrencyCode?: string +): void { + // The destination, from whichever half of the request carries it. A + // send-to-address request has no `toWallet` at all, and falling back to the + // SOURCE wallet there tagged every stealth-send failure as if the swap had + // ended on the chain it started on, which is exactly the pair-specific + // triage these tags exist for. The descriptor names the destination chain, + // and the caller supplies the payout currency code. + const { toWallet, toAddressInfo } = swapRequest + const toWalletKind = + toWallet?.currencyInfo.pluginId ?? toAddressInfo?.toPluginId ?? 'unknown' + const toCurrency = + toWallet != null + ? getCurrencyCode(toWallet, swapRequest.toTokenId) + : toCurrencyCode ?? 'unknown' + + captureException(error, scope => { + // This is a warning level error because it's expected to occur but not wanted. + scope.setLevel('warning') + // Searchable tags: + scope.setTags({ + errorType: 'swapQuoteFailure', + swapFromWalletKind: swapRequest.fromWallet.currencyInfo.pluginId, + swapFromCurrency: getCurrencyCode( + swapRequest.fromWallet, + swapRequest.fromTokenId + ), + swapToCurrency: toCurrency, + swapToWalletKind: toWalletKind, + swapDirectionType: swapRequest.quoteFor + }) + // Unsearchable context data: + scope.setContext('Swap Request Details', { + fromTokenId: String(swapRequest.fromTokenId), // Stringify to include "null" + fromWalletType: swapRequest.fromWallet.type, + toTokenId: String(swapRequest.toTokenId), // Stringify to include "null" + // The destination's own wallet type, which a send-to-address request + // does not have. Naming the destination chain there is the honest + // answer; reading the SOURCE wallet claimed the swap ended where it + // started. + toWalletType: toWallet?.type ?? `address:${toWalletKind}`, + quoteFor: swapRequest.quoteFor + }) + return scope + }) +} From 35fdb06dcdbe74283f304f924e4f437997ac5409 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:26:24 -0700 Subject: [PATCH 09/18] Group a chosen set of assets at the top of the wallet picker A picker whose list spans several assets may want one of them first. WalletList and WalletListModal take an opt-in pinnedAssets filter with its own section titles: matching rows render first, everything else follows. Callers that omit it keep today's recent-then-all ordering, and searching stays flat as it already does for every other caller. --- eslint.config.mjs | 1 - .../WalletListModal.test.tsx.snap | 7 +-- src/components/modals/WalletListModal.tsx | 16 +++++++ src/components/themed/WalletList.tsx | 44 +++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index b0fe84c25b3..7e5db8a44fb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -381,7 +381,6 @@ export default [ 'src/components/themed/TransactionListComponents.tsx', 'src/components/themed/VectorIcon.tsx', - 'src/components/themed/WalletList.tsx', 'src/components/themed/WalletListErrorRow.tsx', 'src/components/themed/WalletListHeader.tsx', diff --git a/src/__tests__/modals/__snapshots__/WalletListModal.test.tsx.snap b/src/__tests__/modals/__snapshots__/WalletListModal.test.tsx.snap index 25d2c135ddc..3319626903a 100644 --- a/src/__tests__/modals/__snapshots__/WalletListModal.test.tsx.snap +++ b/src/__tests__/modals/__snapshots__/WalletListModal.test.tsx.snap @@ -319,6 +319,7 @@ exports[`WalletListModal should render with loading props 1`] = ` }, ] } + testID="walletPickerSearch" > = props => { excludeAssets, excludeWalletIds, filterActivation, + pinnedAssets, + pinnedTitle, + otherTitle, // Visuals: createWalletId, @@ -289,6 +301,7 @@ export const WalletListModal: React.FC = props => { aroundRem={0.5} returnKeyType="search" placeholder={lstrings.search_wallets} + testID="walletPickerSearch" onChangeText={setSearchText} onClear={handleSearchClear} value={searchText} @@ -309,6 +322,9 @@ export const WalletListModal: React.FC = props => { excludeAssets={walletListExcludeAssets} excludeWalletIds={excludeWalletIds} filterActivation={filterActivation} + pinnedAssets={pinnedAssets} + pinnedTitle={pinnedTitle} + otherTitle={otherTitle} searchText={searchText} showCreateWallet={showCreateWallet} createWalletId={createWalletId} diff --git a/src/components/themed/WalletList.tsx b/src/components/themed/WalletList.tsx index e9f5852858c..35b9658c9bc 100644 --- a/src/components/themed/WalletList.tsx +++ b/src/components/themed/WalletList.tsx @@ -33,6 +33,15 @@ interface Props { excludeWalletIds?: string[] filterActivation?: boolean + /** + * Opt-in grouping: assets matching this filter render first, under + * `pinnedTitle`, and everything else follows under `otherTitle`. Callers that + * omit it keep the default recent/all ordering untouched. + */ + pinnedAssets?: EdgeAsset[] + pinnedTitle?: string + otherTitle?: string + // Visuals: searchText: string showCreateWallet?: boolean @@ -58,6 +67,9 @@ export const WalletList: React.FC = (props: Props) => { excludeAssets, excludeWalletIds, filterActivation, + pinnedAssets, + pinnedTitle, + otherTitle, // Visuals: searchText, @@ -208,6 +220,35 @@ export const WalletList: React.FC = (props: Props) => { // Show the create-wallet list, filtered by the search term: walletItems.push(...createWalletList) + // Opt-in pinned grouping wins over the recent/all split: a caller that + // asks for it wants its own assets first, not the most-recently-used ones. + // Searching stays flat, as it does for every other caller. + if (pinnedAssets != null && searchText.length === 0) { + const pinned: Array = [] + const rest: Array = [] + for (const item of walletItems) { + const isPinned = + item.type === 'asset' && + checkAssetFilter( + { + pluginId: item.wallet.currencyInfo.pluginId, + tokenId: item.tokenId + }, + pinnedAssets, + undefined + ) + if (isPinned) pinned.push(item) + else rest.push(item) + } + if (pinned.length === 0 || rest.length === 0) return walletItems + return [ + ...(pinnedTitle == null ? [] : [pinnedTitle]), + ...pinned, + ...(otherTitle == null ? [] : [otherTitle]), + ...rest + ] + } + // Show a flat list if we are searching, or have no recent wallets: if (searchText.length > 0 || recentWalletList.length === 0) { return walletItems @@ -238,7 +279,10 @@ export const WalletList: React.FC = (props: Props) => { }, [ createWalletList, filteredWalletList, + otherTitle, parentWalletSection, + pinnedAssets, + pinnedTitle, recentWalletList, searchText ]) From 67b8bfab967075c6221a84c97d0d5f08ea9531b8 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:26:48 -0700 Subject: [PATCH 10/18] Validate a recipient address against the destination chain A cross-chain destination address cannot go through the source wallet's parseUri, which reads it as an invalid address for its own chain. AddressTile2 takes a crossChainAddressValidation override so a caller that knows the destination chain can validate against that chain's own rules instead, covering Paste, Enter address, and Scan through the one changeAddress path they share. --- .../__snapshots__/SendScene2.ui.test.tsx.snap | 3 + src/components/tiles/AddressTile2.tsx | 158 +++++++++++++++++- src/locales/en_US.ts | 2 + src/locales/strings/enUS.json | 2 + 4 files changed, 157 insertions(+), 8 deletions(-) diff --git a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap index d50f1d07082..f5915ca2a1d 100644 --- a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap @@ -16087,6 +16087,7 @@ exports[`SendScene2 Render SendScene 1`] = ` "opacity": 1, } } + testID="addressTileEnter" > boolean + /** + * Last resort for input this tile could not resolve on its own chain (or on + * the currently-picked destination chain). An address for another chain is + * usually a cross-chain send whose recipient asset has not been picked yet, + * so the consumer gets a chance to detect that chain and adopt the address. + * Return true when it took ownership, false to show the invalid-address + * error as before. + */ + onUnparsedAddress?: ( + address: string, + addressEntryMethod: AddressEntryMethod + ) => Promise + /** + * Opt-in expansion of the "Myself" picker past the source asset. The caller + * supplies the destination assets this send can route to, derived from route + * metadata rather than any hardcoded asset shape, and adopts a cross-asset + * pick through `onPickCrossAsset`. Same-asset wallets pin to the top of the + * modal. Omitting this keeps the source-asset-only picker every other caller + * gets. + */ + selfTransfer?: { + allowedAssets: EdgeAsset[] + onPickCrossAsset: (pluginId: string, address: string) => Promise + } navigation: NavigationBase } @@ -100,8 +164,11 @@ export const AddressTile2 = React.forwardRef( lockInputs, navigation, onChangeAddress, + onUnparsedAddress, + selfTransfer, recipientAddress, resetSendTransaction, + crossChainAddressValidation, title } = props @@ -156,9 +223,23 @@ export const AddressTile2 = React.forwardRef( const canSelfTransfer: boolean = Object.keys(currencyWallets).some( walletId => { if (walletId === coreWallet.id) return false - if (currencyWallets[walletId].type !== coreWallet.type) return false + const wallet = currencyWallets[walletId] + // A self-transfer caller offers every asset the send can route to, so + // the control has to appear whenever the user holds ANY of them. The + // same-type test below would hide it from exactly the account the + // cross-chain picker exists for: one wallet on the source chain and + // the rest elsewhere. + if (selfTransfer != null) { + return selfTransfer.allowedAssets.some( + asset => + asset.pluginId === wallet.currencyInfo.pluginId && + (asset.tokenId == null || + wallet.enabledTokenIds.includes(asset.tokenId)) + ) + } + if (wallet.type !== coreWallet.type) return false if (tokenId == null) return true - return currencyWallets[walletId].enabledTokenIds.includes(tokenId) + return wallet.enabledTokenIds.includes(tokenId) } ) @@ -170,6 +251,38 @@ export const AddressTile2 = React.forwardRef( async (address: string, addressEntryMethod: AddressEntryMethod) => { if (address == null || address.trim() === '') return + // A cross-chain destination cannot go through this wallet's URI + // parsing or name services. Split payment URIs (scanned QR codes) + // generically, then validate against the destination chain's own + // rules and pass the address through verbatim. + if (crossChainAddressValidation != null) { + const { addressCandidates, displayAmount, scheme, evmChainId, memo } = + parsePaymentUri(address) + const crossChainAddress = addressCandidates.find(candidate => + crossChainAddressValidation(candidate, { scheme, evmChainId }) + ) + if (crossChainAddress == null) { + // Not valid on the picked destination either. It may still belong + // to some other chain the consumer can switch to. + const adopted = await onUnparsedAddress?.( + address, + addressEntryMethod + ) + if (adopted === true) return + showToast( + `${lstrings.scan_invalid_address_error_title} ${lstrings.scan_invalid_address_error_description}` + ) + return + } + await onChangeAddress({ + parsedUri: { publicAddress: crossChainAddress }, + addressEntryMethod, + crossChainDisplayAmount: displayAmount, + crossChainMemo: memo + }) + return + } + setLoading(true) const enteredInput = address.trim() address = enteredInput @@ -364,6 +477,15 @@ export const AddressTile2 = React.forwardRef( }) } } else { + // This wallet's chain can't read the input. Before calling it + // invalid, let the consumer check whether it addresses another + // chain, which turns the send into a cross-chain swap. + setLoading(false) + const adopted = await onUnparsedAddress?.( + address, + addressEntryMethod + ) + if (adopted === true) return showToast( `${lstrings.scan_invalid_address_error_title} ${lstrings.scan_invalid_address_error_description}` ) @@ -445,17 +567,24 @@ export const AddressTile2 = React.forwardRef( const handleSelfTransfer = useHandler(() => { const { currencyWallets } = account const { pluginId } = coreWallet.currencyInfo + const sourceAsset = { pluginId, tokenId } Airship.show(bridge => ( )) @@ -468,6 +597,15 @@ export const AddressTile2 = React.forwardRef( const { segwitAddress, publicAddress } = await wallet.getReceiveAddress({ tokenId: null }) const address = segwitAddress ?? publicAddress + + // A wallet on another chain is a cross-asset destination, so the + // caller adopts it (recipient asset, quote reset) instead of this + // tile validating the address against the source wallet's chain. + const destPluginId = wallet.currencyInfo.pluginId + if (selfTransfer != null && destPluginId !== pluginId) { + await selfTransfer.onPickCrossAsset(destPluginId, address) + return + } await changeAddress(address, 'other') }) .catch((err: unknown) => { @@ -518,6 +656,7 @@ export const AddressTile2 = React.forwardRef( Date: Mon, 17 Aug 2026 11:27:24 -0700 Subject: [PATCH 11/18] Turn SendScene2 into a send-to-address swap (Stealth Send) The send scene offers a "Recipient receives" asset selector over the destination chains the provider serves, and a Stealth Send toggle. Stealth or a cross-asset recipient turns the send into a swap-to-address quote: live quotes through account.fetchSwapQuotes with toAddressInfo, linked "You send"/"Recipient gets" rows whose edited side is the guaranteed amount and whose other side tracks the quote as an estimate, each row naming its state in its own title, the shared price-impact indicator, an expiry countdown that re-quotes, the quote's network fee, and a destination tag row on memo-required chains that rides toMemos to the provider. Both amounts go through the standard flip input and open on fiat, as the swap scene's inputs do. The confirm slider approves the quote and lands on the swap success scene. An address the sending wallet cannot read is matched against the served destination chains rather than reported as invalid: a URI scheme names its chain outright, a bare address is matched on format, and where several chains share one format the user picks rather than the app guessing and misdirecting funds. A URI amount is what the RECIPIENT should receive, so it sets the guaranteed receive side for a cross-asset destination; a same-asset stealth send keeps it on the send side, because the provider serves no receive-priced route when the two assets match. What the pair cannot route is learned from the quote failures themselves rather than probed: a same-asset pair with no private route turns the toggle off with a toast and degrades to the plain send it had upgraded, a missing receive-priced route falls back to a rate-seeded guaranteed send amount, and re-arming either on a known-unavailable pair answers pre-emptively instead of sending another doomed quote. Amounts under the applicable floor are refused before a request goes out. Plain same-asset sends are unchanged, including multi-recipient UTXO sends, which now also show a total-amount row. Multi-recipient and stealth/cross-asset are mutually exclusive, gated in both directions. Constrained callers (locked or hidden tiles, FIO requests, payment protocol, custom broadcast or completion hooks) keep today's behavior. --- .../__snapshots__/SendScene2.ui.test.tsx.snap | 1956 ++++++++++++++--- src/__tests__/util/houdiniChains.test.ts | 133 ++ src/components/scenes/SendScene2.tsx | 1844 +++++++++++++++- src/components/themed/StealthInfoText.tsx | 69 + src/constants/stealthConstants.ts | 15 + src/locales/en_US.ts | 33 + src/locales/strings/enUS.json | 23 + src/util/houdiniChains.ts | 89 + 8 files changed, 3836 insertions(+), 326 deletions(-) create mode 100644 src/components/themed/StealthInfoText.tsx create mode 100644 src/constants/stealthConstants.ts diff --git a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap index f5915ca2a1d..228e641c502 100644 --- a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap @@ -248,7 +248,7 @@ exports[`SendScene2 1 spendTarget 1`] = ` "reduceMotionV": "system", } } - nativeID="6" + nativeID="9" > @@ -1571,7 +1571,7 @@ exports[`SendScene2 1 spendTarget 1`] = ` "reduceMotionV": "system", } } - nativeID="13" + nativeID="16" > @@ -3581,7 +3581,7 @@ exports[`SendScene2 1 spendTarget with info tiles 1`] = ` "reduceMotionV": "system", } } - nativeID="23" + nativeID="26" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -5591,7 +5703,7 @@ exports[`SendScene2 2 spendTargets 1`] = ` "reduceMotionV": "system", } } - nativeID="34" + nativeID="37" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -7182,7 +7406,7 @@ exports[`SendScene2 2 spendTargets hide tiles 1`] = ` "reduceMotionV": "system", } } - nativeID="44" + nativeID="47" > - Network Fee: + Total Amount - 0 (0) + 0.00013579 BTC - + + + + + Network Fee: + + + 0 (0) + + + @@ -8753,7 +9089,7 @@ exports[`SendScene2 2 spendTargets hide tiles 2`] = ` "reduceMotionV": "system", } } - nativeID="54" + nativeID="57" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -10091,7 +10539,7 @@ exports[`SendScene2 2 spendTargets hide tiles 3`] = ` "reduceMotionV": "system", } } - nativeID="63" + nativeID="66" > - Network Fee: + Total Amount - 0 (0) + 0.00013579 BTC - + + + + + Network Fee: + + + 0 (0) + + + @@ -11842,7 +12402,7 @@ exports[`SendScene2 2 spendTargets lock tiles 1`] = ` "reduceMotionV": "system", } } - nativeID="75" + nativeID="78" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -13544,7 +14216,7 @@ exports[`SendScene2 2 spendTargets lock tiles 2`] = ` "reduceMotionV": "system", } } - nativeID="86" + nativeID="89" > - Network Fee: + Total Amount - 0 (0) + 0.00013579 BTC - + + + + + Network Fee: + + + 0 (0) + + + @@ -15173,7 +15957,7 @@ exports[`SendScene2 2 spendTargets lock tiles 3`] = ` "reduceMotionV": "system", } } - nativeID="97" + nativeID="100" > - Send to Address + Recipient receives - -  - - - Enter - + "bottom": 0, + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + } + } + /> + - + Bitcoin (BTC) + + + + + +  + + + + + + + + Send to Address + + + + +  + + + Enter + + + + +  + + + Myself + + + + + + + + + Stealth Send + + + + + + + + + + + + + + + + { expect(schemeNamesChain('polygon', getChain('ethereum'))).toEqual(false) }) }) + +// A USDT contract on Ethereum, standing in for any token source: +const USDT_TOKEN_ID = 'dac17f958d2ee523a2206206994597c13d831ec7' +// The POL ERC-20 on Ethereum. Its `displayName` is "Polygon" and its +// `currencyCode` is POL, both identical to the Polygon chain's: +const POL_TOKEN_ID = '455e53cbb86018ac2b8092fdcd39d8444affc3f6' + +describe('getRecipientAsset', () => { + it('gives the source asset for a plain send, token included', () => { + expect( + getRecipientAsset({ + sourcePluginId: 'ethereum', + sourceTokenId: USDT_TOKEN_ID, + destPluginId: 'ethereum', + swapSendActive: false + }) + ).toEqual({ pluginId: 'ethereum', tokenId: USDT_TOKEN_ID }) + }) + + it('gives the destination chain native for a swap-send', () => { + // The quote asks for `toTokenId: null`, so a USDT source pays out ETH even + // with no destination chain picked. Naming the source token here told a + // USDT sender their recipient receives USDT. + expect( + getRecipientAsset({ + sourcePluginId: 'ethereum', + sourceTokenId: USDT_TOKEN_ID, + destPluginId: 'ethereum', + swapSendActive: true + }) + ).toEqual({ pluginId: 'ethereum', tokenId: null }) + + expect( + getRecipientAsset({ + sourcePluginId: 'ethereum', + sourceTokenId: USDT_TOKEN_ID, + destPluginId: 'litecoin', + swapSendActive: true + }) + ).toEqual({ pluginId: 'litecoin', tokenId: null }) + }) +}) + +describe('getRecipientAssetChoices', () => { + const servedPluginIds = ['bitcoin', 'ethereum', 'litecoin', 'polygon'] + + it('leads with the same asset the row names, so the two cannot drift', () => { + for (const swapSendActive of [false, true]) { + for (const sourceTokenId of [null, USDT_TOKEN_ID]) { + const [first] = getRecipientAssetChoices({ + sourcePluginId: 'ethereum', + sourceTokenId, + swapSendActive, + servedPluginIds + }) + expect(first.recipientPluginId).toEqual(undefined) + expect(first.asset).toEqual( + getRecipientAsset({ + sourcePluginId: 'ethereum', + sourceTokenId, + destPluginId: 'ethereum', + swapSendActive + }) + ) + } + } + }) + + it('never lists the source chain twice', () => { + // Two rows for the same chain quote identically and differ only in + // `crossAssetPicked`, which decides whether turning Stealth off degrades + // to a plain send. No user can tell them apart. + for (const sourceTokenId of [null, USDT_TOKEN_ID]) { + const choices = getRecipientAssetChoices({ + sourcePluginId: 'ethereum', + sourceTokenId, + swapSendActive: true, + servedPluginIds + }) + const ethereumRows = choices.filter( + choice => choice.asset.pluginId === 'ethereum' + ) + expect(ethereumRows.length).toEqual(1) + } + }) + + it('offers every served chain but the source', () => { + const choices = getRecipientAssetChoices({ + sourcePluginId: 'ethereum', + sourceTokenId: null, + swapSendActive: true, + servedPluginIds + }) + expect(choices.map(choice => choice.recipientPluginId)).toEqual([ + undefined, + 'bitcoin', + 'litecoin', + 'polygon' + ]) + }) +}) + +describe('recipientAssetKey', () => { + it('separates a token from a chain that shares its name and code', () => { + // The POL ERC-20 on Ethereum and the Polygon chain are both displayed as + // "Polygon (POL)". Keying the picker on the label marked both rows + // selected and resolved either tap to the same destination, which left + // Polygon unreachable from a POL wallet. + const polToken = recipientAssetKey({ + pluginId: 'ethereum', + tokenId: POL_TOKEN_ID + }) + const polygonChain = recipientAssetKey({ + pluginId: 'polygon', + tokenId: null + }) + expect(polToken).not.toEqual(polygonChain) + }) + + it('gives every row of a picker a distinct key', () => { + const choices = getRecipientAssetChoices({ + sourcePluginId: 'ethereum', + sourceTokenId: POL_TOKEN_ID, + swapSendActive: false, + servedPluginIds: HOUDINI_CHAINS.map(chain => chain.pluginId) + }) + const keys = choices.map(choice => recipientAssetKey(choice.asset)) + expect(new Set(keys).size).toEqual(keys.length) + }) +}) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 050a7e8ddf2..37060a8f478 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -1,17 +1,21 @@ -import { abs, add, div, gte, lt, lte, mul, sub } from 'biggystring' +import { abs, add, div, gte, lt, lte, mul, sub, toFixed } from 'biggystring' import { asMaybe } from 'cleaners' import { asMaybeInsufficientFundsError, asMaybeNoAmountSpecifiedError, + asMaybeSwapCurrencyError, type EdgeAccount, + type EdgeCurrencyConfig, type EdgeCurrencyWallet, type EdgeDenomination, type EdgeMemo, type EdgeMemoOption, type EdgeSpendInfo, type EdgeSpendTarget, + type EdgeSwapQuote, type EdgeTokenId, type EdgeTransaction, + type EdgeTxActionSwapType, type InsufficientFundsError } from 'edge-core-js' import * as React from 'react' @@ -32,10 +36,12 @@ import { playSendSound } from '../../actions/SoundActions' import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' import { FIO_STR, + getFiatSymbol, getSpecialCurrencyInfo } from '../../constants/WalletAndCurrencyConstants' import { useAsyncEffect } from '../../hooks/useAsyncEffect' import { useDisplayDenom } from '../../hooks/useDisplayDenom' +import { formatFiatString } from '../../hooks/useFiatText' import { useHandler } from '../../hooks/useHandler' import { useIconColor } from '../../hooks/useIconColor' import { useMount } from '../../hooks/useMount' @@ -43,12 +49,16 @@ import { useUnmount } from '../../hooks/useUnmount' import { useWatch } from '../../hooks/useWatch' import { lstrings } from '../../locales/strings' import { getExchangeDenom } from '../../selectors/DenominationSelectors' -import { getExchangeRate } from '../../selectors/WalletSelectors' +import { + convertCurrency, + getExchangeRate, + getFiatRate +} from '../../selectors/WalletSelectors' import { config } from '../../theme/appConfig' import { useState } from '../../types/reactHooks' import { useDispatch, useSelector } from '../../types/reactRedux' import type { EdgeAppSceneProps, NavigationBase } from '../../types/routerTypes' -import type { FioRequest } from '../../types/types' +import type { EdgeAsset, FioRequest } from '../../types/types' import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { getWalletName } from '../../util/CurrencyWalletHelpers' import { @@ -59,6 +69,18 @@ import { FioError, recordSend } from '../../util/FioAddressUtils' +import { + detectHoudiniChains, + getHoudiniChain, + getRecipientAsset, + getRecipientAssetChoices, + HOUDINI_CHAINS, + HOUDINI_MIN_USD, + type HoudiniChain, + isValidHoudiniAddress, + recipientAssetKey, + schemeNamesChain +} from '../../util/houdiniChains' import { logActivity } from '../../util/logger' import { createEdgeMemo, @@ -67,6 +89,12 @@ import { getMemoLabel, getMemoTitle } from '../../util/memoUtils' +import { parsePaymentUri } from '../../util/paymentUri' +import { + hasParentFeeRow, + makeStealthSwapRequestOptions +} from '../../util/stealthSwap' +import { processSwapQuoteError } from '../../util/swapErrorDisplay' import { convertTransactionFeeToDisplayFee, darkenHexColor, @@ -79,6 +107,7 @@ import { ErrorCard, I18nError } from '../cards/ErrorCard' import type { AccentColors } from '../common/DotsBackground' import { EdgeAnim } from '../common/EdgeAnim' import { SceneWrapper } from '../common/SceneWrapper' +import { CryptoIcon } from '../icons/CryptoIcon' import { ButtonsModal } from '../modals/ButtonsModal' import { FlipInputModal2, @@ -86,6 +115,7 @@ import { type FlipInputModalResult } from '../modals/FlipInputModal2' import { showInsufficientFeesModal } from '../modals/InsufficientFeesModal' +import { RadioListModal } from '../modals/RadioListModal' import { TextInputModal } from '../modals/TextInputModal' import { WalletListModal, @@ -94,16 +124,23 @@ import { import { EdgeRow } from '../rows/EdgeRow' import { Airship, showError, showToast } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { SettingsSwitchRow } from '../settings/SettingsSwitchRow' +import { FiatText } from '../text/FiatText' import { UnscaledTextInput } from '../text/UnscaledTextInput' -import { EdgeText } from '../themed/EdgeText' +import { EdgeText, PositiveText, WarningText } from '../themed/EdgeText' import type { ExchangedFlipInputAmounts, ExchangeFlipInputFields } from '../themed/ExchangedFlipInput2' import { asPrivateNetworkingSetting } from '../themed/MaybePrivateNetworkingSetting' import { PinDots } from '../themed/PinDots' +import { + calculateQuotePriceImpact, + PriceImpactText +} from '../themed/PriceImpactText' import { SafeSlider } from '../themed/SafeSlider' import { SendFromFioRows } from '../themed/SendFromFioRows' +import { StealthInfoText } from '../themed/StealthInfoText' import { type AddressEntryMethod, AddressTile2, @@ -179,6 +216,13 @@ interface FioSenderInfo { skipRecord?: boolean } +/** One side of a swap row's inline fiat value, rendered by `FiatText`. */ +interface SwapRowFiat { + nativeAmount: string + tokenId: EdgeTokenId + currencyConfig: EdgeCurrencyConfig +} + const ALLOW_MULTIPLE_TARGETS = true /** @@ -249,12 +293,21 @@ const SendComponent: React.FC = props => { initMinNativeAmount ) const [expireDate, setExpireDate] = useState(initExpireDate) + /** Whether the payment request's own countdown has run out. */ + const [addressExpired, setAddressExpiredState] = useState(false) + /** + * Mirror of `addressExpired` for the error-owner helpers below, which are + * also called from async effects holding an older render's closure. + */ + const addressExpiredRef = React.useRef(false) + const setAddressExpired = (value: boolean): void => { + addressExpiredRef.current = value + setAddressExpiredState(value) + } const [error, setError] = useState(undefined) const [edgeTransaction, setEdgeTransaction] = useState(null) const [pinValue, setPinValue] = useState(undefined) - const [spendingLimitExceeded, setSpendingLimitExceeded] = - useState(false) const [lastAddressEntryMethod, setLastAddressEntryMethod] = useState< AddressEntryMethod | undefined >(undefined) @@ -270,6 +323,56 @@ const SendComponent: React.FC = props => { // -1 = no max spend, otherwise equal to the index the spendTarget that requested the max spend. const [maxSpendSetter, setMaxSpendSetter] = useState(-1) + // Send-to-address swap state (Stealth Send / cross-asset recipient). The + // recipient asset defaults to the source asset (undefined); picking another + // chain, or enabling stealth, turns the send into a swap-to-address quote. + const [recipientPluginId, setRecipientPluginId] = useState< + string | undefined + >(undefined) + const [stealth, setStealth] = useState(false) + const [destinationTag, setDestinationTag] = useState( + undefined + ) + const [swapQuote, setSwapQuote] = useState( + undefined + ) + const [fetchingSwapQuote, setFetchingSwapQuote] = useState(false) + const [guaranteedSide, setGuaranteedSide] = useState<'send' | 'receive'>( + 'send' + ) + // The fixed receive amount (destination-chain native units) when the user + // edits "Recipient gets"; otherwise the latest quote's estimate. + const [receiveNativeAmount, setReceiveNativeAmount] = useState< + string | undefined + >(undefined) + // Bumped when the quote expires, to force a re-quote: + const [swapQuoteNonce, setSwapQuoteNonce] = useState(0) + // Route capabilities learned from the provider's live answers, keyed by + // `${sourcePluginId}:${tokenId}->${destPluginId}`. Availability is a live + // provider property (routes appear and disappear between sessions), so the + // scene learns it from real quote failures and reflects it pre-emptively + // from then on, rather than trusting a static table that would go stale. + const [routeCaps, setRouteCaps] = useState< + Record + >({}) + // A fixed receive amount was abandoned because the provider offers no + // receive-priced route for the pair. Shows the warning card until the user + // edits an amount or changes the destination: + const [fixedToFallback, setFixedToFallback] = useState(false) + // The fixed-to fallback wanted to seed a send amount but had no exchange rate + // to seed it from. Cleared by the one retry that fires when rates arrive. + const [rateStarvedFallback, setRateStarvedFallback] = useState(false) + const isApprovingSwapRef = React.useRef(false) + // Quote requests are not cancellable, so each one carries a generation and + // only the newest is allowed to write state. Without this a slow response + // for a superseded amount lands last and wins. + const swapQuoteGeneration = React.useRef(0) + // The live quote, for the one read that resumes after an await. The slider + // awaits the PIN check before approving, and every term the quote was priced + // against retires it meanwhile, so the closed-over `swapQuote` can still name + // an order the scene has already dropped. + const swapQuoteRef = React.useRef(undefined) + const countryCode = useSelector(state => state.ui.countryCode) const account = useSelector(state => state.core.account) const exchangeRates = useSelector( @@ -286,6 +389,45 @@ const SendComponent: React.FC = props => { ) const hasNotifications = useSelector(state => state.ui.notificationHeight > 0) + /** + * Whether the error currently on screen came from the swap-send path. The + * `error` state is shared with the plain-send `makeSpend` effect, which owns + * its own failures, so leaving swap-send mode may only retract a swap error + * and must never clear an insufficient-funds message that effect put there. + */ + const swapErrorShown = React.useRef(false) + const setSwapError = (value: unknown): void => { + swapErrorShown.current = value != null + setError(value) + } + const clearSwapError = (): void => { + if (!swapErrorShown.current) return + swapErrorShown.current = false + setError(undefined) + } + /** The converse: retract a plain-send error without touching a swap one. */ + const clearPlainSendError = (): void => { + if (swapErrorShown.current) return + // The expiry message belongs to the REQUEST, not to either send mode, and + // the slider stays disabled on it either way. Retracting it here left the + // user looking at a dead slider with nothing on screen explaining why. + if (addressExpiredRef.current) return + setError(undefined) + } + + /** + * Live exchange rates, for the quote effect's fixed-to fallback to read. + * The fallback needs current rates, but the effect must NOT re-run on a rate + * tick: rates update constantly and the provider rate-limits tight traffic, + * so depending on them would fire a quote request every few seconds. A ref + * gives the fallback today's rates without making them a trigger. + */ + const ratesRef = React.useRef({ + rates: exchangeRates, + isoFiat: defaultIsoFiat + }) + ratesRef.current = { rates: exchangeRates, isoFiat: defaultIsoFiat } + const currencyWallets = useWatch(account, 'currencyWallets') const coreWallet = currencyWallets[walletId] const { pluginId, memoOptions = [] } = coreWallet.currencyInfo @@ -329,6 +471,275 @@ const SendComponent: React.FC = props => { spendInfo.tokenId = tokenId + // --------------------------------------------------------------------- + // Send-to-address swap mode (Stealth Send / cross-asset recipient) + // --------------------------------------------------------------------- + + // The send-to-address swap UI is offered only when this scene is a plain, + // unconstrained send. Callers that pre-lock tiles, pre-fill an address + // (payment protocol, deep links), pay FIO requests, or take over the + // broadcast/completion flow keep today's behavior untouched. + const swapSendAllowed = + lockTilesMap.address !== true && + lockTilesMap.amount !== true && + lockTilesMap.wallet !== true && + hiddenFeaturesMap.address !== true && + hiddenFeaturesMap.amount !== true && + fioPendingRequest == null && + onDone == null && + alternateBroadcast == null && + beforeTransaction == null && + initSpendInfo?.spendTargets[0]?.publicAddress == null + + // Where the funds land. The recipient asset defaults to the source asset; + // `destChain` carries Houdini's metadata (address regex, memoNeeded) for + // the destination chain when it is served. + const destPluginId = recipientPluginId ?? pluginId + // The payout is always the destination chain's NATIVE asset (the quote asks + // for `toTokenId: null`), so a token source is never the same asset as its + // destination even on its own chain. + const sameAsset = destPluginId === pluginId && tokenId == null + /** + * A recipient asset was explicitly adopted. This is what turns a plain send + * into a swap-send on its own, and it is also the test for whether turning + * Stealth off would help: without an adopted recipient, the toggle is the + * only thing making this a swap, so switching it off degrades to a plain + * same-chain send. + */ + const crossAssetPicked = recipientPluginId != null && !sameAsset + /** + * Whether the swap crosses assets, for labelling the flow. Distinct from + * `crossAssetPicked`: a token send to its own chain pays out that chain's + * native asset, so it is a cross-asset swap even though no recipient asset + * was picked. Treating it as same-asset titled it "Stealth Send". + */ + const crossAsset = !sameAsset + const swapSendActive = swapSendAllowed && (stealth || crossAssetPicked) + /** + * The asset the recipient ends up with, which the "Recipient receives" row + * and its picker both name. A swap-send pays out the destination chain's + * native asset; a plain send delivers the source asset, token and all. + */ + const recipientAsset = getRecipientAsset({ + sourcePluginId: pluginId, + sourceTokenId: tokenId, + destPluginId, + swapSendActive + }) + const destChain = swapSendActive + ? getHoudiniChain(destPluginId, null) + : undefined + const destCurrencyConfig = account.currencyConfig[destPluginId] + const destCurrencyInfo = destCurrencyConfig?.currencyInfo + const destExchangeDenom = + destCurrencyConfig == null + ? undefined + : getExchangeDenom(destCurrencyConfig, null) + // Called unconditionally to keep hook order stable; the value is only read + // once a destination chain is actually selected. Limits are quoted in the + // denomination the user reads elsewhere, not the exchange one. + const destDisplayDenom = useDisplayDenom( + destCurrencyConfig ?? coreWallet.currencyConfig, + null + ) + + /** + * Whether Houdini can route the source asset to ITSELF privately, which is + * what a same-asset Stealth Send asks for. Read off the chain table rather + * than learned from a quote, since it is a property of the asset. + */ + const selfPrivateAvailable = + getHoudiniChain(pluginId, tokenId)?.hasSelfPrivate === true + + /** + * The order size in USD, the unit Houdini states its minimums in. Priced off + * whichever side the user fixed, so it matches the number they typed. + * `undefined` when no rate is known, which stands the floor check down rather + * than blocking a send on a missing rate. + */ + const orderUsdValue = React.useMemo(() => { + const useSendSide = guaranteedSide === 'send' + const nativeAmount = useSendSide + ? spendInfo.spendTargets[0].nativeAmount + : receiveNativeAmount + const multiplier = useSendSide + ? cryptoExchangeDenomination.multiplier + : destExchangeDenom?.multiplier + if ( + nativeAmount == null || + zeroString(nativeAmount) || + multiplier == null + ) { + return undefined + } + const usdValue = convertCurrency( + exchangeRates, + useSendSide ? pluginId : destPluginId, + useSendSide ? tokenId : null, + 'iso:USD', + div(nativeAmount, multiplier, DECIMAL_PRECISION) + ) + return zeroString(usdValue) ? undefined : usdValue + }, [ + cryptoExchangeDenomination.multiplier, + destExchangeDenom?.multiplier, + destPluginId, + exchangeRates, + guaranteedSide, + pluginId, + receiveNativeAmount, + spendInfo, + tokenId + ]) + + /** + * Whether the order clears Houdini's minimum for the route it would take. + * Private routes start at 25 USD and standard ones at 10, so a Stealth Send + * is pre-empted well before a plain Swap & Send is. Both are checked before + * any request goes out: the provider is an aggregator that rate-limits tight + * traffic, so a quote we already know will be refused is not worth sending. + */ + const belowPrivateFloor = + orderUsdValue != null && lt(orderUsdValue, HOUDINI_MIN_USD.private) + const belowStandardFloor = + orderUsdValue != null && lt(orderUsdValue, HOUDINI_MIN_USD.standard) + + /** + * A provider floor, stated in USD, rendered in the user's own display fiat. + * + * The floors are USD because that is the unit Houdini enforces them in, but + * the user reads every other amount on this scene in their display currency, + * so writing the raw figure with a dollar sign is wrong twice for anyone not + * on USD: the wrong symbol, and a number that is not the threshold in their + * currency. Converted through the same rates the rest of the scene uses and + * formatted by the same helper, so it reads like every other fiat figure. + * + * With no rate between USD and the display fiat there is nothing honest to + * convert to, so the USD figure is shown carrying its OWN symbol rather than + * a wrong number wearing the user's. + */ + const formatUsdFloor = (usdFloor: string): string => { + const rate = getFiatRate(exchangeRates, 'iso:USD', defaultIsoFiat) + const isoFiat = rate === 0 ? 'iso:USD' : defaultIsoFiat + const amount = rate === 0 ? usdFloor : mul(usdFloor, String(rate)) + return `${getFiatSymbol(isoFiat)}${formatFiatString({ + fiatAmount: amount + })}` + } + + // The PIN spending limit gates every outbound flow, swap-send included. + // DERIVED, not effect-written: it used to be state refreshed inside the + // makeSpend effect, which runs AFTER the render that already holds a live + // quote, so a frame could show an armed slider while the flag still read + // false. Computing it during render makes the gate and the amount it judges + // come from the same render. + const spendingLimitExceeded = React.useMemo(() => { + if (!pinSpendingLimitsEnabled) return false + const rate = + getExchangeRate( + exchangeRates, + coreWallet.currencyInfo.pluginId, + tokenId, + defaultIsoFiat + ) ?? INFINITY_STRING + const totalNativeAmount = spendInfo.spendTargets.reduce( + (prev, target) => add(target.nativeAmount ?? '0', prev), + '0' + ) + const totalExchangeAmount = div( + totalNativeAmount, + cryptoExchangeDenomination.multiplier, + DECIMAL_PRECISION + ) + const fiatAmount = mul(totalExchangeAmount, rate) + return gte(fiatAmount, pinSpendingLimitsAmount.toFixed(DECIMAL_PRECISION)) + }, [ + coreWallet.currencyInfo.pluginId, + cryptoExchangeDenomination.multiplier, + defaultIsoFiat, + exchangeRates, + pinSpendingLimitsAmount, + pinSpendingLimitsEnabled, + spendInfo, + tokenId + ]) + + // A raw swap failure renders as ErrorCard's catch-all "unexpected error" + // card, which tells the user nothing they can act on. Map it to the same + // specific text the wallet-to-wallet swap flow shows: the limit that was + // crossed and by how much, the pair that cannot route, or, failing a known + // shape, the provider's own message. + const describeSwapError = (error: unknown): unknown => { + const info = processSwapQuoteError({ + error, + swapRequest: { + fromWallet: coreWallet, + fromTokenId: tokenId, + toTokenId: null, + toAddressInfo: { + toPluginId: destPluginId, + toAddress: spendInfo.spendTargets[0].publicAddress ?? '', + toMemos: [] + }, + nativeAmount: spendInfo.spendTargets[0].nativeAmount ?? '0', + quoteFor: guaranteedSide === 'send' ? 'from' : 'to' + }, + fromDenomination: cryptoDisplayDenomination, + toDenomination: destDisplayDenom, + toCurrencyCode: destCurrencyInfo?.currencyCode + }) + return info == null ? error : new I18nError(info.title, info.message) + } + const multipleTargets = spendInfo.spendTargets.length > 1 + + // What the provider is known to offer for the current pair. `false` means a + // live quote already came back without that capability this session; + // `undefined` means untested, so the UI assumes available until told + // otherwise. + const routePairKey = `${pluginId}:${String(tokenId)}->${destPluginId}` + // `fixedTo` is a property of the ROUTE, not of the pair. Houdini prices + // exact-out on fixed-rate quotes alone, which its private routing does not + // serve, so a receive-priced failure learned with privacy required says + // nothing about the standard route the same pair takes with Stealth off, and + // vice versa. Sharing one key let a failure on either side refuse the editor + // on the other, for a capability that was never tested there. + const routeCapKeys = { + stealth: routePairKey, + fixedTo: `${routePairKey}:${stealth ? 'private' : 'any'}` + } + const pairCaps = { + stealth: routeCaps[routeCapKeys.stealth]?.stealth, + fixedTo: routeCaps[routeCapKeys.fixedTo]?.fixedTo + } + const markRouteCap = (cap: 'stealth' | 'fixedTo'): void => { + const key = routeCapKeys[cap] + setRouteCaps(caps => ({ + ...caps, + [key]: { ...caps[key], [cap]: false } + })) + } + + /** + * Why the Stealth toggle cannot be armed right now, or `undefined` when it + * can. Ordered most specific first, so the user reads the reason that + * applies to the send in front of them rather than the first one that fires. + */ + const stealthBlockedReason: string | undefined = multipleTargets + ? lstrings.stealth_multi_recipient_unsupported + : sameAsset && !selfPrivateAvailable + ? sprintf(lstrings.stealth_self_private_unsupported_1s, currencyCode) + : belowPrivateFloor + ? sprintf( + lstrings.stealth_below_private_minimum_1s, + formatUsdFloor(HOUDINI_MIN_USD.private) + ) + : pairCaps.stealth === false + ? lstrings.stealth_route_unavailable_info + : undefined + + /** The floor this send must clear for the route it would actually take. */ + const belowActiveFloor = stealth ? belowPrivateFloor : belowStandardFloor + const updatePendingTxState = React.useCallback(async (): Promise => { if (coreWallet == null || !isEvmWallet(coreWallet)) { setHasPendingTx(false) @@ -433,19 +844,104 @@ const SendComponent: React.FC = props => { }) } + /** + * Sets the destination tag for a user- or URI-driven change, retiring any + * held quote when the value actually moves. + * + * The tag rides `toAddressInfo.toMemos` into the quote request, so it is one + * of the terms the order was created against, exactly like the amount and the + * address. Leaving a quote armed across a tag change lets a slide submit the + * memo the order was built with while the screen shows a different one, which + * on a memo-required payout is the difference between the recipient being + * credited and not. + */ + const changeDestinationTag = (tag: string | undefined): void => { + setDestinationTag(previous => { + if (previous !== tag) setSwapQuote(undefined) + return tag + }) + } + const handleChangeAddress = (spendTarget: EdgeSpendTarget) => async (changeAddressResult: ChangeAddressResult): Promise => { - const { addressEntryMethod, parsedUri, fioAddress, alias, resolvedName } = - changeAddressResult + const { + addressEntryMethod, + parsedUri, + fioAddress, + alias, + resolvedName, + crossChainDisplayAmount, + crossChainMemo, + detectedDestPluginId + } = changeAddressResult + + // A destination detected from the address itself makes this a cross-asset + // send. `setRecipientPluginId` has not re-rendered yet, so the routing + // below reads the detected chain rather than the stale render-time state. + const uriGuaranteesReceiveSide = + detectedDestPluginId != null || (swapSendActive && !sameAsset) + const uriDestExchangeDenom = + detectedDestPluginId == null + ? destExchangeDenom + : getExchangeDenom(account.currencyConfig[detectedDestPluginId], null) if (parsedUri != null) { + // The recipient is one of the terms a quote was priced against, so a + // new address retires it exactly as a new amount, wallet or toggle + // does. Dropping the quote rather than only re-requesting one matters + // because the slider gates on a quote being PRESENT: leaving the old + // one up keeps the slider armed, and a slide during the re-quote would + // approve an order created for the address the user just replaced. + if (spendTarget.publicAddress !== parsedUri.publicAddress) { + setSwapQuote(undefined) + } + // A scanned code's tag reaches the row here too, not only through the + // detect-and-adopt path: picking the recipient asset BEFORE scanning is + // the ordinary order, and the tag credits the recipient either way. + // + // Gated on the destination needing a memo, exactly as the adopt path + // is. The Destination Tag row only renders for a `memoNeeded` chain, so + // taking a BIP-21 `message` on any other chain would ride a value into + // `toMemos` that the user can neither see nor clear. + const memoDestChain = + detectedDestPluginId == null + ? destChain + : getHoudiniChain(detectedDestPluginId, null) + if ( + memoDestChain?.memoNeeded === true && + crossChainMemo != null && + crossChainMemo !== '' + ) { + changeDestinationTag(crossChainMemo) + } if (parsedUri.metadata != null) { spendInfo.metadata = parsedUri.metadata } spendTarget.uniqueIdentifier = parsedUri?.uniqueIdentifier spendTarget.publicAddress = parsedUri?.publicAddress - spendTarget.nativeAmount = parsedUri?.nativeAmount + + if (uriGuaranteesReceiveSide) { + // A payment URI's amount is what the recipient should receive, so a + // cross-asset send guarantees the destination side and prices the + // send side off the quote. A cross-chain URI carries display units + // to convert; a same-chain one is already destination-native. + // + // Same-asset (stealth) sends stay on the send side: guaranteeing the + // receive side needs a receive-priced quote, and the provider offers + // no fixed-rate route when the source and destination assets match. + const uriReceiveNativeAmount = + crossChainDisplayAmount != null && uriDestExchangeDenom != null + ? mul(crossChainDisplayAmount, uriDestExchangeDenom.multiplier) + : parsedUri.nativeAmount + spendTarget.nativeAmount = undefined + if (uriReceiveNativeAmount != null) { + setReceiveNativeAmount(uriReceiveNativeAmount) + setGuaranteedSide('receive') + } + } else { + spendTarget.nativeAmount = parsedUri.nativeAmount + } const memos: EdgeMemo[] = [] // Preserve existing memo data or use memo/uniqueIdentifier from parsed URI @@ -485,11 +981,226 @@ const SendComponent: React.FC = props => { setLastAddressEntryMethod(addressEntryMethod) setMinNativeAmount(parsedUri.minNativeAmount) setExpireDate(parsedUri?.expireDate) + setAddressExpired(false) setSpendInfo({ ...spendInfo, memos }) needsScrollToEnd.current = true } } + /** + * Rescues input the sending wallet could not parse. An address for another + * chain is the ordinary way a user asks for a cross-chain send: they paste + * the recipient's address before touching "Recipient receives". Detect the + * chain it belongs to, adopt it as the destination, and keep the address. + * + * Returns false to let the tile report an invalid address, which is still + * the right answer for a genuine typo. + */ + /** + * Adopt a destination on another chain: the recipient asset becomes that + * chain, any tag and quote held for the previous one is dropped, and the + * address lands in the tile. Shared by address detection, which infers the + * chain from the text, and the "Myself" picker, which knows it outright. + */ + const adoptCrossChainDestination = + (spendTarget: EdgeSpendTarget) => + async ( + destPluginId: string, + publicAddress: string, + addressEntryMethod: AddressEntryMethod, + crossChainDisplayAmount?: string, + /** + * A destination memo the new destination arrived with, from a scanned + * URI. Passed in rather than written by the caller beforehand, because + * the reset below clears the tag and would drop it. + */ + crossChainMemo?: string + ): Promise => { + setRecipientPluginId(destPluginId) + // A new destination chain invalidates any tag and quote held for the old + // one, exactly as picking the recipient asset by hand does. A memo that + // came WITH the new destination survives, since it describes this + // destination rather than the one being left: + setDestinationTag(crossChainMemo) + setSwapQuote(undefined) + setReceiveNativeAmount(undefined) + setGuaranteedSide('send') + setFixedToFallback(false) + + await handleChangeAddress(spendTarget)({ + parsedUri: { publicAddress }, + addressEntryMethod, + crossChainDisplayAmount, + detectedDestPluginId: destPluginId + }) + } + + /** + * The recipient assets the "Myself" picker may offer: the source asset plus + * every chain the provider pays out to. Derived from the route metadata, so + * a chain added there shows up here with no further change. Tokens are + * absent only because `getHoudiniChain` returns undefined for a non-null + * tokenId; when token routes appear they flow through unchanged. + */ + const selfTransferAssets = React.useMemo(() => { + if (!swapSendAllowed || multipleTargets) return undefined + const assets: EdgeAsset[] = [{ pluginId, tokenId }] + for (const chain of HOUDINI_CHAINS) { + if (chain.pluginId === pluginId) continue + if (account.currencyConfig[chain.pluginId] == null) continue + assets.push({ pluginId: chain.pluginId, tokenId: null }) + } + return assets + }, [account, multipleTargets, pluginId, swapSendAllowed, tokenId]) + + /** + * Which of the three send-shaped swap flows this scene just ran. Stealth is + * the toggle; cross-asset is the destination asset differing from the + * source. + */ + const swapSendType: EdgeTxActionSwapType = stealth + ? crossAsset + ? 'stealthSwapSend' + : 'stealthSend' + : 'swapSend' + + /** + * Record the flow on the broadcast transaction's saved action, preserving + * everything the swap plugin already wrote. A failure here costs the + * transaction its title, never the transaction, so it is logged and + * swallowed rather than surfaced over a completed send. + */ + const stampSwapSendAction = async (tx: EdgeTransaction): Promise => { + const { savedAction } = tx + if (savedAction == null || savedAction.actionType !== 'swap') return + const stamped = { ...savedAction, swapType: swapSendType } + // The success scene, and the details scene behind it, render this object + // rather than re-reading the wallet, so it carries the flow too. + tx.savedAction = stamped + try { + // A token send files a second action for its parent-currency fee, built + // from the plugin's own unstamped copy, so that row has no `swapType` + // and no flow identity. Stamping it is best effort, like the stamp + // beside it: the recipient's privacy on that row does NOT rest on this + // call landing, because the details scene suppresses a payout address on + // any network-fee row regardless. What a failure here costs is the row's + // title, not the recipient. + // + // The two writes are independent rows, so they go out together rather + // than one after the other, which held the success scene for two round + // trips on every token send. + await Promise.all([ + coreWallet.saveTxAction({ + txid: tx.txid, + tokenId, + assetAction: tx.assetAction ?? { assetActionType: 'swap' }, + savedAction: stamped + }), + ...(hasParentFeeRow(tx) + ? [ + coreWallet.saveTxAction({ + txid: tx.txid, + tokenId: null, + assetAction: { assetActionType: 'swapNetworkFee' }, + savedAction: stamped + }) + ] + : []) + ]) + } catch (error: unknown) { + console.warn('Could not save the swap-send action type', String(error)) + } + } + + const handleSelfTransferAsset = + (spendTarget: EdgeSpendTarget) => + async (destPluginId: string, address: string): Promise => { + await adoptCrossChainDestination(spendTarget)( + destPluginId, + address, + 'other' + ) + return true + } + + const handleUnparsedAddress = + (spendTarget: EdgeSpendTarget) => + async ( + address: string, + addressEntryMethod: AddressEntryMethod + ): Promise => { + if (!swapSendAllowed || multipleTargets) return false + + const candidates = detectHoudiniChains(address, { + sourcePluginId: pluginId, + sourceTokenId: tokenId, + isSupported: id => account.currencyConfig[id] != null + }) + if (candidates.length === 0) return false + + // An address format shared by several chains (any EVM `0x…`) cannot be + // resolved from the address alone, and guessing would send the funds to + // the wrong network, so the user names the network. + let chain = candidates[0] + if (candidates.length > 1) { + const displayNameToChain = new Map() + const items = candidates.map(candidate => { + const { currencyCode: chainCode, displayName } = + account.currencyConfig[candidate.pluginId].currencyInfo + displayNameToChain.set(displayName, candidate) + return { + name: displayName, + text: chainCode, + icon: ( + + ) + } + }) + const selected = await Airship.show(bridge => ( + + )) + const picked = + selected == null ? undefined : displayNameToChain.get(selected) + // Dismissing the picker is a deliberate cancel, not a bad address. + if (picked == null) return true + chain = picked + } + + const { addressCandidates, displayAmount, memo } = + parsePaymentUri(address) + const publicAddress = addressCandidates.find(candidate => + isValidHoudiniAddress(chain, candidate) + ) + if (publicAddress == null) return false + + // A scanned exchange deposit code carries the tag that credits the + // recipient. Adopting the address and dropping the tag pays the exchange + // with nothing to attribute it to, which is a loss the user cannot see. + const crossChainMemo = + chain.memoNeeded && memo != null && memo !== '' ? memo : undefined + + await adoptCrossChainDestination(spendTarget)( + chain.pluginId, + publicAddress, + addressEntryMethod, + displayAmount, + crossChainMemo + ) + return true + } + const handleAddressAmountPress = (index: number) => (): void => { // This is deleting the combo address/amount tile. If this happens, remove the // lastAddressEntryMethod so we don't auto launch the camera again. @@ -535,9 +1246,28 @@ const SendComponent: React.FC = props => { spendTarget.publicAddress = undefined spendTarget.nativeAmount = undefined spendTarget.memo = spendTarget.uniqueIdentifier = undefined - setError(undefined) + // Through the owners, not `setError` directly: a bare clear leaves + // `swapErrorShown` believing a swap error is still on screen, and the next + // plain-send failure then cannot retract itself. + // + // The expiry flag is lowered BEFORE the retraction, not after: the + // retraction reads that flag and declines while it is raised, so clearing + // in the other order leaves the expiry card on screen over an address the + // user just removed, with the slider re-enabled beneath it. + clearSwapError() + setAddressExpired(false) + clearPlainSendError() setExpireDate(undefined) setPinValue(undefined) + setFixedToFallback(false) + // Clearing the address ends the swap-send: leaving the destination chain, + // tag, receive amount or standing quote behind means the next address + // entered gets quoted against the previous recipient's state. + setSwapQuote(undefined) + setReceiveNativeAmount(undefined) + setGuaranteedSide('send') + setRecipientPluginId(undefined) + setDestinationTag(undefined) setSpendInfo({ ...spendInfo }) // This is deleting the amount tile. If this happens, remove the // lastAddressEntryMethod so we don't auto launch the camera again. @@ -567,6 +1297,42 @@ const SendComponent: React.FC = props => { (publicAddress === '' && lastAddressEntryMethod === 'scan') if (openCameraRef.current) openCameraRef.current = false + // A cross-chain destination address cannot be parsed by the source + // wallet; validate it against the destination chain's own rules: + const crossChainAddressValidation = + swapSendActive && destPluginId !== pluginId + ? ( + address: string, + uri: { scheme?: string; evmChainId?: string } + ) => { + if (destChain == null) return false + // What the code says about its own chain wins over the fact that + // the address happens to validate here. Refusing hands the input + // to `onUnparsedAddress`, which adopts the chain the code names; + // accepting would pay whichever chain was already picked and read + // the URI's amount in that chain's asset. + // + // The chain id is checked first and on its own: every EVM network + // writes `ethereum:`, so on that family the scheme agrees with a + // picked Polygon destination while the id is the only thing that + // disagrees. + if ( + uri.evmChainId != null && + Number(uri.evmChainId) !== destChain.evmChainId + ) { + return false + } + if ( + uri.scheme != null && + uri.evmChainId == null && + !schemeNamesChain(uri.scheme, destChain) + ) { + return false + } + return isValidHoudiniAddress(destChain, address) + } + : undefined + return ( = props => { isCameraOpen={doOpenCamera} recipientName={recipientName} recipientNameService={recipientNameService} + crossChainAddressValidation={crossChainAddressValidation} + onUnparsedAddress={handleUnparsedAddress(spendTarget)} + selfTransfer={ + selfTransferAssets == null + ? undefined + : { + allowedAssets: selfTransferAssets, + onPickCrossAsset: handleSelfTransferAsset(spendTarget) + } + } navigation={navigation as NavigationBase} /> ) @@ -657,6 +1433,8 @@ const SendComponent: React.FC = props => { index: number, spendTarget: EdgeSpendTarget ): React.ReactElement | null => { + // A send-to-address swap renders its own linked amount rows: + if (swapSendActive) return null const { publicAddress, nativeAmount } = spendTarget if (publicAddress != null && hiddenFeaturesMap.amount !== true) { const title = @@ -711,12 +1489,57 @@ const SendComponent: React.FC = props => { if (result?.type !== 'wallet') { return } + const walletChanged = result.walletId !== walletId setWalletId(result.walletId) const { pluginId: newPluginId } = currencyWallets[result.walletId].currencyInfo - if (pluginId !== newPluginId || tokenId !== result.tokenId) { + const assetChanged = + pluginId !== newPluginId || tokenId !== result.tokenId + if (assetChanged) { setTokenId(result.tokenId) - setSpendInfo({ tokenId: result.tokenId, spendTargets: [{}] }) + } + // A new source WALLET invalidates the swap-send destination state, not + // just a new source asset: a held quote carries an order created for + // the old wallet's refund address, so approving it after a switch would + // spend from one wallet against another wallet's order. Switching + // between two wallets on the same asset is the case that used to slip + // through. The fixed-to warning, the Stealth toggle and the learned + // route capabilities go too: all describe the pair and wallet the user + // just left, so carrying them over produces auto-disables and errors + // the user cannot connect to anything they did. + if (walletChanged || assetChanged) { + setRecipientPluginId(undefined) + setDestinationTag(undefined) + setSwapQuote(undefined) + setReceiveNativeAmount(undefined) + setGuaranteedSide('send') + setFixedToFallback(false) + setRateStarvedFallback(false) + setStealth(false) + setRouteCaps({}) + // The message describes the pair and wallet the user just left, the + // same reason the toggle and the route caps go. Through the owners, + // so `swapErrorShown` does not survive the wallet it belonged to. + clearSwapError() + setAddressExpired(false) + clearPlainSendError() + // The recipients go with them whenever the new wallet could not pay + // them: a foreign-chain destination adopted for a swap-send, or a + // different asset. Clearing the destination CHAIN while leaving such + // an address behind drops the scene back into plain-send mode still + // displaying an address the new source wallet cannot pay, one slide + // from a send that can only fail. A plain switch between two wallets + // on the SAME asset is the case that must NOT clear: that address is + // still payable, and wiping it only makes the user type it again. + // Written once, as the whole spend: a second `setSpendInfo` here + // would close over the pre-reset value and put the old targets back. + if (assetChanged || recipientPluginId != null) { + setSpendInfo({ + tokenId: assetChanged ? result.tokenId : tokenId, + spendTargets: [{}], + memos: [] + }) + } } }) .catch((error: unknown) => { @@ -744,7 +1567,505 @@ const SendComponent: React.FC = props => { needsScrollToEnd.current = true }) + // --------------------------------------------------------------------- + // Send-to-address swap handlers + rows + // --------------------------------------------------------------------- + + const handleToggleStealth = useHandler((): void => { + if (multipleTargets) return + // No private route is possible for this asset, pair, or amount: refuse to + // arm and say why, instead of arming a toggle whose quote is guaranteed to + // fail. Turning it back OFF is always allowed. + if (!stealth && stealthBlockedReason != null) { + showToast(stealthBlockedReason) + return + } + // The standing quote was priced under the OTHER privacy setting, so it is + // dead the moment the toggle moves. Drop it here rather than relying on + // the re-quote effect to disable the slider a render later: the whole + // point of the toggle is that a private send is never approved against a + // transparent route, and the reverse. + setSwapQuote(undefined) + setStealth(value => !value) + setPinValue(undefined) + }) + + const handlePickRecipientAsset = useHandler((): void => { + if (multipleTargets) return + // Rows are keyed on the ASSET, never on its label: several chains share a + // currency code (ETH on Base / Arbitrum / Ethereum) and a token can share + // both name and code with a chain (the POL ERC-20 and Polygon), so a + // label-keyed list marks the wrong rows selected and resolves either tap + // to the same destination. + const keyToRecipientPluginId = new Map() + const items = getRecipientAssetChoices({ + sourcePluginId: pluginId, + sourceTokenId: tokenId, + swapSendActive, + servedPluginIds: HOUDINI_CHAINS.filter( + chain => account.currencyConfig[chain.pluginId] != null + ).map(chain => chain.pluginId) + }).flatMap(choice => { + const described = describeAsset(account, choice.asset) + if (described == null) return [] + const value = recipientAssetKey(choice.asset) + keyToRecipientPluginId.set(value, choice.recipientPluginId) + return [ + { + value, + name: described.displayName, + text: described.currencyCode, + icon: ( + + ) + } + ] + }) + + Airship.show(bridge => ( + + )) + .then(selected => { + if (selected == null || !keyToRecipientPluginId.has(selected)) return + const nextPluginId = keyToRecipientPluginId.get(selected) + if (nextPluginId === recipientPluginId) return + // A new destination chain invalidates the entered address and tag, so + // clear the whole recipient first. The reset drops the destination + // chain too, which is why the new one is applied AFTER it: setting it + // first would leave the reset's undefined as the last write. + handleResetSendTransaction(spendInfo.spendTargets[0])() + setRecipientPluginId(nextPluginId) + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + // Swap-send amounts are entered through the standard crypto/fiat flip + // input. The modal resolves its final amounts on close; an untouched or + // zero amount is a dismissal, matching the old text-modal semantics, so + // quotes still fire on commit rather than per keystroke. Max is hidden + // because max spend is not offered in swap-send mode. + // + // Both sides open on fiat, which is what the Exchange scene's amount entry + // and the plain send's already do. It is also the denomination the decision + // is made in here: the provider states its floors in USD, and the two sides + // of a cross-asset send have no common crypto unit to compare in. + const handleEditYouSend = useHandler((): void => { + Airship.show(bridge => ( + + )) + .then(({ nativeAmount }) => { + if (zeroString(nativeAmount)) return + spendInfo.spendTargets[0].nativeAmount = nativeAmount + // The standing quote priced the previous amount, so it is dead the + // moment a new one commits. Drop it here rather than leaving it up + // until the refetch lands, which would keep the slider armed against + // an amount the user just replaced. + setSwapQuote(undefined) + setGuaranteedSide('send') + setFixedToFallback(false) + setSpendInfo({ ...spendInfo }) + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + // The destination is an address, not a wallet, so the flip input borrows + // the user's own wallet on the destination chain for denominations and + // rates. Without one, a plain text modal is the fallback. + const destFlipWallet = Object.values(currencyWallets).find( + wallet => wallet.currencyInfo.pluginId === destPluginId + ) + + const handleEditRecipientGets = useHandler((): void => { + if (destExchangeDenom == null) return + // The pair is known to have no receive-priced route, so an exact receive + // amount cannot be honored. Explain rather than opening an editor whose + // value would immediately bounce back to the send side. + if (pairCaps.fixedTo === false) { + showToast(lstrings.stealth_fixed_to_unavailable_toast) + return + } + if (destFlipWallet != null) { + Airship.show(bridge => ( + + )) + .then(({ nativeAmount }) => { + if (zeroString(nativeAmount)) return + setReceiveNativeAmount(nativeAmount) + setSwapQuote(undefined) + setGuaranteedSide('receive') + setFixedToFallback(false) + }) + .catch((error: unknown) => { + showError(error) + }) + return + } + const startAmount = + receiveNativeAmount == null || zeroString(receiveNativeAmount) + ? '' + : div( + receiveNativeAmount, + destExchangeDenom.multiplier, + DECIMAL_PRECISION + ) + Airship.show(bridge => ( + + )) + .then(amount => { + if (amount == null || amount === '') return + setReceiveNativeAmount(mul(amount, destExchangeDenom.multiplier)) + setSwapQuote(undefined) + setGuaranteedSide('receive') + setFixedToFallback(false) + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + const handleEditDestinationTag = useHandler((): void => { + Airship.show(bridge => ( + + )) + .then(tag => { + if (tag == null) return + changeDestinationTag(tag === '' ? undefined : tag.trim()) + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + const handleSwapQuoteExpired = useHandler((): void => { + // Drop the quote, do not just ask for a new one. Bumping the nonce alone + // left the expired quote in state until the effect got around to running, + // and the slider gates on `swapQuote != null`, so there was a window where + // a slide would approve an order the provider had already retired. + setSwapQuote(undefined) + setSwapQuoteNonce(nonce => nonce + 1) + }) + + /** + * One side of the linked flip inputs. The edited side is the guaranteed + * amount; the other tracks the live quote as an estimate. The state word + * rides in the row's own header, tinted, so the amount below it reads as a + * single uninterrupted line. + */ + const renderSwapAmountRow = ( + title: string, + displayAmount: string, + displayCode: string, + isGuaranteed: boolean, + onPress: () => void, + fiat: SwapRowFiat | undefined + ): React.ReactElement => ( + {`(${lstrings.stealth_guaranteed})`} + ) : ( + {`(${lstrings.stealth_estimated})`} + ) + } + onPress={onPress} + > + + {`${isGuaranteed ? '' : '~ '}${displayAmount} ${displayCode}`} + {fiat == null ? null : ( + <> + {' ('} + + ) + + )} + + + ) + + /** + * What one side of the swap needs for its inline fiat value, or `undefined` + * when there is no amount to convert. `FiatText` owns the formatting, so the + * parenthesised `1.23 LTC ($45.67)` shape matches the rest of the app. + */ + const swapRowFiat = ( + nativeAmount: string | undefined, + rowCurrencyConfig: EdgeCurrencyConfig | undefined, + rowTokenId: EdgeTokenId + ): SwapRowFiat | undefined => { + if ( + nativeAmount == null || + zeroString(nativeAmount) || + rowCurrencyConfig == null + ) { + return undefined + } + return { + nativeAmount, + tokenId: rowTokenId, + currencyConfig: rowCurrencyConfig + } + } + + const renderYouSendRow = (): React.ReactElement => { + const nativeAmount = spendInfo.spendTargets[0].nativeAmount + const displayAmount = zeroString(nativeAmount) + ? '0' + : div( + nativeAmount ?? '0', + cryptoDisplayDenomination.multiplier, + DECIMAL_PRECISION + ) + return renderSwapAmountRow( + lstrings.stealth_you_send, + displayAmount, + currencyCode, + guaranteedSide === 'send', + handleEditYouSend, + swapRowFiat(nativeAmount, coreWallet.currencyConfig, tokenId) + ) + } + + const renderRecipientGetsRow = (): React.ReactElement | null => { + if (destExchangeDenom == null) return null + const displayAmount = + receiveNativeAmount == null || zeroString(receiveNativeAmount) + ? '0' + : div( + receiveNativeAmount, + destExchangeDenom.multiplier, + DECIMAL_PRECISION + ) + return renderSwapAmountRow( + lstrings.stealth_recipient_gets, + displayAmount, + destCurrencyInfo?.currencyCode ?? '', + guaranteedSide === 'receive', + handleEditRecipientGets, + swapRowFiat(receiveNativeAmount, destCurrencyConfig, null) + ) + } + + const renderRecipientReceives = (): React.ReactElement | null => { + if (!swapSendAllowed) return null + const described = describeAsset(account, recipientAsset) + const recipientCurrencyCode = described?.currencyCode ?? currencyCode + const recipientDisplayName = described?.displayName ?? recipientCurrencyCode + return ( + + + + {`${recipientDisplayName} (${recipientCurrencyCode})`} + + + ) + } + + const renderDestinationTagRow = (): React.ReactElement | null => { + if (destChain?.memoNeeded !== true) return null + return ( + + {destinationTag ?? ''} + + ) + } + + const renderSwapQuoteRow = (): React.ReactElement | null => { + if (spendInfo.spendTargets[0].publicAddress == null) return null + if (fetchingSwapQuote) { + return ( + + + {lstrings.stealth_getting_quote} + + + + ) + } + if (swapQuote == null) return null + + // Rate in exchange (standard) units, plus the provider that quoted and + // the shared price-delta indicator: + const fromExchangeAmount = div( + swapQuote.fromNativeAmount, + cryptoExchangeDenomination.multiplier, + DECIMAL_PRECISION + ) + const toExchangeAmount = + destExchangeDenom == null + ? '0' + : div( + swapQuote.toNativeAmount, + destExchangeDenom.multiplier, + DECIMAL_PRECISION + ) + const rate = zeroString(fromExchangeAmount) + ? '0' + : div(toExchangeAmount, fromExchangeAmount, 8) + const providerName = + account.swapConfig[swapQuote.pluginId]?.swapInfo.displayName ?? + swapQuote.pluginId + const priceImpact = calculateQuotePriceImpact( + swapQuote, + exchangeRates, + defaultIsoFiat + ) + + return ( + <> + + + + {`1 ${currencyCode} = ${rate} ${ + destCurrencyInfo?.currencyCode ?? '' + }`} + + + {providerName} + + + {swapQuote.expirationDate == null ? null : ( + + )} + + ) + } + + const renderSwapFeeRow = (): React.ReactElement | null => { + if (swapQuote == null) return null + const { networkFee } = swapQuote + const feeDenom = getExchangeDenom( + coreWallet.currencyConfig, + networkFee.tokenId + ) + const feeDisplayAmount = div( + networkFee.nativeAmount, + feeDenom.multiplier, + DECIMAL_PRECISION + ) + return ( + + {`${feeDisplayAmount} ${feeDenom.name}`} + + ) + } + + const renderStealthToggle = (): React.ReactElement | null => { + if (!swapSendAllowed) return null + return ( + + + + {stealthBlockedReason != null && !stealth ? ( + + ) : stealth ? ( + + ) : null} + + + ) + } + + /** + * With multiple recipients, show the aggregate on one row instead of making + * the reviewer sum the per-recipient amounts. + */ + const renderMultiRecipientTotal = (): React.ReactElement | null => { + if (!multipleTargets) return null + const totalNativeAmount = spendInfo.spendTargets.reduce( + (prev, target) => add(target.nativeAmount ?? '0', prev), + '0' + ) + const totalDisplayAmount = div( + totalNativeAmount, + cryptoDisplayDenomination.multiplier, + DECIMAL_PRECISION + ) + return ( + + {`${totalDisplayAmount} ${currencyCode}`} + + ) + } + const renderAddAddress = (): React.ReactElement | null => { + // Stealth and cross-asset sends support exactly one recipient: + if (swapSendActive) return null const { pluginId } = coreWallet.currencyInfo const maxSpendTargets = getSpecialCurrencyInfo(pluginId)?.maxSpendTargets ?? 1 @@ -782,7 +2103,13 @@ const SendComponent: React.FC = props => { // Caller provided custom expiry handler - call it without showing error onExpired() } else { - // Fall back to showing expiry error message + // The flag, not just the card. The card lives in the shared `error` + // state, which entering swap-send legitimately clears, so on its own it + // let an expired payment request end up behind a live swap quote with the + // slider still armed. Expiry is a property of the REQUEST, so it outlives + // whichever send mode the scene is in and is cleared only by replacing + // the address. + setAddressExpired(true) setError( new I18nError( lstrings.transaction_failure, @@ -813,6 +2140,7 @@ const SendComponent: React.FC = props => { } const renderFees = (): React.ReactElement | null => { + if (swapSendActive) return null if ( spendInfo.spendTargets[0].publicAddress != null && spendInfo.spendTargets[0].nativeAmount != null @@ -965,6 +2293,9 @@ const SendComponent: React.FC = props => { } const renderMemoOptions = (): Array => { + // A send-to-address swap's deposit memo comes from the provider, and the + // recipient's tag is entered on the destination-tag row instead: + if (swapSendActive) return [null] const spendTarget: EdgeSpendTarget | undefined = spendInfo.spendTargets[0] if (spendTarget?.publicAddress == null) return [null] @@ -1196,6 +2527,29 @@ const SendComponent: React.FC = props => { ) } + /** + * A fixed receive amount (typed, or carried by a scanned payment URI) had + * to fall back to a guaranteed SEND amount because the provider offers no + * receive-priced route for this pair. Sits with the scene's other warning + * cards and clears as soon as the user edits an amount. + */ + const renderFixedToFallbackWarning = (): React.ReactElement | null => { + if (!fixedToFallback || !swapSendActive) return null + return ( + + + + ) + } + const renderNymWarning = (): React.ReactElement | null => { if (!isNymActive || !processingAmountChanged) return null @@ -1290,7 +2644,9 @@ const SendComponent: React.FC = props => { const handleSliderComplete = useHandler( async (resetSlider: () => void): Promise => { - if (edgeTransaction == null) return + // The PIN spending limit gates BOTH submit paths, so it is checked + // before either one. It used to sit below the swap-send branch, which + // returns early: a swap-send of any size skipped the PIN entirely. if (pinSpendingLimitsEnabled && spendingLimitExceeded) { const isAuthorized = await account.checkPin(pinValue ?? '') if (!isAuthorized) { @@ -1301,6 +2657,58 @@ const SendComponent: React.FC = props => { } } + // A send-to-address swap submits by approving the live quote. The + // slider is intentionally not reset on success, so a second slide + // cannot fire while the scene transitions to the success scene. + if (swapSendActive) { + // A quote can be retired mid-slide, since the PIN check above awaits + // and every term the quote was priced against retires it. The slider + // latches its spinner until something resets it, so a slide that + // arrives with no quote has to hand it back rather than just return. + // An approval already in flight owns the slider and resets it itself. + // The ref is what says whether the quote is still live: the closed-over + // value is the one this slide started with, so approving it would sign + // an order the scene retired while the PIN check was in flight. + const liveQuote = swapQuoteRef.current + if (liveQuote == null) { + resetSlider() + return + } + if (isApprovingSwapRef.current) return + isApprovingSwapRef.current = true + isSendingRef.current = true + try { + const result = await liveQuote.approve() + // Name the flow on the saved action. Only this scene knows which of + // the three send shapes ran: the plugin sees an ordinary swap, and + // with every send-to-address quote restricted to the privacy + // provider, the winning plugin cannot tell them apart either. + await stampSwapSendAction(result.transaction) + playSendSound().catch((error: unknown) => { + console.log(error) // Fail quietly + }) + // Delay navigation until gesture interactions finish to prevent + // possible crashes, the same as the plain-send path below. The + // slider stays latched either way, so the extra frame cannot let a + // second slide through. + InteractionManager.runAfterInteractions(() => { + navigation.replace('swapSuccess', { + edgeTransaction: result.transaction, + walletId: coreWallet.id + }) + }) + } catch (err: unknown) { + setSwapError(describeSwapError(err)) + resetSlider() + } finally { + isApprovingSwapRef.current = false + isSendingRef.current = false + } + return + } + + if (edgeTransaction == null) return + try { if (beforeTransaction != null) await beforeTransaction() } catch (e: unknown) { @@ -1379,6 +2787,11 @@ const SendComponent: React.FC = props => { broadcastedTx.metadata ??= {} if ( payeeName != null && + // A stealth send must not put the recipient in the transaction + // title, where it would sit in the list next to the amount. The + // payout address is still stored on the swap data, so support can + // trace a stuck order. + !stealth && (broadcastedTx.metadata?.name == null || broadcastedTx.metadata.name === '') ) { @@ -1588,12 +3001,28 @@ const SendComponent: React.FC = props => { // Calculate the transaction useAsyncEffect( async () => { + // A send-to-address swap builds its transaction through the swap quote, + // not through makeSpend: + if (swapSendActive) { + // Retire any plain makeSpend still in flight. Without this its success + // handler lands after the switch and writes plain-send state (a fee, a + // transaction, a cleared error) over a scene that is now quoting. + makeSpendCounter.current++ + setEdgeTransaction(null) + setProcessingAmountChanged(false) + // Entering swap-send mode retracts the plain send's own error, the + // mirror of what the quote effect does on the way out. An + // insufficient-funds message from the direct send would otherwise sit + // over a perfectly good swap quote. Only a plain-send error is cleared + // here; a swap error belongs to the quote effect. + clearPlainSendError() + return + } pendingInsufficientFees.current = undefined try { setProcessingAmountChanged(true) if (spendInfo.spendTargets[0].publicAddress == null) { setEdgeTransaction(null) - setSpendingLimitExceeded(false) setMaxSpendSetter(-1) setProcessingAmountChanged(false) return @@ -1609,30 +3038,6 @@ const SendComponent: React.FC = props => { feeTokenId: null }) } - if (pinSpendingLimitsEnabled) { - const rate = - getExchangeRate( - exchangeRates, - coreWallet.currencyInfo.pluginId, - tokenId, - defaultIsoFiat - ) ?? INFINITY_STRING - const totalNativeAmount = spendInfo.spendTargets.reduce( - (prev, target) => add(target.nativeAmount ?? '0', prev), - '0' - ) - const totalExchangeAmount = div( - totalNativeAmount, - cryptoExchangeDenomination.multiplier, - DECIMAL_PRECISION - ) - const fiatAmount = mul(totalExchangeAmount, rate) - const exceeded = gte( - fiatAmount, - pinSpendingLimitsAmount.toFixed(DECIMAL_PRECISION) - ) - setSpendingLimitExceeded(exceeded) - } if (minNativeAmount != null) { for (const target of spendInfo.spendTargets) { @@ -1679,7 +3084,9 @@ const SendComponent: React.FC = props => { setFeeNativeAmount(feeNativeAmount) flipInputModalRef.current?.setFees({ feeTokenId, feeNativeAmount }) flipInputModalRef.current?.setError(null) - setError(undefined) + // Only the plain send's own error: a swap error belongs to the quote + // effect, and a makeSpend that succeeded says nothing about it. + clearPlainSendError() } catch (err: unknown) { let error = err const insufficientFunds = asMaybeInsufficientFundsError(error) @@ -1751,15 +3158,305 @@ const SendComponent: React.FC = props => { } setProcessingAmountChanged(false) }, - [spendInfo, maxSpendSetter, walletId, pinSpendingLimitsEnabled, pinValue], + [ + spendInfo, + maxSpendSetter, + walletId, + pinSpendingLimitsEnabled, + pinValue, + swapSendActive + ], 'SendComponent' ) + // Fetch the send-to-address swap quote. Quotes are requested when the + // guaranteed-side amount commits (not per keystroke), and re-requested when + // the destination, tag, or expiry nonce changes. Toggling stealth flips + // `swapSendActive` on same-asset pairs; on cross-asset pairs the request is + // identical either way, so the toggle alone never re-quotes. + useAsyncEffect( + async () => { + // EVERY run of this effect retires any request still in flight from a + // previous run, the early returns below included. Bumping only on the + // paths that fetch let a request issued before the amount fell under the + // floor land afterwards and re-arm the slider under it. + const generation = ++swapQuoteGeneration.current + + if (!swapSendActive) { + setSwapQuote(undefined) + setFetchingSwapQuote(false) + // Leaving swap-send mode retracts the swap's own error. Without this a + // minimum-amount or unroutable-pair message from a cross-asset or + // stealth attempt stayed on screen over the plain same-asset send the + // user just switched to. + clearSwapError() + return + } + const toAddress = spendInfo.spendTargets[0].publicAddress + const sendNativeAmount = spendInfo.spendTargets[0].nativeAmount + const quoteNativeAmount = + guaranteedSide === 'send' ? sendNativeAmount : receiveNativeAmount + if ( + toAddress == null || + toAddress === '' || + quoteNativeAmount == null || + zeroString(quoteNativeAmount) + ) { + setSwapQuote(undefined) + setFetchingSwapQuote(false) + return + } + + // Houdini refuses an order under its floor, so the refusal is spelled + // out here instead of spent on a request. It also keeps a user tapping + // through small amounts from burning the provider's rate limit, whose + // 429s would come back looking like unavailable routes. + if (belowActiveFloor) { + setSwapQuote(undefined) + setFetchingSwapQuote(false) + setSwapError( + new I18nError( + lstrings.exchange_generic_error_title, + sprintf( + stealth + ? lstrings.stealth_below_private_minimum_1s + : lstrings.stealth_below_standard_minimum_1s, + formatUsdFloor( + stealth ? HOUDINI_MIN_USD.private : HOUDINI_MIN_USD.standard + ) + ) + ) + ) + return + } + + setFetchingSwapQuote(true) + try { + const toMemos: EdgeMemo[] = + destinationTag == null || destinationTag === '' + ? [] + : [ + { + type: destCurrencyInfo?.memoOptions?.[0]?.type ?? 'text', + value: destinationTag + } + ] + + // EVERY send-to-address quote is restricted to the Houdini privacy + // provider, stealth toggle on or off: send-to-any is a privacy + // feature and must never fan out to other swap providers. The toggle + // decides whether the route itself must be private: without + // `privacy: 'required'` Houdini may answer with a standard route, + // which is correct for a plain Swap & Send and would silently + // downgrade a Stealth one. + const quotes = await account.fetchSwapQuotes( + { + fromWallet: coreWallet, + fromTokenId: tokenId, + toTokenId: null, + toAddressInfo: { + toPluginId: destPluginId, + toAddress, + toMemos + }, + nativeAmount: quoteNativeAmount, + quoteFor: guaranteedSide === 'send' ? 'from' : 'to', + privacy: stealth ? 'required' : undefined + }, + makeStealthSwapRequestOptions(account, undefined, { + ignoreProviderSetting: true + }) + ) + const quote = quotes[0] + + if (generation !== swapQuoteGeneration.current) return + if (quote == null) { + // fetchSwapQuotes normally throws when nothing can route, but it + // resolves with an empty list if every plugin simply declines. + // Reading toNativeAmount off that would crash the scene. + setSwapQuote(undefined) + setSwapError( + new I18nError( + lstrings.trade_option_no_quotes_title, + lstrings.trade_option_no_quotes_body + ) + ) + return + } + setSwapQuote(quote) + clearSwapError() + setRateStarvedFallback(false) + // Update the estimated side from the live quote: + if (guaranteedSide === 'send') { + setReceiveNativeAmount(quote.toNativeAmount) + } else { + spendInfo.spendTargets[0].nativeAmount = quote.fromNativeAmount + setSpendInfo({ ...spendInfo }) + } + needsScrollToEnd.current = true + } catch (err: unknown) { + if (generation !== swapQuoteGeneration.current) return + setSwapQuote(undefined) + // A missing route is a capability of the PAIR, not a transient fault: + // remember it, degrade to what the provider does offer, and say so. + // Amount errors (below/above limit) fall through to the error card, + // since those routes exist and the amount is the problem. + if (asMaybeSwapCurrencyError(err) != null) { + if (guaranteedSide === 'receive') { + // No receive-priced route. Guarantee the send side instead, + // seeded from display rates so the send stays actionable, and + // warn that the recipient amount is no longer exact. + markRouteCap('fixedTo') + // Read the rates through the ref, not the closure. This effect + // deliberately does not depend on `exchangeRates` (see its + // dependency list), so the captured copy can predate a rate that + // has since loaded. + const { rates, isoFiat } = ratesRef.current + const destRate = getExchangeRate(rates, destPluginId, null, isoFiat) + const srcRate = getExchangeRate(rates, pluginId, tokenId, isoFiat) + if ( + receiveNativeAmount != null && + destExchangeDenom != null && + destRate > 0 && + srcRate > 0 + ) { + const receiveExchange = div( + receiveNativeAmount, + destExchangeDenom.multiplier, + DECIMAL_PRECISION + ) + const fromExchange = div( + mul(receiveExchange, String(destRate)), + String(srcRate), + DECIMAL_PRECISION + ) + spendInfo.spendTargets[0].nativeAmount = toFixed( + mul(fromExchange, cryptoExchangeDenomination.multiplier), + 0, + 0 + ) + setSpendInfo({ ...spendInfo }) + setGuaranteedSide('send') + setFixedToFallback(true) + setRateStarvedFallback(false) + clearSwapError() + showToast(lstrings.stealth_fixed_to_unavailable_toast) + } else { + // The send side cannot be seeded without a rate on both ends, + // and switching to it empty strands the scene: the quote effect + // returns early on a zero send amount, so the user would be left + // holding a warning with no quote and no way to get one. Show + // the provider's own error instead, and remember that a rate is + // all that was missing, so the retry below can take over once + // one arrives. + setSwapError(describeSwapError(err)) + setRateStarvedFallback(true) + } + } else if (stealth && !crossAssetPicked) { + // No private route, and the toggle is the only thing making this a + // swap: turning it off degrades to a plain same-chain send, so do + // that and say why. That covers a token send to its own chain too, + // which pays out native and so is cross-ASSET but still degrades. + // Once a recipient asset has been adopted the send is + // Houdini-routed either way, so disabling the toggle cannot help; + // fall through to the error card instead. + markRouteCap('stealth') + setStealth(false) + clearSwapError() + showToast(lstrings.stealth_route_unavailable_toast) + } else { + setSwapError(describeSwapError(err)) + } + } else { + setSwapError(describeSwapError(err)) + } + } finally { + if (generation === swapQuoteGeneration.current) { + setFetchingSwapQuote(false) + } + } + }, + [ + swapSendActive, + // The toggle changes the request: it decides whether the route must be + // private, and which floor applies. Leaving it out left a Stealth quote + // showing standard-route pricing on a cross-asset pair, which is the + // re-quote gap the feedback round called out. + stealth, + belowActiveFloor, + // The order is created against THIS wallet's refund address, so a switch + // to another wallet on the same asset must re-quote rather than keep the + // previous wallet's order armed. + coreWallet.id, + spendInfo.spendTargets[0].publicAddress, + guaranteedSide, + guaranteedSide === 'send' + ? spendInfo.spendTargets[0].nativeAmount + : receiveNativeAmount, + destPluginId, + destinationTag, + swapQuoteNonce + ], + 'SendComponent:swapQuote' + ) + + // Mirror the quote into its ref, so a read that resumes after an await sees + // the retirement rather than the value its render closed over. + React.useEffect(() => { + swapQuoteRef.current = swapQuote + }, [swapQuote]) + + // Retry ONCE when the rate the fixed-to fallback was missing finally loads. + // The fallback runs inside the quote effect, which cannot depend on + // `exchangeRates` without re-quoting on every rate tick, so a failure that + // happened before rates loaded would otherwise sit on a hard error until the + // user edited a field. Keyed on the rates becoming usable rather than on the + // rates object changing, so a later tick cannot trigger a second request. + React.useEffect(() => { + if (!rateStarvedFallback) return + const destRate = getExchangeRate( + exchangeRates, + destPluginId, + null, + defaultIsoFiat + ) + const srcRate = getExchangeRate( + exchangeRates, + pluginId, + tokenId, + defaultIsoFiat + ) + if (destRate <= 0 || srcRate <= 0) return + setRateStarvedFallback(false) + setSwapQuoteNonce(nonce => nonce + 1) + }, [ + defaultIsoFiat, + destPluginId, + exchangeRates, + pluginId, + rateStarvedFallback, + tokenId + ]) + const showSlider = spendInfo.spendTargets[0].publicAddress != null let disableSlider = false let disabledText: string | undefined - if ( + if (swapSendActive) { + // A send-to-address swap submits its live quote: + disableSlider = swapQuote == null || fetchingSwapQuote + // Same PIN gate the plain-send branch below applies: without this the + // swap-send slider stayed live and never prompted for the PIN. + if ( + !disableSlider && + pinSpendingLimitsEnabled && + spendingLimitExceeded && + (pinValue?.length ?? 0) < PIN_MAX_LENGTH + ) { + disableSlider = true + disabledText = lstrings.spending_limits_enter_pin + } + } else if ( edgeTransaction == null || processingAmountChanged || (zeroString(spendInfo.spendTargets[0].nativeAmount) && @@ -1779,6 +3476,12 @@ const SendComponent: React.FC = props => { disableSlider = true } + // An expired payment request cannot be paid in EITHER mode, so this sits + // outside the swap-send branch above. + if (addressExpired) { + disableSlider = true + } + const accentColors: AccentColors = { // Transparent fallback for while iconColor is loading iconAccentColor: iconColor ?? '#00000000' @@ -1847,19 +3550,33 @@ const SendComponent: React.FC = props => { {renderSelectedWallet()} {renderSelectFioAddress()} + {swapSendActive && + spendInfo.spendTargets[0].publicAddress != null + ? renderYouSendRow() + : null} + {swapSendActive ? renderSwapFeeRow() : null} + {renderRecipientReceives()} {renderAddressAmountPairs()} + {swapSendActive && + spendInfo.spendTargets[0].publicAddress != null + ? renderRecipientGetsRow() + : null} + {swapSendActive ? renderDestinationTagRow() : null} + {swapSendActive ? renderSwapQuoteRow() : null} {renderTimeout()} {renderAddAddress()} + {renderStealthToggle()} + {renderMultiRecipientTotal()} {renderFees()} {renderMetadataNotes()} {renderMemoOptions()} @@ -1871,6 +3588,7 @@ const SendComponent: React.FC = props => { {renderScamWarning()} {renderPendingTransactionWarning()} + {renderFixedToFallbackWarning()} {renderNymWarning()} {renderError()} {sliderTopNode} @@ -1880,6 +3598,11 @@ const SendComponent: React.FC = props => { @@ -1909,6 +3632,23 @@ const getStyles = cacheStyles((theme: Theme) => ({ calcFeeView: { flexDirection: 'row' }, + swapAmountRow: { + alignItems: 'flex-start' + }, + swapAmountText: { + fontSize: theme.rem(1) + }, + swapAssetRow: { + flexDirection: 'row', + alignItems: 'center', + // Match the visual title-to-body gap of a text row: an icon fills its + // box, so it lacks the font line-box whitespace a text body carries. + marginTop: theme.rem(0.375) + }, + providerHint: { + fontSize: theme.rem(0.75), + color: theme.secondaryText + }, calcFeeSpinner: { marginLeft: theme.rem(1) }, @@ -1925,3 +3665,23 @@ const getStyles = cacheStyles((theme: Theme) => ({ height: 0 } })) + +/** + * The display name and currency code for an asset, or `undefined` when the + * account has no plugin or token for it. + */ +function describeAsset( + account: EdgeAccount, + asset: EdgeAsset +): { currencyCode: string; displayName: string } | undefined { + const currencyConfig = account.currencyConfig[asset.pluginId] + if (currencyConfig == null) return undefined + if (asset.tokenId == null) { + const { currencyCode, displayName } = currencyConfig.currencyInfo + return { currencyCode, displayName } + } + const token = currencyConfig.allTokens[asset.tokenId] + if (token == null) return undefined + const { currencyCode, displayName } = token + return { currencyCode, displayName } +} diff --git a/src/components/themed/StealthInfoText.tsx b/src/components/themed/StealthInfoText.tsx new file mode 100644 index 00000000000..6a71051151e --- /dev/null +++ b/src/components/themed/StealthInfoText.tsx @@ -0,0 +1,69 @@ +import * as React from 'react' +import { View } from 'react-native' + +import { STEALTH_LEARN_MORE_URI } from '../../constants/stealthConstants' +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import { openBrowserUri } from '../../util/WebUtils' +import { showError } from '../services/AirshipInstance' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { EdgeText } from './EdgeText' + +interface Props { + /** What the line says: the explanation, or why the toggle cannot be armed. */ + message: string + /** + * Whether to offer the "Learn more" link. A blocked-reason line does not: + * the article explains the feature, not why this particular send is + * ineligible. + */ + showLearnMore?: boolean +} + +/** + * The explanatory line under a Stealth toggle. + * + * Both toggles render it, the send scene's and the swap amount-entry scene's, + * with the same styles and the same "Learn more" target, so it lives in one + * place rather than twice. Only the message differs. + */ +export const StealthInfoText: React.FC = props => { + const { message, showLearnMore = false } = props + const theme = useTheme() + const styles = getStyles(theme) + + const handleLearnMore = useHandler((): void => { + openBrowserUri(STEALTH_LEARN_MORE_URI).catch((error: unknown) => { + showError(error) + }) + }) + + return ( + + + {message} + {showLearnMore ? ' ' : null} + {showLearnMore ? ( + + {lstrings.stealth_learn_more} + + ) : null} + + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + container: { + paddingHorizontal: theme.rem(1), + paddingBottom: theme.rem(0.75) + }, + text: { + color: theme.secondaryText, + fontSize: theme.rem(0.75) + }, + link: { + color: theme.textLink, + fontSize: theme.rem(0.75) + } +})) diff --git a/src/constants/stealthConstants.ts b/src/constants/stealthConstants.ts new file mode 100644 index 00000000000..3842a90eca4 --- /dev/null +++ b/src/constants/stealthConstants.ts @@ -0,0 +1,15 @@ +/** + * Where both Stealth toggles' "Learn more" sends the user. + * + * PLACEHOLDER, and a merge blocker: this is Edge's support home, not the + * Stealth Send article, so the link is correct-but-unhelpful until that article + * exists. The final URL is a single edit here, since the send scene and the swap + * amount-entry scene both read this constant. + * + * It points at an Edge-controlled host on purpose. The first placeholder was a + * personal gist, which a security review called out: "Learn more" is opened from + * inside a privacy flow the user is trusting with fund movement, so a mutable + * third-party page there is a UI-steering vector whoever controls it, and a + * placeholder is exactly the kind of link that survives longer than intended. + */ +export const STEALTH_LEARN_MORE_URI = 'https://edgeapp.zendesk.com/hc/en-us' diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 1c59ebf189f..040a44ded6b 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1652,6 +1652,39 @@ const strings = { // Send Scene send_scene_send_from_wallet: 'Send from Wallet', send_scene_send_to_address: 'Send to Address', + stealth_send_toggle: 'Stealth Send', + stealth_send_info: + 'Uses a route that helps obfuscate the on-chain link between source and destination wallets.', + stealth_learn_more: 'Learn more', + stealth_you_send: 'You send', + stealth_recipient_gets: 'Recipient gets', + stealth_recipient_receives: 'Recipient receives', + stealth_guaranteed: 'Guaranteed', + stealth_estimated: 'Estimated', + stealth_slide_send: 'Slide to send stealthily', + stealth_quote_rate: 'Exchange Rate', + stealth_quote_expires: 'Quote Expires', + stealth_getting_quote: 'Getting quote...', + stealth_multi_recipient_unsupported: + 'Stealth Send and cross-asset recipients are not available when sending to multiple recipients.', + stealth_route_unavailable_toast: + 'Private routing is not available for this pair right now. Stealth Send has been turned off.', + stealth_route_unavailable_info: + 'Private routing is not available for this pair right now.', + stealth_self_private_unsupported_1s: + 'Private routing is not available when sending %1$s to itself.', + stealth_below_private_minimum_1s: + 'Private routing needs at least %1$s. Enter a larger amount to send privately.', + stealth_below_standard_minimum_1s: + 'The provider needs at least %1$s to route this send. Enter a larger amount.', + stealth_fixed_to_unavailable_toast: + 'The provider cannot guarantee an exact receive amount for this pair. The send amount is now the guaranteed side.', + stealth_fixed_to_fallback_title: 'Receive amount is an estimate', + stealth_fixed_to_fallback_body: + 'The provider could not guarantee the requested receive amount, so the send amount is now guaranteed instead and the recipient amount is an estimate from current rates. Edit either amount to continue.', + stealth_detected_network_title: 'Which network is this address on?', + stealth_detected_network_message: + 'This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.', send_scene_error_title: 'Error:', send_scene_metadata_name_title: 'Payee', send_make_spend_xrp_dest_tag_length_error: diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index abce91b6c5c..18281af3560 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1298,6 +1298,29 @@ "loan_welcome_6s": "Welcome to DeFi for Everyone!\n\nUse your %3$s without selling it! Utilize the %2$s DeFi protocol to easily post your %3$s as collateral and borrow USD at rates as low as 1.5%% APR. All without counterparty risk since neither %1$s nor any other company controls your loan collateral.\n\n%1$s simplifies the process by automatically converting and depositing your %3$s into %2$s and withdrawing up to 50%% of it's value in %4$s or even directly depositing into your bank account. Create a loan as little as $%5$s with just $%6$s of %3$s collateral.", "send_scene_send_from_wallet": "Send from Wallet", "send_scene_send_to_address": "Send to Address", + "stealth_send_toggle": "Stealth Send", + "stealth_send_info": "Uses a route that helps obfuscate the on-chain link between source and destination wallets.", + "stealth_learn_more": "Learn more", + "stealth_you_send": "You send", + "stealth_recipient_gets": "Recipient gets", + "stealth_recipient_receives": "Recipient receives", + "stealth_guaranteed": "Guaranteed", + "stealth_estimated": "Estimated", + "stealth_slide_send": "Slide to send stealthily", + "stealth_quote_rate": "Exchange Rate", + "stealth_quote_expires": "Quote Expires", + "stealth_getting_quote": "Getting quote...", + "stealth_multi_recipient_unsupported": "Stealth Send and cross-asset recipients are not available when sending to multiple recipients.", + "stealth_route_unavailable_toast": "Private routing is not available for this pair right now. Stealth Send has been turned off.", + "stealth_route_unavailable_info": "Private routing is not available for this pair right now.", + "stealth_self_private_unsupported_1s": "Private routing is not available when sending %1$s to itself.", + "stealth_below_private_minimum_1s": "Private routing needs at least %1$s. Enter a larger amount to send privately.", + "stealth_below_standard_minimum_1s": "The provider needs at least %1$s to route this send. Enter a larger amount.", + "stealth_fixed_to_unavailable_toast": "The provider cannot guarantee an exact receive amount for this pair. The send amount is now the guaranteed side.", + "stealth_fixed_to_fallback_title": "Receive amount is an estimate", + "stealth_fixed_to_fallback_body": "The provider could not guarantee the requested receive amount, so the send amount is now guaranteed instead and the recipient amount is an estimate from current rates. Edit either amount to continue.", + "stealth_detected_network_title": "Which network is this address on?", + "stealth_detected_network_message": "This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.", "send_scene_error_title": "Error:", "send_scene_metadata_name_title": "Payee", "send_make_spend_xrp_dest_tag_length_error": "XRP Destination Tag must be 10 characters or less", diff --git a/src/util/houdiniChains.ts b/src/util/houdiniChains.ts index 1e982bd4aa5..507e1fabe57 100644 --- a/src/util/houdiniChains.ts +++ b/src/util/houdiniChains.ts @@ -1,5 +1,6 @@ import type { EdgeTokenId } from 'edge-core-js' +import type { EdgeAsset } from '../types/types' import { parsePaymentUri } from './paymentUri' /** @@ -459,3 +460,91 @@ export function schemeNamesChain(scheme: string, chain: HoudiniChain): boolean { chain.houdiniShortName.toLowerCase() === schemeLower ) } + +/** + * The asset the recipient actually receives. + * + * A swap-send always pays out the destination chain's NATIVE asset, because + * the quote asks for `toTokenId: null` and token destinations are not offered + * at all. A plain send delivers the source asset verbatim, token included. + * Both the "Recipient receives" row and that row's picker read this, so the + * two cannot drift: naming the source token while the order paid out the + * chain's own coin told a USDT sender their recipient receives USDT. + */ +export function getRecipientAsset(opts: { + sourcePluginId: string + sourceTokenId: EdgeTokenId + /** `recipientPluginId ?? sourcePluginId`. */ + destPluginId: string + swapSendActive: boolean +}): EdgeAsset { + const { destPluginId, sourcePluginId, sourceTokenId, swapSendActive } = opts + return swapSendActive + ? { pluginId: destPluginId, tokenId: null } + : { pluginId: sourcePluginId, tokenId: sourceTokenId } +} + +/** One row of the "Recipient receives" picker. */ +export interface RecipientAssetChoice { + /** The asset this row names, which is what the recipient would receive. */ + asset: EdgeAsset + /** + * What `recipientPluginId` becomes when this row is picked. `undefined` + * clears the explicit destination chain, leaving the source chain. + */ + recipientPluginId: string | undefined +} + +/** + * The rows of the "Recipient receives" picker, in display order. + * + * The first row is whatever `getRecipientAsset` says the recipient gets with + * no destination chain adopted, so the picker and the row it edits always name + * the same asset. The rest are the served destination chains, never including + * the source chain: the first row already stands for it, and offering it again + * gives two rows that quote identically and differ only in whether turning + * Stealth off degrades to a plain send, which no user can tell apart. + * + * Callers must key the rows on the asset rather than on its display name. The + * POL ERC-20 on Ethereum and the Polygon chain share both their name and their + * currency code, so a name-keyed list marks both selected and resolves either + * tap to the same row. + */ +export function getRecipientAssetChoices(opts: { + sourcePluginId: string + sourceTokenId: EdgeTokenId + swapSendActive: boolean + /** Served destination chains, already filtered to what the account holds. */ + servedPluginIds: string[] +}): RecipientAssetChoice[] { + const { servedPluginIds, sourcePluginId, sourceTokenId, swapSendActive } = + opts + return [ + { + asset: getRecipientAsset({ + sourcePluginId, + sourceTokenId, + destPluginId: sourcePluginId, + swapSendActive + }), + recipientPluginId: undefined + }, + ...servedPluginIds + .filter(pluginId => pluginId !== sourcePluginId) + .map(pluginId => ({ + asset: { pluginId, tokenId: null }, + recipientPluginId: pluginId + })) + ] +} + +/** + * A stable per-row identity for the picker, since a display name is not one. + * Natives key on the pluginId alone so the value reads as the chain, which is + * also what the row's `testID` becomes. + */ +export function recipientAssetKey(asset: EdgeAsset): string { + return asset.tokenId == null + ? asset.pluginId + : `${asset.pluginId}:${asset.tokenId}` +} From 8f48d0aa119d21fb811192bc162cf31e2a48e3f6 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:27:57 -0700 Subject: [PATCH 12/18] Add Stealth Swap to the existing swap flow A Stealth Swap toggle on the amount-entry scene restricts the quote request to the Houdini privacy provider and asks it for a private route, with a working "Learn more" link. The confirmation scene keeps the restriction on its re-quotes and renders the powered-by card as a fixed provider, with no chevron and no "tap to change provider" hint, through a now-optional PoweredByCard onPress. A pair the provider cannot route privately turns the toggle off and returns the user to the filled-in form rather than a dead-end error, through a new optional onError hook on swapProcessing that gets first refusal on the failure. --- src/components/cards/PoweredByCard.tsx | 25 +++- .../scenes/SwapConfirmationScene.tsx | 141 +++++++++++------- src/components/scenes/SwapCreateScene.tsx | 96 ++++++++++-- src/locales/en_US.ts | 5 + src/locales/strings/enUS.json | 3 + 5 files changed, 195 insertions(+), 75 deletions(-) diff --git a/src/components/cards/PoweredByCard.tsx b/src/components/cards/PoweredByCard.tsx index 5b0bdc53782..a96412bbec5 100644 --- a/src/components/cards/PoweredByCard.tsx +++ b/src/components/cards/PoweredByCard.tsx @@ -11,18 +11,23 @@ import { EdgeCard } from './EdgeCard' interface Props { poweredByText: string iconUri?: string - onPress: () => Promise | void + // When omitted, the card is not tappable: no chevron and no + // "tap to change provider" hint are shown (e.g. a fixed-provider swap). + onPress?: () => Promise | void } /** * Small card that displays "Powered by {provider}" with an optional logo. - * Tapping the card triggers `onPress` to change the active provider. + * Tapping the card triggers `onPress` to change the active provider. When + * `onPress` is omitted the card is static (no chevron) to indicate the + * provider cannot be changed. */ export const PoweredByCard: React.FC = (props: Props) => { const { iconUri, poweredByText, onPress } = props const theme = useTheme() const styles = getStyles(theme) const iconSrc = iconUri == null ? {} : { uri: iconUri } + const tappable = onPress != null return ( @@ -40,13 +45,17 @@ export const PoweredByCard: React.FC = (props: Props) => { {poweredByText} - - - {lstrings.tap_to_change_provider} - - + {tappable ? ( + + + {lstrings.tap_to_change_provider} + + + ) : null} - + {tappable ? ( + + ) : null} diff --git a/src/components/scenes/SwapConfirmationScene.tsx b/src/components/scenes/SwapConfirmationScene.tsx index 5aba519cc4b..03f7586a429 100644 --- a/src/components/scenes/SwapConfirmationScene.tsx +++ b/src/components/scenes/SwapConfirmationScene.tsx @@ -1,6 +1,10 @@ import { useIsFocused } from '@react-navigation/native' -import { add, div, gt, gte, lte, sub, toFixed } from 'biggystring' -import type { EdgeSwapQuote, EdgeSwapResult } from 'edge-core-js' +import { add, div, gt, gte, toFixed } from 'biggystring' +import { + asMaybeSwapCurrencyError, + type EdgeSwapQuote, + type EdgeSwapResult +} from 'edge-core-js' import React, { useState } from 'react' import { SectionList, type ViewStyle } from 'react-native' import { sprintf } from 'sprintf-js' @@ -25,6 +29,10 @@ import type { GuiSwapInfo } from '../../types/types' import { getSwapPluginIconUri } from '../../util/CdnUris' import { CryptoAmount } from '../../util/CryptoAmount' import { logActivity } from '../../util/logger' +import { + makeStealthSwapRequestOptions, + requireDestinationWallet +} from '../../util/stealthSwap' import { logEvent } from '../../util/tracking' import { convertNativeToExchange, DECIMAL_PRECISION } from '../../util/utils' import { AlertCardUi4 } from '../cards/AlertCard' @@ -46,20 +54,29 @@ import { EdgeModal } from '../modals/EdgeModal' import { swapVerifyTerms } from '../modals/SwapVerifyTermsModal' import { CircleTimer } from '../progress-indicators/CircleTimer' import { SwapProviderRow } from '../rows/SwapProviderRow' -import { Airship, showError } from '../services/AirshipInstance' +import { Airship, showError, showToast } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { ExchangeQuote } from '../themed/ExchangeQuoteComponent' import { LineTextDivider } from '../themed/LineTextDivider' import { ModalFooter } from '../themed/ModalParts' +import { + calculateQuotePriceImpact, + PRICE_IMPACT_WARNING_THRESHOLD +} from '../themed/PriceImpactText' import { SafeSlider } from '../themed/SafeSlider' import { WalletListSectionHeader } from '../themed/WalletListSectionHeader' -const PRICE_IMPACT_WARNING_THRESHOLD = 0.05 - export interface SwapConfirmationParams { selectedQuote: EdgeSwapQuote quotes: EdgeSwapQuote[] onApprove: () => void + + /** + * A Stealth Swap routes through the Houdini privacy provider as a fixed + * provider: the powered-by card is not tappable and a re-quote keeps the + * provider restriction. + */ + stealth?: boolean } interface Props extends SwapTabSceneProps<'swapConfirmation'> {} @@ -71,7 +88,7 @@ interface Section { export const SwapConfirmationScene: React.FC = (props: Props) => { const { route, navigation } = props - const { quotes, onApprove } = route.params + const { quotes, onApprove, stealth = false } = route.params const dispatch = useDispatch() const theme = useTheme() @@ -93,6 +110,8 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { ) const [pending, setPending] = useState(false) + /** The quote's timer ran out; nothing on screen may be approved any more. */ + const [expired, setExpired] = useState(false) const swapRequestOptions = useSwapRequestOptions() @@ -126,44 +145,11 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { const { request } = selectedQuote const { quoteFor } = request - const priceImpact = React.useMemo(() => { - const { fromWallet, fromTokenId, toWallet, toTokenId } = request - - const fromExchangeDenom = getExchangeDenom( - fromWallet.currencyConfig, - fromTokenId - ) - const toExchangeDenom = getExchangeDenom(toWallet.currencyConfig, toTokenId) - - const fromExchangeAmount = convertNativeToExchange( - fromExchangeDenom.multiplier - )(selectedQuote.fromNativeAmount) - const toExchangeAmount = convertNativeToExchange( - toExchangeDenom.multiplier - )(selectedQuote.toNativeAmount) - - const fromFiatValue = convertCurrency( - exchangeRates, - fromWallet.currencyInfo.pluginId, - fromTokenId, - defaultIsoFiat, - fromExchangeAmount - ) - const toFiatValue = convertCurrency( - exchangeRates, - toWallet.currencyInfo.pluginId, - toTokenId, - defaultIsoFiat, - toExchangeAmount - ) - - if (lte(fromFiatValue, '0')) return undefined - - const impact = parseFloat( - div(sub(fromFiatValue, toFiatValue), fromFiatValue, 8) - ) - return impact > 0 ? impact : undefined - }, [selectedQuote, exchangeRates, defaultIsoFiat, request]) + const priceImpact = React.useMemo( + () => + calculateQuotePriceImpact(selectedQuote, exchangeRates, defaultIsoFiat), + [selectedQuote, exchangeRates, defaultIsoFiat] + ) const showPriceImpact = priceImpact != null && priceImpact >= PRICE_IMPACT_WARNING_THRESHOLD @@ -209,14 +195,25 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { const handleExchangeTimerExpired = useHandler(() => { if (!isFocused) return + // The quote is dead whether or not we can leave this scene yet. Recording + // it disables the slider immediately, which matters in the terms-check + // case below, where the navigation away is deferred until the modal + // resolves and the scene stays on screen in the meantime. + setExpired(true) if (termsCheckPending.current) { timerExpiredDuringTerms.current = true return } navigation.replace('swapProcessing', { - swapRequest: selectedQuote.request, - swapRequestOptions, + // The re-quote carries the same privacy demand the original did, so an + // expired stealth quote cannot be replaced by a transparent route. + swapRequest: stealth + ? { ...selectedQuote.request, privacy: 'required' } + : selectedQuote.request, + swapRequestOptions: stealth + ? makeStealthSwapRequestOptions(account, swapRequestOptions) + : swapRequestOptions, onCancel: () => { navigation.navigate('swapTab', { screen: 'swapCreate' }) }, @@ -224,8 +221,31 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { navigation.replace('swapConfirmation', { selectedQuote: quotes[0], quotes, - onApprove + onApprove, + stealth }) + }, + onError: error => { + // Same degrade SwapCreateScene applies when a stealth quote finds no + // private route: without it an expiring stealth quote on a pair that + // lost its route dead-ends on the generic error instead of offering + // the standard swap. + const { fromWallet, fromTokenId, toWallet, toTokenId } = + selectedQuote.request + if (!stealth || toWallet == null) return false + if (asMaybeSwapCurrencyError(error) == null) return false + showToast(lstrings.stealth_swap_route_unavailable_toast) + navigation.navigate('swapTab', { + screen: 'swapCreate', + params: { + fromWalletId: fromWallet.id, + fromTokenId, + toWalletId: toWallet.id, + toTokenId, + disableStealth: true + } + }) + return true } }) }) @@ -282,7 +302,8 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { request } = selectedQuote // Both fromCurrencyCode and toCurrencyCode will exist, since we set them: - const { toWallet, toTokenId, fromWallet, fromTokenId } = request + const { toTokenId, fromWallet, fromTokenId } = request + const toWallet = requireDestinationWallet(request) try { dispatch(logEvent('Exchange_Shift_Start')) @@ -488,11 +509,20 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { /> - + {stealth ? ( + // A stealth swap's provider is fixed, so the card is not + // tappable (no chevron, no "tap to change provider"): + + ) : ( + + )} {selectedQuote.isEstimate && !showPriceImpact ? ( @@ -521,7 +551,7 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { {renderTimer()} @@ -556,7 +586,8 @@ const getSwapInfo = ( // Currency conversion tools: // Both fromCurrencyCode and toCurrencyCode will exist, since we set them: const { request } = quote - const { fromWallet, toWallet, fromTokenId, toTokenId } = request + const { fromWallet, fromTokenId, toTokenId } = request + const toWallet = requireDestinationWallet(request) // Format from amount: const fromDisplayDenomination = selectDisplayDenom( diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index f42ec8d90b1..c85b59fc776 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -25,11 +25,17 @@ import { useDispatch, useSelector } from '../../types/reactRedux' import type { NavigationBase, SwapTabSceneProps } from '../../types/routerTypes' import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { getWalletName } from '../../util/CurrencyWalletHelpers' +import { + makeStealthSwapRequestOptions, + requireDestinationWallet +} from '../../util/stealthSwap' +import type { SwapErrorDisplayInfo } from '../../util/swapErrorDisplay' import { zeroString } from '../../util/utils' import { EdgeButton } from '../buttons/EdgeButton' import { KavButtons } from '../buttons/KavButtons' import { SceneButtons } from '../buttons/SceneButtons' import { AlertCardUi4 } from '../cards/AlertCard' +import { EdgeCard } from '../cards/EdgeCard' import { EdgeAnim, fadeInDown30, @@ -48,8 +54,10 @@ import { } from '../modals/WalletListModal' import { Airship, showToast, showWarning } from '../services/AirshipInstance' import { useTheme } from '../services/ThemeContext' +import { SettingsSwitchRow } from '../settings/SettingsSwitchRow' import { UnscaledText } from '../text/UnscaledText' import { LineTextDivider } from '../themed/LineTextDivider' +import { StealthInfoText } from '../themed/StealthInfoText' import { SwapInput, type SwapInputCardAmounts, @@ -66,12 +74,11 @@ export interface SwapCreateParams { // Display error message in an alert card errorDisplayInfo?: SwapErrorDisplayInfo -} -export interface SwapErrorDisplayInfo { - message: string - title: string - error: unknown + // Turn the Stealth Swap toggle off on arrival. The confirmation scene sets + // this when a re-quote found the pair has no private route, so the degrade + // lands where the toggle actually lives. + disableStealth?: boolean } interface Props extends SwapTabSceneProps<'swapCreate'> {} @@ -83,7 +90,8 @@ export const SwapCreateScene: React.FC = props => { fromTokenId = null, toWalletId, toTokenId = null, - errorDisplayInfo + errorDisplayInfo, + disableStealth } = route.params ?? {} const theme = useTheme() const dispatch = useDispatch() @@ -95,6 +103,10 @@ export const SwapCreateScene: React.FC = props => { 'from' | 'to' >('from') + // Stealth Swap: when enabled, the quote routes through the Houdini privacy + // provider as a fixed provider (see SwapConfirmationScene). + const [stealth, setStealth] = useState(false) + const fromInputRef = React.useRef(null) const toInputRef = React.useRef(null) @@ -149,6 +161,15 @@ export const SwapCreateScene: React.FC = props => { }) }, [dispatch, navigation]) + // A re-quote on the confirmation scene found no private route for the pair + // and sent the user back here to retry as a standard swap. Consume the flag + // so a later visit does not turn the toggle off again. + React.useEffect(() => { + if (disableStealth !== true) return + setStealth(false) + navigation.setParams({ disableStealth: undefined }) + }, [disableStealth, navigation]) + // // Callbacks // @@ -228,6 +249,9 @@ export const SwapCreateScene: React.FC = props => { } const getQuote = (swapRequest: EdgeSwapRequest): void => { + // This scene only builds wallet-to-wallet swap requests, which always carry + // a destination wallet (swap-to-address has its own flow). + const toWallet = requireDestinationWallet(swapRequest) if (exchangeInfo != null) { const disableSrc = checkDisableAsset( exchangeInfo.swap.disableAssets.source, @@ -247,7 +271,7 @@ export const SwapCreateScene: React.FC = props => { const disableDest = checkDisableAsset( exchangeInfo.swap.disableAssets.destination, - swapRequest.toWallet.id, + toWallet.id, toTokenId ) if (disableDest) { @@ -255,7 +279,7 @@ export const SwapCreateScene: React.FC = props => { sprintf( lstrings.swap_token_no_enabled_exchanges_2s, toCurrencyCode, - swapRequest.toWallet.currencyInfo.displayName + toWallet.currencyInfo.displayName ) ) return @@ -266,10 +290,17 @@ export const SwapCreateScene: React.FC = props => { errorDisplayInfo: undefined }) - // Start request for quote: + // Start request for quote. A stealth swap restricts the request to the + // Houdini privacy provider AND demands a private route: restricting the + // provider alone would still accept that provider's transparent standard + // routes, which are priced better and would be labelled private here. navigation.navigate('swapProcessing', { - swapRequest, - swapRequestOptions, + swapRequest: stealth + ? { ...swapRequest, privacy: 'required' } + : swapRequest, + swapRequestOptions: stealth + ? makeStealthSwapRequestOptions(account, swapRequestOptions) + : swapRequestOptions, onCancel: () => { navigation.goBack() }, @@ -277,8 +308,28 @@ export const SwapCreateScene: React.FC = props => { navigation.replace('swapConfirmation', { selectedQuote: quotes[0], quotes, - onApprove: resetState + onApprove: resetState, + stealth }) + }, + onError: error => { + // The provider has no private route for this pair: turn Stealth Swap + // off, say why, and bring the user back to their filled-in request so + // they can retry as a standard swap. Amount errors keep the generic + // handling, since the route exists and the amount is the problem. + if (!stealth || asMaybeSwapCurrencyError(error) == null) return false + setStealth(false) + showToast(lstrings.stealth_swap_route_unavailable_toast) + navigation.navigate('swapTab', { + screen: 'swapCreate', + params: { + fromWalletId: swapRequest.fromWallet.id, + fromTokenId: swapRequest.fromTokenId, + toWalletId: toWallet.id, + toTokenId: swapRequest.toTokenId + } + }) + return true } }) } @@ -441,6 +492,10 @@ export const SwapCreateScene: React.FC = props => { Keyboard.dismiss() }) + const handleToggleStealth = useHandler(() => { + setStealth(value => !value) + }) + const handleFromAmountChange = useHandler((amounts: SwapInputCardAmounts) => { navigation.setParams({ // Update the error state: @@ -607,6 +662,23 @@ export const SwapCreateScene: React.FC = props => { /> )} + {fromWallet != null && toWallet != null ? ( + + + + {stealth ? ( + + ) : null} + + + ) : null} {renderAlert()} {isNextHidden || isKeyboardOpen ? null : ( diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 040a44ded6b..52000fa538c 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1653,8 +1653,11 @@ const strings = { send_scene_send_from_wallet: 'Send from Wallet', send_scene_send_to_address: 'Send to Address', stealth_send_toggle: 'Stealth Send', + stealth_swap_toggle: 'Stealth Swap', stealth_send_info: 'Uses a route that helps obfuscate the on-chain link between source and destination wallets.', + stealth_swap_info: + 'Routes your swap through multiple exchanges so your source and destination wallets are more obfuscated on-chain.', stealth_learn_more: 'Learn more', stealth_you_send: 'You send', stealth_recipient_gets: 'Recipient gets', @@ -1669,6 +1672,8 @@ const strings = { 'Stealth Send and cross-asset recipients are not available when sending to multiple recipients.', stealth_route_unavailable_toast: 'Private routing is not available for this pair right now. Stealth Send has been turned off.', + stealth_swap_route_unavailable_toast: + 'Private routing is not available for this pair right now. Stealth Swap has been turned off.', stealth_route_unavailable_info: 'Private routing is not available for this pair right now.', stealth_self_private_unsupported_1s: diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 18281af3560..bbafeb3cacb 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1299,7 +1299,9 @@ "send_scene_send_from_wallet": "Send from Wallet", "send_scene_send_to_address": "Send to Address", "stealth_send_toggle": "Stealth Send", + "stealth_swap_toggle": "Stealth Swap", "stealth_send_info": "Uses a route that helps obfuscate the on-chain link between source and destination wallets.", + "stealth_swap_info": "Routes your swap through multiple exchanges so your source and destination wallets are more obfuscated on-chain.", "stealth_learn_more": "Learn more", "stealth_you_send": "You send", "stealth_recipient_gets": "Recipient gets", @@ -1312,6 +1314,7 @@ "stealth_getting_quote": "Getting quote...", "stealth_multi_recipient_unsupported": "Stealth Send and cross-asset recipients are not available when sending to multiple recipients.", "stealth_route_unavailable_toast": "Private routing is not available for this pair right now. Stealth Send has been turned off.", + "stealth_swap_route_unavailable_toast": "Private routing is not available for this pair right now. Stealth Swap has been turned off.", "stealth_route_unavailable_info": "Private routing is not available for this pair right now.", "stealth_self_private_unsupported_1s": "Private routing is not available when sending %1$s to itself.", "stealth_below_private_minimum_1s": "Private routing needs at least %1$s. Enter a larger amount to send privately.", From 626f8adb7bbd2bb45ae90dc9971ff248b57d32aa Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:28:31 -0700 Subject: [PATCH 13/18] Name the send-shaped swap flows in the transaction list and details A swap-send, a stealth send, and a stealth swap-send all landed under the same generic swap title, and the two private flows displayed the recipient they exist to conceal. Each flow now names itself on the swap action's swapType, which only the send scene can determine: the plugin sees an ordinary swap, and with every send-to-address quote restricted to the privacy provider the winning plugin cannot tell them apart either. The list and details map the field to a title. A private send skips the recipient write into transaction metadata and hides the payout address in the details text, while keeping it on the swap data so support can trace an order. The order id and provider stay visible for the same reason: the card used to resolve its payout denomination through the payout wallet and render nothing without one, which hid them entirely on a synthetic destination. The spend-target row is retitled from the saved action, so a send-shaped swap reads "Exchange Deposit Address" (the address the funds actually went to) and every other transaction keeps today's wording. A token send pays its fee in the chain's own coin, so makeSwapPluginQuote files a second action under tokenId null from the plugin's own copy, which carries no swapType; the send scene stamps that row under the same condition the plugin writes it, and the title map applies only where the asset action is not a network fee, so the fee row does not become a second private send in the list. --- eslint.config.mjs | 1 - .../actions/CategoriesActions.test.ts | 175 ++++++++++++++++++ src/actions/CategoriesActions.ts | 45 ++++- src/components/cards/SwapDetailsCard.tsx | 86 +++++---- .../scenes/TransactionDetailsScene.tsx | 48 ++++- src/constants/txActionConstants.ts | 9 +- src/locales/en_US.ts | 5 + src/locales/strings/enUS.json | 5 + 8 files changed, 333 insertions(+), 41 deletions(-) create mode 100644 src/__tests__/actions/CategoriesActions.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 7e5db8a44fb..571287094c3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -165,7 +165,6 @@ export default [ 'src/components/cards/StakingOptionCard.tsx', 'src/components/cards/StakingReturnsCard.tsx', 'src/components/cards/SupportCard.tsx', - 'src/components/cards/SwapDetailsCard.tsx', 'src/components/cards/TappableAccountCard.tsx', 'src/components/cards/TappableCard.tsx', 'src/components/cards/UnderlinedNumInputCard.tsx', diff --git a/src/__tests__/actions/CategoriesActions.test.ts b/src/__tests__/actions/CategoriesActions.test.ts new file mode 100644 index 00000000000..d06d0a293fb --- /dev/null +++ b/src/__tests__/actions/CategoriesActions.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from '@jest/globals' +import type { + EdgeAccount, + EdgeAssetActionType, + EdgeCurrencyWallet, + EdgeMetadata, + EdgeTransaction, + EdgeTxActionSwapType +} from 'edge-core-js' + +import { getTxActionDisplayInfo } from '../../actions/CategoriesActions' +import { lstrings } from '../../locales/strings' + +const BITCOIN_WALLET_ID = 'bitcoin-wallet-id' +const RECIPIENT_ADDRESS = 'bc1qrecipientaddressthepayeecontrols' +const DEPOSIT_ADDRESS = '13e6qqcAZCgApTDyMNG8brru4PmtjbReUd' + +// Only the fields `getTxActionDisplayInfo` actually reads: +const account = { + currencyWallets: {}, + currencyConfig: { + bitcoin: { + currencyInfo: { currencyCode: 'BTC' }, + allTokens: {} + }, + ethereum: { + currencyInfo: { currencyCode: 'ETH' }, + allTokens: { + '0000000000000000000000000000000000000001': { currencyCode: 'USDC' } + } + } + } +} as unknown as EdgeAccount + +const bitcoinWallet = { + id: BITCOIN_WALLET_ID, + currencyInfo: { pluginId: 'bitcoin', assetDisplayName: 'Bitcoin' }, + currencyConfig: account.currencyConfig.bitcoin +} as unknown as EdgeCurrencyWallet + +const ethereumWallet = { + id: 'ethereum-wallet-id', + currencyInfo: { pluginId: 'ethereum', assetDisplayName: 'Ethereum' }, + currencyConfig: account.currencyConfig.ethereum +} as unknown as EdgeCurrencyWallet + +interface SwapTxOpts { + assetActionType?: EdgeAssetActionType + fromPluginId?: string + fromTokenId?: string | null + metadata?: EdgeMetadata + swapType?: EdgeTxActionSwapType + tokenId?: string | null +} + +/** + * A broadcast send-shaped swap, as the Houdini plugin and the send scene leave + * it: the spend target is the provider's deposit address, and the payee rides + * on the saved action alone. + */ +const makeSwapSendTx = (opts: SwapTxOpts = {}): EdgeTransaction => { + const { + assetActionType = 'swap', + fromPluginId = 'bitcoin', + fromTokenId = null, + metadata, + swapType, + tokenId = null + } = opts + + return { + txid: 'txid', + tokenId, + currencyCode: 'BTC', + nativeAmount: '-38693', + isSend: true, + metadata, + assetAction: { assetActionType }, + spendTargets: [{ publicAddress: DEPOSIT_ADDRESS, nativeAmount: '38693' }], + savedAction: { + actionType: 'swap', + swapInfo: { pluginId: 'houdini', displayName: 'HoudiniSwap' }, + orderId: '9zdiWHWi2Q4Y7NRPB8k7mL', + isEstimate: true, + fromAsset: { + pluginId: fromPluginId, + tokenId: fromTokenId, + nativeAmount: '38693' + }, + toAsset: { pluginId: 'bitcoin', tokenId: null, nativeAmount: '37580' }, + payoutAddress: RECIPIENT_ADDRESS, + swapType + } + } as unknown as EdgeTransaction +} + +describe('getTxActionDisplayInfo, private send titles', () => { + it('titles a private send by the flow, not the asset', () => { + const { mergedData } = getTxActionDisplayInfo( + makeSwapSendTx({ swapType: 'stealthSend' }), + account, + bitcoinWallet + ) + expect(mergedData.name).toBe(lstrings.transaction_details_stealth_send) + }) + + it('outranks a stored metadata name on a private send', () => { + // A recipient-style name reaching the transaction by any route must not + // win the merge, or the flow displays what it exists to conceal. + const { mergedData } = getTxActionDisplayInfo( + makeSwapSendTx({ + swapType: 'stealthSend', + metadata: { name: RECIPIENT_ADDRESS } + }), + account, + bitcoinWallet + ) + expect(mergedData.name).toBe(lstrings.transaction_details_stealth_send) + expect(mergedData.name).not.toContain(RECIPIENT_ADDRESS) + }) + + it('lets a stored name stand on a non-private swap-send', () => { + const { mergedData } = getTxActionDisplayInfo( + makeSwapSendTx({ swapType: 'swapSend', metadata: { name: 'Alice' } }), + account, + bitcoinWallet + ) + expect(mergedData.name).toBe('Alice') + }) +}) + +describe('getTxActionDisplayInfo, the parent network-fee row', () => { + // A token send files its fee under `tokenId: null` with the same swap + // action. Stamping that row with the flow is what keeps the private-send + // display rules true there, but the row is the fee, not the send. + const feeRow = makeSwapSendTx({ + assetActionType: 'swapNetworkFee', + fromPluginId: 'ethereum', + fromTokenId: '0000000000000000000000000000000000000001', + swapType: 'stealthSend', + tokenId: null + }) + + it('keeps the network-fee title rather than the flow title', () => { + const { mergedData } = getTxActionDisplayInfo( + feeRow, + account, + ethereumWallet + ) + expect(mergedData.name).toBe(lstrings.transaction_details_swap_network_fee) + expect(mergedData.name).not.toBe(lstrings.transaction_details_stealth_send) + }) + + it('still outranks a stored metadata name on the fee row', () => { + const namedFeeRow: EdgeTransaction = { + ...feeRow, + metadata: { name: RECIPIENT_ADDRESS } + } + const { mergedData } = getTxActionDisplayInfo( + namedFeeRow, + account, + ethereumWallet + ) + expect(mergedData.name).not.toContain(RECIPIENT_ADDRESS) + }) + + it('keeps the network-fee category', () => { + const { mergedData } = getTxActionDisplayInfo( + feeRow, + account, + ethereumWallet + ) + expect(mergedData.category).toContain(lstrings.wc_smartcontract_network_fee) + }) +}) diff --git a/src/actions/CategoriesActions.ts b/src/actions/CategoriesActions.ts index d00a39a6b15..eb3ad984bd0 100644 --- a/src/actions/CategoriesActions.ts +++ b/src/actions/CategoriesActions.ts @@ -12,7 +12,10 @@ import { sprintf } from 'sprintf-js' import { showError } from '../components/services/AirshipInstance' import { EDGE_CONTENT_SERVER_URI } from '../constants/CdnConstants' -import { TX_ACTION_LABEL_MAP } from '../constants/txActionConstants' +import { + SWAP_SEND_LABEL_MAP, + TX_ACTION_LABEL_MAP +} from '../constants/txActionConstants' import { lstrings } from '../locales/strings' import type { ThunkAction } from '../types/reduxTypes' import { getCurrencyCodeWithAccount } from '../util/CurrencyInfoHelpers' @@ -334,6 +337,8 @@ export const getTxActionDisplayInfo = ( tx.nativeAmount.startsWith('-') || (eq(tx.nativeAmount, '0') && tx.isSend) let payeeText: string | undefined + /** Title wins over any stored metadata name (privacy-bearing titles). */ + let forceSavedName = false let edgeCategory: EdgeCategory let direction: 'send' | 'receive' let notes: string | undefined @@ -375,15 +380,45 @@ export const getTxActionDisplayInfo = ( switch (actionType) { case 'swap': { iconPluginId = action.swapInfo.pluginId + // A token send files its parent-currency fee under the same swap + // action as the send itself. That row is the fee, not the send, so it + // keeps its own network-fee title while still obeying the privacy rule + // below. + const isNetworkFeeRow = + assetActionType === 'swapNetworkFee' || + assetActionType === 'transferNetworkFee' + // A send-shaped swap is titled by the flow the user ran, so the three + // are distinguishable in the list. The two private flavors also drop + // the recipient from the title; the payout address stays on swapData + // for support. + if (action.swapType != null) { + if (!isNetworkFeeRow) payeeText = SWAP_SEND_LABEL_MAP[action.swapType] + // The two private flavors exist to keep the recipient off the + // screen, so their title outranks any stored metadata name. A + // recipient-style name reaching this transaction by any route would + // otherwise win the merge below and display exactly what the flow + // is meant to conceal. + forceSavedName = + action.swapType === 'stealthSend' || + action.swapType === 'stealthSwapSend' + } switch (assetActionType) { case 'transfer': { - const txSrc = action.payoutWalletId !== wallet.id + // A swap-to-address payout has no payout wallet, so there is no + // wallet id to compare against and the transfer can only be + // outbound from this one. Spelled out rather than left to + // `undefined !== wallet.id`, which lands on the same answer by + // accident and reads as an oversight next to the null guard below. + const txSrc = + action.payoutWalletId == null || + action.payoutWalletId !== wallet.id const toFromStr = txSrc ? lstrings.transaction_details_swap_to_subcat_1s : lstrings.transaction_details_swap_from_subcat_1s const walletName = - account.currencyWallets[action.payoutWalletId]?.name ?? - displayName + (action.payoutWalletId != null + ? account.currencyWallets[action.payoutWalletId]?.name + : undefined) ?? displayName edgeCategory = { category: 'transfer', subcategory: sprintf(toFromStr, walletName) @@ -681,7 +716,7 @@ export const getTxActionDisplayInfo = ( const mergedData: EdgeMetadata = { name: - metadata?.name != null && metadata.name.length > 0 + !forceSavedName && metadata?.name != null && metadata.name.length > 0 ? metadata.name : savedData.name, category: diff --git a/src/components/cards/SwapDetailsCard.tsx b/src/components/cards/SwapDetailsCard.tsx index a0df0c33f4a..04f70f4d1f6 100644 --- a/src/components/cards/SwapDetailsCard.tsx +++ b/src/components/cards/SwapDetailsCard.tsx @@ -1,5 +1,6 @@ import { abs, sub } from 'biggystring' import type { + EdgeCurrencyConfig, EdgeCurrencyWallet, EdgeTransaction, EdgeTxSwap @@ -33,6 +34,13 @@ interface Props { swapData: EdgeTxSwap transaction: EdgeTransaction wallet: EdgeCurrencyWallet + + /** + * Keep the payout address out of the details text. Set for a private send, + * whose recipient the UI must not reveal. The address stays on `swapData` + * so support can still trace the order. + */ + hidePayoutAddress?: boolean } const TXID_PLACEHOLDER = '{{TXID}}' @@ -40,26 +48,22 @@ const TXID_PLACEHOLDER = '{{TXID}}' // Metadata may have been created and saved before tokenId was required. // If tokenId is missing it defaults to null so we can try upgrading it. const upgradeSwapData = ( - destinationWallet: EdgeCurrencyWallet, + payoutConfig: EdgeCurrencyConfig | undefined, swapData: EdgeTxSwap ): EdgeTxSwap => { - if ( - swapData.payoutTokenId === undefined && - destinationWallet.currencyInfo.currencyCode !== swapData.payoutCurrencyCode - ) { - swapData.payoutTokenId = getTokenId( - destinationWallet.currencyConfig, - swapData.payoutCurrencyCode - ) - } else if (swapData.payoutTokenId === undefined) { - swapData.payoutTokenId = null - } + if (swapData.payoutTokenId !== undefined) return swapData + + swapData.payoutTokenId = + payoutConfig != null && + payoutConfig.currencyInfo.currencyCode !== swapData.payoutCurrencyCode + ? getTokenId(payoutConfig, swapData.payoutCurrencyCode) + : null return swapData } -export function SwapDetailsCard(props: Props) { - const { swapData, transaction, wallet } = props +export const SwapDetailsCard: React.FC = props => { + const { swapData, transaction, wallet, hidePayoutAddress = false } = props const theme = useTheme() const styles = getStyles(theme) @@ -72,13 +76,33 @@ export function SwapDetailsCard(props: Props) { : selectDisplayDenom(state, wallet.currencyConfig, tokenId) ) - // The wallet may have been deleted: + // A swap-to-address payout has no wallet, and the wallet may also have + // been deleted: const account = useSelector(state => state.core.account) const currencyWallets = useWatch(account, 'currencyWallets') - const destinationWallet = currencyWallets[swapData.payoutWalletId] + const destinationWallet = + swapData.payoutWalletId == null + ? undefined + : currencyWallets[swapData.payoutWalletId] const destinationWalletName = destinationWallet == null ? '' : getWalletName(destinationWallet) + // The payout asset's own currency config. A swap-to-address payout has no + // wallet to read it off, so it comes from the saved action's destination + // asset instead. Falling back to the SOURCE wallet was not viable: it + // resolves the payout currency code against the wrong chain, which left + // `payoutTokenId` unset and made the guard below hide this whole card for + // every swap-and-send, taking the order id and provider with it. + const payoutSwapAction = + transaction.savedAction?.actionType === 'swap' + ? transaction.savedAction + : undefined + const payoutConfig = + destinationWallet?.currencyConfig ?? + (payoutSwapAction == null + ? undefined + : account.currencyConfig[payoutSwapAction.toAsset.pluginId]) + const { isEstimate, orderId, @@ -88,7 +112,7 @@ export function SwapDetailsCard(props: Props) { payoutTokenId, plugin, refundAddress - } = upgradeSwapData(wallet, swapData) + } = upgradeSwapData(payoutConfig, swapData) const formattedOrderUri = orderUri == null ? undefined @@ -124,12 +148,12 @@ export function SwapDetailsCard(props: Props) { return } - if (error) showError(error) + if (error != null) showError(error) } ) }) - const handleLink = async () => { + const handleLink = async (): Promise => { if (formattedOrderUri == null) return // Replace {{TXID}} with actual transaction ID if present @@ -140,9 +164,9 @@ export function SwapDetailsCard(props: Props) { if (available) await SafariView.show({ url: formattedOrderUri }) else await Linking.openURL(formattedOrderUri) }) - .catch(error => { + .catch((error: unknown) => { showError(error) - Linking.openURL(formattedOrderUri).catch(err => { + Linking.openURL(formattedOrderUri).catch((err: unknown) => { showError(err) }) }) @@ -152,13 +176,9 @@ export function SwapDetailsCard(props: Props) { } const destinationDenomination = useSelector(state => - destinationWallet == null || payoutTokenId === undefined + payoutConfig == null || payoutTokenId === undefined ? undefined - : selectDisplayDenom( - state, - destinationWallet.currencyConfig, - payoutTokenId - ) + : selectDisplayDenom(state, payoutConfig, payoutTokenId) ) if (destinationDenomination == null) return null @@ -180,11 +200,9 @@ export function SwapDetailsCard(props: Props) { destinationDenomination.multiplier )(swapData.payoutNativeAmount) const destinationAssetName = - payoutTokenId == null + payoutTokenId == null || payoutConfig == null ? payoutCurrencyCode - : `${payoutCurrencyCode} (${ - getExchangeDenom(destinationWallet.currencyConfig, null).name - })` + : `${payoutCurrencyCode} (${getExchangeDenom(payoutConfig, null).name})` const symbolString = currencyInfo.currencyCode === transaction.currencyCode && @@ -192,7 +210,7 @@ export function SwapDetailsCard(props: Props) { ? walletDefaultDenom.symbol : transaction.currencyCode - const createExchangeDataString = (newline: string = '\n') => { + const createExchangeDataString = (newline: string = '\n'): string => { const uniqueIdentifier = memos .map( (memo, index) => @@ -231,7 +249,9 @@ export function SwapDetailsCard(props: Props) { lstrings.transaction_details_exchange_exchange_unique_id }:${newline}${uniqueIdentifier}${newline}${newline}${ lstrings.transaction_details_exchange_payout_address - }:${newline}${payoutAddress}${newline}${newline}${ + }:${newline}${ + hidePayoutAddress ? lstrings.stealth_recipient_hidden : payoutAddress + }${newline}${newline}${ lstrings.transaction_details_exchange_refund_address }:${newline}${refundAddress ?? ''}${newline}` } diff --git a/src/components/scenes/TransactionDetailsScene.tsx b/src/components/scenes/TransactionDetailsScene.tsx index 1d17b7a482c..d0ab0c6b8a7 100644 --- a/src/components/scenes/TransactionDetailsScene.tsx +++ b/src/components/scenes/TransactionDetailsScene.tsx @@ -37,6 +37,7 @@ import type { EdgeAppSceneProps } from '../../types/routerTypes' import { getCurrencyCodeWithAccount } from '../../util/CurrencyInfoHelpers' import { matchJson } from '../../util/matchJson' import { getMemoTitle } from '../../util/memoUtils' +import { STEALTH_SWAP_PLUGIN_ID } from '../../util/stealthSwap' import { convertNativeToExchange, darkenHexColor, @@ -101,6 +102,45 @@ export const TransactionDetailsComponent: React.FC = props => { const swapData = convertActionToSwapData(account, transaction) ?? transaction.swapData + // A private send must not reveal its recipient anywhere in the UI. The + // payout address stays on the swap data for support to trace the order. + // + // This test FAILS CLOSED, and that is why it has two halves. `swapType` is + // the precise answer, but it reaches the saved action through the send + // scene's best-effort `saveTxAction`, which is logged and swallowed so a + // storage hiccup cannot fail a completed send. A stamp that never landed + // would then leave the recipient's address on screen days later, for a send + // the user was told was private. The provider is the durable half: a swap + // routed by the privacy provider is privacy-routed by construction, whether + // or not the stamp arrived, and hiding the payout address on an ordinary + // provider swap costs nothing, since that payout goes to the user's own + // wallet and the wallet row already names it. + const isStealthSend = + action != null && + action.actionType === 'swap' && + (action.swapType === 'stealthSend' || + action.swapType === 'stealthSwapSend' || + action.swapInfo.pluginId === STEALTH_SWAP_PLUGIN_ID) + + // A send-shaped swap spends to the provider's deposit address; the pasted + // recipient never reaches `spendTargets` at all. Titling that row "Recipient + // Addresses" therefore names the wrong party, and on a private send it reads + // as exactly the disclosure the flow exists to prevent. + const isSwapSend = + action != null && action.actionType === 'swap' && action.swapType != null + + // A token send's parent network-fee row carries the same swap action as the + // send. It is not the send, though, so its payout address is never the datum + // anyone came for: the row it accompanies carries the identical order. Hiding + // it here unconditionally is what makes the private-send rule fail CLOSED, + // because the fee row's `swapType` arrives through a best-effort + // `saveTxAction` that the send deliberately does not fail on. A stamp that + // never landed now costs the row its title, not its privacy. + const isNetworkFeeRow = + assetAction != null && + (assetAction.assetActionType === 'swapNetworkFee' || + assetAction.assetActionType === 'transferNetworkFee') + const thumbnailPath = useContactThumbnail(mergedData.name) ?? pluginIdIcons[iconPluginId ?? ''] @@ -636,6 +676,7 @@ export const TransactionDetailsComponent: React.FC = props => { swapData={swapData} transaction={transaction} wallet={wallet} + hidePayoutAddress={isStealthSend || isNetworkFeeRow} /> )} @@ -662,7 +703,11 @@ export const TransactionDetailsComponent: React.FC = props => { )} @@ -771,6 +816,7 @@ const convertActionToSwapData = ( payoutCurrencyCode, payoutTokenId: toAsset.tokenId, payoutNativeAmount: action.toAsset.nativeAmount ?? '0', + // A swap-to-address (private send) has no payout wallet: payoutWalletId, refundAddress } diff --git a/src/constants/txActionConstants.ts b/src/constants/txActionConstants.ts index 13d811cb8ec..c7b6b7202f4 100644 --- a/src/constants/txActionConstants.ts +++ b/src/constants/txActionConstants.ts @@ -1,7 +1,14 @@ -import type { EdgeAssetActionType } from 'edge-core-js' +import type { EdgeAssetActionType, EdgeTxActionSwapType } from 'edge-core-js' import { lstrings } from '../locales/strings' +/** Titles for the send-shaped swap flows, which a plain swap does not carry. */ +export const SWAP_SEND_LABEL_MAP: Record = { + swapSend: lstrings.transaction_details_swap_and_send, + stealthSend: lstrings.transaction_details_stealth_send, + stealthSwapSend: lstrings.transaction_details_stealth_swap_and_send +} + export const TX_ACTION_LABEL_MAP: Record = { buy: lstrings.transaction_details_bought_1s, claim: lstrings.transaction_details_claim, diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 52000fa538c..32146b50536 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -937,6 +937,7 @@ const strings = { transaction_details_error_invalid: 'Invalid Transaction', sub_category_label: 'Sub-category', transaction_details_recipient_addresses: 'Recipient Addresses', + transaction_details_exchange_deposit_address: 'Exchange Deposit Address', transaction_details_advance_details_header: 'Advanced Details', transaction_details_advance_details_fee_setting: 'Fee Setting', transaction_details_advance_details_device: 'Device', @@ -980,6 +981,9 @@ const strings = { transaction_details_exchange_support_request: '%s Support Request', transaction_details_fee_warning: 'High Network Fees', transaction_details_swap: 'Swap Funds', + transaction_details_swap_and_send: 'Swap & Send', + transaction_details_stealth_send: 'Stealth Send', + transaction_details_stealth_swap_and_send: 'Stealth Swap & Send', transaction_details_swap_network_fee: 'Swap Network Fee', transaction_details_swap_order_cancel: 'Swap Order Cancelled', transaction_details_swap_order_post: 'Swap Order Opened', @@ -1687,6 +1691,7 @@ const strings = { stealth_fixed_to_fallback_title: 'Receive amount is an estimate', stealth_fixed_to_fallback_body: 'The provider could not guarantee the requested receive amount, so the send amount is now guaranteed instead and the recipient amount is an estimate from current rates. Edit either amount to continue.', + stealth_recipient_hidden: 'Hidden for privacy', stealth_detected_network_title: 'Which network is this address on?', stealth_detected_network_message: 'This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index bbafeb3cacb..f74889775e0 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -744,6 +744,7 @@ "transaction_details_error_invalid": "Invalid Transaction", "sub_category_label": "Sub-category", "transaction_details_recipient_addresses": "Recipient Addresses", + "transaction_details_exchange_deposit_address": "Exchange Deposit Address", "transaction_details_advance_details_header": "Advanced Details", "transaction_details_advance_details_fee_setting": "Fee Setting", "transaction_details_advance_details_device": "Device", @@ -782,6 +783,9 @@ "transaction_details_exchange_support_request": "%s Support Request", "transaction_details_fee_warning": "High Network Fees", "transaction_details_swap": "Swap Funds", + "transaction_details_swap_and_send": "Swap & Send", + "transaction_details_stealth_send": "Stealth Send", + "transaction_details_stealth_swap_and_send": "Stealth Swap & Send", "transaction_details_swap_network_fee": "Swap Network Fee", "transaction_details_swap_order_cancel": "Swap Order Cancelled", "transaction_details_swap_order_post": "Swap Order Opened", @@ -1322,6 +1326,7 @@ "stealth_fixed_to_unavailable_toast": "The provider cannot guarantee an exact receive amount for this pair. The send amount is now the guaranteed side.", "stealth_fixed_to_fallback_title": "Receive amount is an estimate", "stealth_fixed_to_fallback_body": "The provider could not guarantee the requested receive amount, so the send amount is now guaranteed instead and the recipient amount is an estimate from current rates. Edit either amount to continue.", + "stealth_recipient_hidden": "Hidden for privacy", "stealth_detected_network_title": "Which network is this address on?", "stealth_detected_network_message": "This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.", "send_scene_error_title": "Error:", From b8ccec76e93b965eee1f48335a8bcad2b67c16f2 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:28:50 -0700 Subject: [PATCH 14/18] Add a composable suite for the stealth branches A flow per user-visible branch in maestro/14-stealth, built from reusable subflows in maestro/common so a later session can drive one specific state without walking the simulator by hand. The two flows that move funds carry their own tag, so a run of the suite cannot spend. --- .gitignore | 3 + maestro/14-stealth/README.md | 132 ++++++++++++++++++ .../stealth-below-private-minimum.yaml | 39 ++++++ .../14-stealth/stealth-cross-asset-quote.yaml | 36 +++++ maestro/14-stealth/stealth-execute-send.yaml | 45 ++++++ maestro/14-stealth/stealth-execute-swap.yaml | 79 +++++++++++ maestro/14-stealth/stealth-myself-picker.yaml | 27 ++++ maestro/14-stealth/stealth-private-quote.yaml | 44 ++++++ .../14-stealth/stealth-qr-payment-uri.yaml | 38 +++++ maestro/14-stealth/stealth-send.yaml | 41 ++++++ maestro/14-stealth/stealth-swap.yaml | 24 ++++ maestro/14-stealth/stealth-tx-details.yaml | 52 +++++++ maestro/common/stealth-await-quote.yaml | 39 ++++++ maestro/common/stealth-enter-address.yaml | 19 +++ maestro/common/stealth-launch.yaml | 64 +++++++++ maestro/common/stealth-open-send.yaml | 58 ++++++++ maestro/common/stealth-open-swap.yaml | 16 +++ maestro/common/stealth-open-wallet.yaml | 38 +++++ maestro/common/stealth-pick-myself.yaml | 31 ++++ .../common/stealth-pick-recipient-asset.yaml | 27 ++++ maestro/common/stealth-scan-address.yaml | 24 ++++ maestro/common/stealth-set-amount.yaml | 54 +++++++ maestro/common/stealth-slide-to-confirm.yaml | 19 +++ maestro/common/stealth-toggle.yaml | 37 +++++ 24 files changed, 986 insertions(+) create mode 100644 maestro/14-stealth/README.md create mode 100644 maestro/14-stealth/stealth-below-private-minimum.yaml create mode 100644 maestro/14-stealth/stealth-cross-asset-quote.yaml create mode 100644 maestro/14-stealth/stealth-execute-send.yaml create mode 100644 maestro/14-stealth/stealth-execute-swap.yaml create mode 100644 maestro/14-stealth/stealth-myself-picker.yaml create mode 100644 maestro/14-stealth/stealth-private-quote.yaml create mode 100644 maestro/14-stealth/stealth-qr-payment-uri.yaml create mode 100644 maestro/14-stealth/stealth-send.yaml create mode 100644 maestro/14-stealth/stealth-swap.yaml create mode 100644 maestro/14-stealth/stealth-tx-details.yaml create mode 100644 maestro/common/stealth-await-quote.yaml create mode 100644 maestro/common/stealth-enter-address.yaml create mode 100644 maestro/common/stealth-launch.yaml create mode 100644 maestro/common/stealth-open-send.yaml create mode 100644 maestro/common/stealth-open-swap.yaml create mode 100644 maestro/common/stealth-open-wallet.yaml create mode 100644 maestro/common/stealth-pick-myself.yaml create mode 100644 maestro/common/stealth-pick-recipient-asset.yaml create mode 100644 maestro/common/stealth-scan-address.yaml create mode 100644 maestro/common/stealth-set-amount.yaml create mode 100644 maestro/common/stealth-slide-to-confirm.yaml create mode 100644 maestro/common/stealth-toggle.yaml diff --git a/.gitignore b/.gitignore index 13a4349fc8f..be84ff6d0dd 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ yarn-error.log !.yarn/releases !.yarn/sdks !.yarn/versions + +# Maestro run output +/maestro/screenshots diff --git a/maestro/14-stealth/README.md b/maestro/14-stealth/README.md new file mode 100644 index 00000000000..cf4f3ea293d --- /dev/null +++ b/maestro/14-stealth/README.md @@ -0,0 +1,132 @@ +# Stealth Send and Stealth Swap flows + +Maestro coverage for the send-to-address swap UI. Every user-visible branch of +the feature has a flow, and the pieces those flows are built from live in +`../common/stealth-*.yaml` so a later session can drive one specific state +without walking the whole thing by hand. + +## Running + +The whole suite, minus the two flows that spend money: + +```bash +npm run maestro -- test --include-tags stealth maestro +``` + +One flow: + +```bash +npm run maestro -- test maestro/14-stealth/stealth-private-quote.yaml +``` + +The two funded flows are tagged `stealth-spend` and nothing else, so the command +above never triggers them. Run them deliberately, and in this order: + +```bash +npm run maestro -- test maestro/14-stealth/stealth-execute-send.yaml +npm run maestro -- test maestro/14-stealth/stealth-execute-swap.yaml +``` + +They run opposite directions, so the pair returns the funds to where they +started and costs two spreads rather than emptying one wallet. Let the first +one's deposit confirm before starting the second: a wallet with an unconfirmed +outgoing transaction cannot quote a new send. + +Run flows ONE AT A TIME rather than as a tagged batch. A failing flow takes the +maestro driver down with it, and every flow after it then reports +`Failed to connect`, which reads as a suite-wide breakage instead of one bad +flow. + +On a machine with more than one simulator booted, pin the device and the driver +port, or the run may attach to the wrong one: + +```bash +maestro --device --driver-host-port test maestro/14-stealth +``` + +## Account expectations + +The flows read wallet names from env vars so they can point at whatever the +signed-in account actually holds. Defaults assume the `edge-funds` roster +account as of 2026-07-30: + +| Env var | Default | Needs | +| -------------------------- | ------------ | -------------------------------------------- | +| `STEALTH_SRC_WALLET` | `My Stellar` | funded above 25 USD for the private branches | +| `STEALTH_DEST_WALLET` | `My Sonic` | exists; no balance needed | +| `STEALTH_AMOUNT` | `30` | fiat, above the 25 USD private floor | +| `STEALTH_BELOW_MIN_AMOUNT` | `15` | fiat, between the 10 and 25 USD floors | +| `STEALTH_PIN_DIGIT` | `0` | the account's single repeated relogin digit | +| `STEALTH_MEMO_CHAIN` | `Ripple` | a memo-required destination chain | + +Two preconditions the flows cannot check for you: + +- **The source wallet must have no unconfirmed outgoing transaction.** A pending + send blocks the next one, so the quote never arms and the flow fails on the + slider rather than on anything it is testing. Running the funded flows + back-to-back on one wallet hits this. +- **The source wallet must hold more than the amount asked for.** The amounts are + fiat, so they sit against the floors on their own and need no re-checking as + prices move, but a wallet that has drifted below `STEALTH_AMOUNT` in value + fails the private branches on the balance rather than on the branch. + +`stealth-send.yaml`, `stealth-swap.yaml` and `stealth-qr-payment-uri.yaml` +request no quote, so they need no balance at all. + +## The flows + +| Flow | Branch it drives | +| ----------------------------------- | -------------------------------------------------------------------- | +| `stealth-send.yaml` | Send scene controls before address entry, plus a memo-chain tag row | +| `stealth-swap.yaml` | Exchange scene toggle card | +| `stealth-qr-payment-uri.yaml` | scanned payment URI, cross-chain, amount on the recipient side | +| `stealth-myself-picker.yaml` | own-wallet destinations grouped same-asset first | +| `stealth-cross-asset-quote.yaml` | plain Swap & Send on a transparent route | +| `stealth-private-quote.yaml` | the toggle invalidating a held quote and refetching a private route | +| `stealth-below-private-minimum.yaml`| the toggle refusing under the private floor, with no request sent | +| `stealth-tx-details.yaml` | a completed stealth transaction's identity rows | +| `stealth-execute-send.yaml` | **spends** a private Stealth Send to the success scene | +| `stealth-execute-swap.yaml` | **spends** a Stealth Swap from the Exchange scene | + +## Composing your own + +Each subflow states its env contract in its header. A walk that needs a live +private quote on a pair the suite does not cover is five `runFlow` steps: + +```yaml +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: My Tron 2 +- runFlow: + file: ../common/stealth-pick-myself.yaml + env: + DEST_WALLET: My Litecoin (new) +- runFlow: + file: ../common/stealth-set-amount.yaml + env: + ROW: You send + AMOUNT: '30' +- runFlow: + file: ../common/stealth-toggle.yaml +- runFlow: + file: ../common/stealth-await-quote.yaml +``` + +Two things bite when writing these by hand: + +- **The confirm slider is a pan gesture.** A coordinate swipe across the track + does nothing at all. `stealth-slide-to-confirm.yaml` swipes from the thumb by + id, which is the only form that completes it. +- **Notification cards float over the bottom of every scene**, including the + slider and the amount rows. `stealth-launch.yaml` swipes them away; skip it + and later taps land on a card instead of the control underneath. + +## Assertions + +These flows are for driving the app to a state, not for asserting behavior. Each +one asserts only what it must to gate the next step (that a scene arrived, that +a quote settled, that the slider is live). Behavioral claims belong in the unit +tests and in `src/docs/stealth-send-swap.md`. diff --git a/maestro/14-stealth/stealth-below-private-minimum.yaml b/maestro/14-stealth/stealth-below-private-minimum.yaml new file mode 100644 index 00000000000..f9a90dccd7e --- /dev/null +++ b/maestro/14-stealth/stealth-below-private-minimum.yaml @@ -0,0 +1,39 @@ +# Below the private floor the toggle refuses, client-side. +# +# The provider serves no private route under 25 USD, so an order under it is +# pre-empted before a request goes out rather than sent and refused. The toggle +# stays off and the card explains the floor. The transparent route is still +# available at the same amount, which is what keeps a plain Swap & Send working +# in the band between the two floors. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Stellar'} + DEST_WALLET: ${STEALTH_DEST_WALLET || 'My Sonic'} + # Fiat, between the 10 USD transparent floor and the 25 USD private one: + AMOUNT: ${STEALTH_BELOW_MIN_AMOUNT || '15'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} +- runFlow: + file: ../common/stealth-pick-myself.yaml + env: + DEST_WALLET: ${DEST_WALLET} +- runFlow: + file: ../common/stealth-set-amount.yaml + env: + ROW: 'You send' + AMOUNT: ${AMOUNT} +- runFlow: + file: ../common/stealth-await-quote.yaml + +- runFlow: + file: ../common/stealth-toggle.yaml +- assertVisible: 'Private routing needs at least.*' +- takeScreenshot: maestro/screenshots/stealth-below-min-01-toggle-refused diff --git a/maestro/14-stealth/stealth-cross-asset-quote.yaml b/maestro/14-stealth/stealth-cross-asset-quote.yaml new file mode 100644 index 00000000000..254a4557dcd --- /dev/null +++ b/maestro/14-stealth/stealth-cross-asset-quote.yaml @@ -0,0 +1,36 @@ +# A plain cross-asset Swap & Send: no privacy requested, so a transparent +# (single-exchange) route is acceptable and the order clears the lower floor. +# +# Ends on a live quote with the slider armed. Nothing is sent. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Stellar'} + DEST_WALLET: ${STEALTH_DEST_WALLET || 'My Sonic'} + # Fiat, and above the 25 USD private floor: + AMOUNT: ${STEALTH_AMOUNT || '30'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} +- runFlow: + file: ../common/stealth-pick-myself.yaml + env: + DEST_WALLET: ${DEST_WALLET} +- runFlow: + file: ../common/stealth-set-amount.yaml + env: + ROW: 'You send' + AMOUNT: ${AMOUNT} +- runFlow: + file: ../common/stealth-await-quote.yaml +# The edited side owns the guarantee, and each row states which it is in its +# own title. Asserting both catches a regression that swaps or drops them. +- assertVisible: 'You send \(Guaranteed\)' +- assertVisible: 'Recipient gets \(Estimated\)' +- takeScreenshot: maestro/screenshots/stealth-cross-asset-01-standard-quote diff --git a/maestro/14-stealth/stealth-execute-send.yaml b/maestro/14-stealth/stealth-execute-send.yaml new file mode 100644 index 00000000000..7560c8075a4 --- /dev/null +++ b/maestro/14-stealth/stealth-execute-send.yaml @@ -0,0 +1,45 @@ +# Executes a private cross-asset Stealth Send end to end, to the success scene. +# +# THIS SPENDS FUNDS. It carries the `stealth-spend` tag ONLY, so a run of the +# rest of the suite by the `stealth` tag never triggers it. See README.md in +# this directory for how to invoke it deliberately. +# +# Sending to one of the account's own wallets keeps the principal inside the +# account, so the cost is the route's spread plus network fees. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Stellar'} + DEST_WALLET: ${STEALTH_DEST_WALLET || 'My Sonic'} + # Fiat, and above the 25 USD private floor: + AMOUNT: ${STEALTH_AMOUNT || '30'} +tags: + - stealth-spend +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} +- runFlow: + file: ../common/stealth-pick-myself.yaml + env: + DEST_WALLET: ${DEST_WALLET} +- runFlow: + file: ../common/stealth-set-amount.yaml + env: + ROW: 'You send' + AMOUNT: ${AMOUNT} +- runFlow: + file: ../common/stealth-toggle.yaml +- runFlow: + file: ../common/stealth-await-quote.yaml + env: + LABEL: 'Slide to send stealthily' +- runFlow: + file: ../common/stealth-slide-to-confirm.yaml +- extendedWaitUntil: + visible: 'Transaction Details' + timeout: 90000 +- takeScreenshot: maestro/screenshots/stealth-execute-01-success diff --git a/maestro/14-stealth/stealth-execute-swap.yaml b/maestro/14-stealth/stealth-execute-swap.yaml new file mode 100644 index 00000000000..2f56d162482 --- /dev/null +++ b/maestro/14-stealth/stealth-execute-swap.yaml @@ -0,0 +1,79 @@ +# Executes a Stealth Swap from the Exchange scene end to end. +# +# THIS SPENDS FUNDS. `stealth-spend` tag only, same as stealth-execute-send. +# +# The Exchange scene's flip input opens on fiat, so AMOUNT_FIAT is a fiat +# figure here, unlike the Send scene walks. +# +# It runs the OPPOSITE direction to stealth-execute-send on purpose. Run that +# one first and this one second, and the funds come back: the send moves the +# source wallet's balance to the destination, and this moves it back, so the +# pair costs two spreads rather than leaving one wallet empty. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SWAP_SRC_WALLET || 'My Sonic'} + DEST_WALLET: ${STEALTH_SWAP_DEST_WALLET || 'My Stellar'} + AMOUNT_FIAT: ${STEALTH_SWAP_AMOUNT_FIAT || '25'} +tags: + - stealth-spend +--- +- runFlow: + file: ../common/stealth-launch.yaml +- tapOn: 'Exchange' +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: 'Select Source Wallet' +- waitForAnimationToEnd: + timeout: 2500 +- tapOn: 'Search Wallets' +- inputText: '${SRC_WALLET}' +# Wait for the row the filter should produce: text typed before the search field +# settles is dropped, leaving the list unfiltered and the row absent. +- extendedWaitUntil: + visible: + id: 'walletPickerRow.${SRC_WALLET}' + timeout: 20000 +- tapOn: + id: 'walletPickerRow.${SRC_WALLET}' +- waitForAnimationToEnd: + timeout: 3500 +- tapOn: 'Select Receiving Wallet' +- waitForAnimationToEnd: + timeout: 2500 +- tapOn: 'Search Wallets' +- inputText: '${DEST_WALLET}' +# Wait for the row the filter should produce: text typed before the search field +# settles is dropped, leaving the list unfiltered and the row absent. +- extendedWaitUntil: + visible: + id: 'walletPickerRow.${DEST_WALLET}' + timeout: 20000 +- tapOn: + id: 'walletPickerRow.${DEST_WALLET}' +- waitForAnimationToEnd: + timeout: 3500 + +- runFlow: + file: ../common/stealth-toggle.yaml + env: + TOGGLE: 'Stealth Swap' +- tapOn: + text: 'Tap to edit' + index: 0 +- waitForAnimationToEnd: + timeout: 2500 +- inputText: '${AMOUNT_FIAT}' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'Next' +- extendedWaitUntil: + visible: 'Slide to Confirm' + timeout: 60000 +- takeScreenshot: maestro/screenshots/stealth-execute-swap-01-quote +- runFlow: + file: ../common/stealth-slide-to-confirm.yaml +- extendedWaitUntil: + visible: 'Transaction Details' + timeout: 90000 +- takeScreenshot: maestro/screenshots/stealth-execute-swap-02-success diff --git a/maestro/14-stealth/stealth-myself-picker.yaml b/maestro/14-stealth/stealth-myself-picker.yaml new file mode 100644 index 00000000000..19a1f295fe2 --- /dev/null +++ b/maestro/14-stealth/stealth-myself-picker.yaml @@ -0,0 +1,27 @@ +# The "Myself" destination picker. +# +# Supported destinations are derived from the route table, not from a native-only +# rule, so the picker offers same-asset wallets under a "Same Asset" heading and +# every other wallet whose asset the provider pays out to under "Other Assets". +# The sending wallet itself is never offered, since a send to yourself in the +# same wallet is not a destination. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Stellar'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} +- tapOn: + id: 'addressTileMyself' +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: 'Same Asset' +- assertVisible: 'Other Assets' +- takeScreenshot: maestro/screenshots/stealth-myself-01-grouped diff --git a/maestro/14-stealth/stealth-private-quote.yaml b/maestro/14-stealth/stealth-private-quote.yaml new file mode 100644 index 00000000000..216e42fd156 --- /dev/null +++ b/maestro/14-stealth/stealth-private-quote.yaml @@ -0,0 +1,44 @@ +# Turning Stealth on re-quotes. +# +# The transparent and private routes are different requests, so a held quote +# cannot be reused across the toggle. Flipping it throws the quote away, drops +# the slider back to its disabled state, and fetches again; the slider label +# changes to "Slide to send stealthily" once the private route arrives. +# +# Ends on a live private quote. Nothing is sent. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Stellar'} + DEST_WALLET: ${STEALTH_DEST_WALLET || 'My Sonic'} + # Fiat, and above the 25 USD private floor: + AMOUNT: ${STEALTH_AMOUNT || '30'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} +- runFlow: + file: ../common/stealth-pick-myself.yaml + env: + DEST_WALLET: ${DEST_WALLET} +- runFlow: + file: ../common/stealth-set-amount.yaml + env: + ROW: 'You send' + AMOUNT: ${AMOUNT} +- runFlow: + file: ../common/stealth-await-quote.yaml + +- runFlow: + file: ../common/stealth-toggle.yaml +- takeScreenshot: maestro/screenshots/stealth-private-01-requoting +- runFlow: + file: ../common/stealth-await-quote.yaml + env: + LABEL: 'Slide to send stealthily' +- takeScreenshot: maestro/screenshots/stealth-private-02-private-quote diff --git a/maestro/14-stealth/stealth-qr-payment-uri.yaml b/maestro/14-stealth/stealth-qr-payment-uri.yaml new file mode 100644 index 00000000000..95134fc9f93 --- /dev/null +++ b/maestro/14-stealth/stealth-qr-payment-uri.yaml @@ -0,0 +1,38 @@ +# Scanned payment-URI walk for the send-to-address swap UI. +# +# A scanned QR carries a payment URI (`ethereum:0x...?amount=0.5`), never a bare +# address. This covers both halves of that handling: the destination address is +# extracted from the URI and accepted even though the source wallet cannot parse +# a foreign-chain URI, and the URI's amount lands on the RECIPIENT side as the +# guaranteed amount, because a payment request states what the recipient should +# receive while the send side comes from the quote. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Tron 2'} + PAYMENT_URI: ${STEALTH_PAYMENT_URI || 'ethereum:0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372?amount=0.5'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} + +# Cross-asset destination: the source wallet cannot parse this chain's URI. +- runFlow: + file: ../common/stealth-pick-recipient-asset.yaml + env: + DEST_CHAIN: 'ethereum' +- runFlow: + file: ../common/stealth-scan-address.yaml + env: + ADDRESS: ${PAYMENT_URI} + +# The address came out of the URI, and its amount is the guaranteed side. +- assertVisible: '.*0x1f36.*' +- assertVisible: '.*0.5 ETH.*' +- assertVisible: '.*Recipient gets \(Guaranteed\).*' +- takeScreenshot: maestro/screenshots/stealth-qr-01-cross-chain diff --git a/maestro/14-stealth/stealth-send.yaml b/maestro/14-stealth/stealth-send.yaml new file mode 100644 index 00000000000..8be4509564a --- /dev/null +++ b/maestro/14-stealth/stealth-send.yaml @@ -0,0 +1,41 @@ +# Stealth Send UI walk: the send-to-address swap controls before any address is +# entered, so no quote is requested. +# +# Covers the "Recipient receives" selector that appears ahead of address entry, +# the Stealth Send toggle with its explainer copy and inline "Learn more" link, +# and the Destination Tag row a memo-required destination chain adds. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Tron 2'} + DEST_CHAIN: ${STEALTH_MEMO_CHAIN || 'stellar'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} + +# Plain same-asset start: the recipient selector is available before an address. +- assertVisible: 'Stealth Send' +- assertVisible: 'Recipient receives' +- takeScreenshot: maestro/screenshots/stealth-send-01-initial + +# Stealth on: the toggle card expands with the explainer and Learn more link. +- runFlow: + file: ../common/stealth-toggle.yaml +- assertVisible: 'Uses a route that helps obfuscate.*Learn more.*' +- takeScreenshot: maestro/screenshots/stealth-send-02-stealth-on +- runFlow: + file: ../common/stealth-toggle.yaml + +# A memo-required destination chain adds its tag row. +- runFlow: + file: ../common/stealth-pick-recipient-asset.yaml + env: + DEST_CHAIN: ${DEST_CHAIN} +- assertVisible: 'Destination Tag' +- takeScreenshot: maestro/screenshots/stealth-send-03-memo-chain diff --git a/maestro/14-stealth/stealth-swap.yaml b/maestro/14-stealth/stealth-swap.yaml new file mode 100644 index 00000000000..469ccc30e44 --- /dev/null +++ b/maestro/14-stealth/stealth-swap.yaml @@ -0,0 +1,24 @@ +# Stealth Swap UI walk: the toggle card on the Exchange scene during amount +# entry. No quote is requested. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Tron 2'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-swap.yaml + env: + WALLET: ${SRC_WALLET} +- assertVisible: 'Stealth Swap' +- takeScreenshot: maestro/screenshots/stealth-swap-01-start + +- runFlow: + file: ../common/stealth-toggle.yaml + env: + TOGGLE: 'Stealth Swap' +- assertVisible: 'Routes your swap through multiple.*Learn more.*' +- takeScreenshot: maestro/screenshots/stealth-swap-02-stealth-on diff --git a/maestro/14-stealth/stealth-tx-details.yaml b/maestro/14-stealth/stealth-tx-details.yaml new file mode 100644 index 00000000000..fd324b97cd1 --- /dev/null +++ b/maestro/14-stealth/stealth-tx-details.yaml @@ -0,0 +1,52 @@ +# The transaction identity of a completed stealth swap-send. +# +# Opens the newest stealth transaction in the source wallet and walks the rows +# that make it legible without exposing the recipient: the title names the +# mechanism, the real payout address is suppressed, and the exchange order +# details are shown so the user can follow their own order. +# +# Requires a stealth send to have already completed in the source wallet, so the +# default matches stealth-execute-send.yaml's source: run that one first and +# this reads the transaction it produced. Pointing this at a wallet whose +# history has not caught up yet fails on the scroll, which looks like a broken +# selector and is not one. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + SRC_WALLET: ${STEALTH_SRC_WALLET || 'My Stellar'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-wallet.yaml + env: + WALLET: ${SRC_WALLET} +# The transaction list sits below the balance card and the price chart, so the +# row is off-screen when the scene opens. +# By row id, not by text. A transaction row's accessible label is the whole row +# joined together, so a text match resolves to a container far larger than the +# row and taps its centre, which lands on nothing. `visibilityPercentage` also +# has to be a fraction: the container is taller than the viewport, so it never +# reaches the default 100. +- scrollUntilVisible: + element: + id: 'txListRow_.*Stealth.*' + direction: DOWN + timeout: 20000 + visibilityPercentage: 30 +- tapOn: + id: 'txListRow_.*Stealth.*' +- waitForAnimationToEnd: + timeout: 4000 +- takeScreenshot: maestro/screenshots/stealth-tx-01-identity +# Same fractional-visibility rule as the transaction row: the node this text +# belongs to is taller than the viewport. +- scrollUntilVisible: + element: + text: 'Exchange Status Page' + direction: DOWN + timeout: 15000 + visibilityPercentage: 30 +- takeScreenshot: maestro/screenshots/stealth-tx-02-order-details diff --git a/maestro/common/stealth-await-quote.yaml b/maestro/common/stealth-await-quote.yaml new file mode 100644 index 00000000000..e990dd81364 --- /dev/null +++ b/maestro/common/stealth-await-quote.yaml @@ -0,0 +1,39 @@ +# Waits for a swap quote to settle and for the confirm slider to arm. +# +# env LABEL the slider text to wait for: "Slide to Confirm" on a transparent +# route, "Slide to send stealthily" once Stealth is on. +# +# The rate row reads "Getting quote..." while a request is out. Waiting only for +# that string to CLEAR is not enough, because a freshly entered amount takes a +# moment to start its request and the wait would pass before the spinner ever +# appeared, so this waits for it to show up first. The slider then has to be +# scrolled to: it sits below the toggle card and a long address pushes it off +# the bottom of the scene. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + # Declared under a different name than the one callers pass: a subflow env + # entry that reuses the caller's key overwrites what the caller passed, so + # `LABEL: ${LABEL || default}` would silently ignore the argument. + SLIDER_LABEL: ${LABEL || 'Slide to Confirm'} + QUOTE_TIMEOUT: ${STEALTH_QUOTE_TIMEOUT || 60000} +--- +- extendedWaitUntil: + visible: 'Getting quote...' + timeout: 10000 + optional: true +- extendedWaitUntil: + notVisible: 'Getting quote...' + timeout: ${QUOTE_TIMEOUT} +- waitForAnimationToEnd: + timeout: 3000 +- scrollUntilVisible: + element: + text: '${SLIDER_LABEL}' + direction: DOWN + timeout: 15000 + centerElement: true + optional: true +- extendedWaitUntil: + visible: '${SLIDER_LABEL}' + timeout: 30000 diff --git a/maestro/common/stealth-enter-address.yaml b/maestro/common/stealth-enter-address.yaml new file mode 100644 index 00000000000..2d593b4316d --- /dev/null +++ b/maestro/common/stealth-enter-address.yaml @@ -0,0 +1,19 @@ +# Types a destination address into the address tile. +# +# env ADDRESS the address, or a payment URI. A URI belonging to another chain +# is accepted: the source wallet cannot parse it, so the scene +# resolves it against the served destination chains instead. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- tapOn: + id: 'addressTileEnter' +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: + id: 'textInputModal.textInput' +- inputText: '${ADDRESS}' +- tapOn: 'Submit' +- waitForAnimationToEnd: + timeout: 4000 diff --git a/maestro/common/stealth-launch.yaml b/maestro/common/stealth-launch.yaml new file mode 100644 index 00000000000..618d717c5f8 --- /dev/null +++ b/maestro/common/stealth-launch.yaml @@ -0,0 +1,64 @@ +# Shared preamble for every stealth walk: launch, clear the PIN gate, and get +# rid of the modals and notification cards a fresh launch raises. The password +# reminder card in particular floats over the bottom third of every scene and +# covers the confirm slider, so it is swiped away rather than left in place. +# +# Leaves the app on whatever scene the account last showed. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + PIN_DIGIT: ${STEALTH_PIN_DIGIT || '0'} +--- +- launchApp +- repeat: + times: 6 + while: + visible: 'Exit PIN' + commands: + # One digit per pass. The keypad unmounts the moment the login resolves + # and can drop a digit from under a tap already on its way, so a fixed run + # of four taps loses one and strands the scene. The `while` exits as soon + # as the gate clears, which also keeps an already-unlocked app from paying + # for six pointless passes, and `times` bounds it if the gate never does. + # + # PIN_DIGIT must be the account's single repeated digit, as it is for the + # roster accounts. That is what makes a retry safe: no sequence of taps on + # one digit can assemble a wrong PIN and walk the account into its lockout + # backoff. + - tapOn: + text: '${PIN_DIGIT}' + waitToSettleTimeoutMs: 900 + retryTapIfNoChange: false + optional: true +- runFlow: + when: + visible: 'Security is Our Priority' + commands: + - tapOn: 'Cancel' +- runFlow: + when: + visible: 'How Did You Discover Edge?' + commands: + - tapOn: 'Dismiss' +- runFlow: + when: + visible: 'Claim Your Web3 Handle' + commands: + - tapOn: 'Not Now' +# The password-reminder card floats over the bottom of every scene, including +# the confirm slider and the amount rows, so it is swiped away before anything +# else runs. Only this one is handled: it is the card the roster accounts show, +# and every extra visibility check costs real wall-clock on a scene that does +# not have the card. An account that shows the 2FA or IP cards instead wants +# `notifOtp` / `notifIp2Fa` added the same way. +- swipe: + from: + id: 'notifPassword' + direction: LEFT + optional: true +# The contract this subflow owes its callers: signed in, and far enough through +# the mount that the tab bar exists. Without this a caller's first tap races the +# launch and misses. +- extendedWaitUntil: + visible: 'Assets' + timeout: 40000 diff --git a/maestro/common/stealth-open-send.yaml b/maestro/common/stealth-open-send.yaml new file mode 100644 index 00000000000..89ac9772ac2 --- /dev/null +++ b/maestro/common/stealth-open-send.yaml @@ -0,0 +1,58 @@ +# Opens the Send scene for one wallet. +# +# env WALLET the source wallet's name, e.g. "My Sonic". +# +# Reached through the home scene's Send button rather than the wallet's own +# transaction list: the balance card's Send opens the transfer modal, whose +# "To Another Wallet/Exchange" row leads to a wallet picker and then straight +# to the Send scene. That route is one modal deep and never depends on which +# scene the Assets tab lands on for a given wallet row. +# +# The picker's rows carry `walletPickerRow.`, not the scene's +# `walletListRow.`: the picker floats over a scene whose rows repeat the +# same names, and one shared namespace resolved a tap to the COVERED row, which +# dismissed the sheet instead of choosing a wallet. +# +# An unfunded source wallet offers to buy or exchange first; that modal is +# declined so the walk reaches the Send scene either way. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- tapOn: 'Home' +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: 'Send' +- extendedWaitUntil: + visible: 'To Another Wallet/Exchange' + timeout: 15000 +- tapOn: 'To Another Wallet/Exchange' +- extendedWaitUntil: + visible: 'Select Wallet to Send From' + timeout: 15000 +# By id, not by placeholder: the placeholder is gone once the field holds a +# query, and a previous walk's term would make the field unfindable. +- tapOn: + id: 'walletPickerSearch' +- inputText: '${WALLET}' +# Waiting for the ROW rather than for an animation: the search field is inside +# a sheet that is still settling, and text typed before it settles is dropped +# silently, leaving the list unfiltered and the row absent. +- extendedWaitUntil: + visible: + id: 'walletPickerRow.${WALLET}' + timeout: 20000 +- tapOn: + id: 'walletPickerRow.${WALLET}' +- waitForAnimationToEnd: + timeout: 3500 +- runFlow: + when: + visible: 'Wallet Empty' + commands: + - tapOn: 'Not at this time' + - waitForAnimationToEnd: + timeout: 2000 +- extendedWaitUntil: + visible: 'Send to Address' + timeout: 20000 diff --git a/maestro/common/stealth-open-swap.yaml b/maestro/common/stealth-open-swap.yaml new file mode 100644 index 00000000000..fbb768418de --- /dev/null +++ b/maestro/common/stealth-open-swap.yaml @@ -0,0 +1,16 @@ +# Opens the Exchange (swap) scene with one wallet already chosen as the source. +# +# env WALLET the source wallet's name, e.g. "My PIVX 2". +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- runFlow: + file: stealth-open-wallet.yaml + env: + WALLET: ${WALLET} +- tapOn: 'Trade' +- tapOn: + text: 'Swap .* to/from another crypto' +- waitForAnimationToEnd: + timeout: 3500 diff --git a/maestro/common/stealth-open-wallet.yaml b/maestro/common/stealth-open-wallet.yaml new file mode 100644 index 00000000000..2bcf825de44 --- /dev/null +++ b/maestro/common/stealth-open-wallet.yaml @@ -0,0 +1,38 @@ +# Opens one wallet's transaction list from the Assets scene. +# +# env WALLET the wallet's name, e.g. "My Sonic". Matched against the row +# label, so it must be the exact name shown in the wallet list. +# +# Gates on the transaction list's own "Receive" button before returning, so a +# caller tapping "Send" next cannot misfire on the home scene's Send button. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- tapOn: 'Assets' +- waitForAnimationToEnd: + timeout: 2500 +# By id, not by placeholder: the placeholder is gone once the field holds a +# query, and the query survives a relaunch, so a previous walk's search term +# would make the field unfindable. +- runFlow: + when: + visible: + id: 'searchFooter.WalletListScene-SearchFooter.clearIcon' + commands: + - tapOn: + id: 'searchFooter.WalletListScene-SearchFooter.clearIcon' +- tapOn: + id: 'searchFooter.WalletListScene-SearchFooter.textInput' +- inputText: '${WALLET}' +# Waiting for the ROW rather than for an animation: the search footer expands on +# focus, and text typed into it before it settles is dropped silently, leaving +# the list unfiltered and the row absent. Waiting on the row is the only check +# that proves the filter actually took. +- extendedWaitUntil: + visible: + id: 'walletListRow.${WALLET}' + timeout: 20000 +- tapOn: + id: 'walletListRow.${WALLET}' +- assertVisible: 'Receive' diff --git a/maestro/common/stealth-pick-myself.yaml b/maestro/common/stealth-pick-myself.yaml new file mode 100644 index 00000000000..688526c2266 --- /dev/null +++ b/maestro/common/stealth-pick-myself.yaml @@ -0,0 +1,31 @@ +# Adopts one of the account's own wallets as the send destination, through the +# address tile's "Myself" affordance. +# +# env DEST_WALLET the destination wallet's name, e.g. "My Stellar". +# +# The picker lists same-asset wallets under a "Same Asset" heading first, then +# every other wallet whose asset Houdini can pay out to. The source wallet is +# never offered. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- tapOn: + id: 'addressTileMyself' +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: + id: 'walletPickerSearch.textInput' +- inputText: '${DEST_WALLET}' +# Same race as the Assets search: wait for the row the filter should produce, +# not for a fixed animation. +- extendedWaitUntil: + visible: + id: 'walletPickerRow.${DEST_WALLET}' + timeout: 20000 +# Rows carry their wallet name as a testID. A plain text selector would match +# the search field, which holds the same string, rather than the row. +- tapOn: + id: 'walletPickerRow.${DEST_WALLET}' +- waitForAnimationToEnd: + timeout: 4000 diff --git a/maestro/common/stealth-pick-recipient-asset.yaml b/maestro/common/stealth-pick-recipient-asset.yaml new file mode 100644 index 00000000000..24cf2d60e6a --- /dev/null +++ b/maestro/common/stealth-pick-recipient-asset.yaml @@ -0,0 +1,27 @@ +# Changes which asset the recipient receives, without choosing an address. +# +# env DEST_CHAIN the destination chain's Edge pluginId, e.g. "ethereum", +# "litecoin", "ripple". The picker keys its rows on the asset +# rather than on a display name, because a token can share both +# its name and its code with a chain (the POL ERC-20 and +# Polygon). +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- tapOn: 'Recipient receives' +- waitForAnimationToEnd: + timeout: 2500 +# The list holds every served chain, so most of it is below the fold and the +# row has to be scrolled to before it can be tapped. The search box above it is +# the faster path by hand, but the row id is stable either way. +- scrollUntilVisible: + element: + id: 'radioListItem_${DEST_CHAIN}' + direction: DOWN + timeout: 20000 + centerElement: true +- tapOn: + id: 'radioListItem_${DEST_CHAIN}' +- waitForAnimationToEnd: + timeout: 2500 diff --git a/maestro/common/stealth-scan-address.yaml b/maestro/common/stealth-scan-address.yaml new file mode 100644 index 00000000000..1aa7ded6f67 --- /dev/null +++ b/maestro/common/stealth-scan-address.yaml @@ -0,0 +1,24 @@ +# Delivers an address or payment URI the way a scanned QR does. +# +# env ADDRESS the address or payment URI to resolve. +# +# The simulator has no camera, so the value goes through the scan modal's +# keyboard-entry affordance, which runs the same code path a decoded QR takes. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- tapOn: + id: 'addressTileScan' +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: + id: 'scanModalTextInput' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: + id: 'textInputModal.textInput' +- inputText: '${ADDRESS}' +- tapOn: 'Submit' +- waitForAnimationToEnd: + timeout: 4000 diff --git a/maestro/common/stealth-set-amount.yaml b/maestro/common/stealth-set-amount.yaml new file mode 100644 index 00000000000..ef5b1e91506 --- /dev/null +++ b/maestro/common/stealth-set-amount.yaml @@ -0,0 +1,54 @@ +# Sets one side of the linked swap-send amounts. +# +# env ROW which row to edit: "You send" or "Recipient gets". The edited +# side becomes the guaranteed amount and the other tracks the +# quote as an estimate. +# env AMOUNT the number to type, in the account's fiat currency. +# +# Both rows open their flip input on fiat, so AMOUNT is a fiat figure and the +# crypto amount is whatever the live rate makes of it. That is the denomination +# the provider's floors are stated in, so a caller aiming above or below one of +# them says so directly instead of converting into source units first. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + # Declared under a different name than the one callers pass: a subflow env + # entry that reuses the caller's key overwrites what the caller passed, so + # `ROW: ${ROW || default}` would silently ignore the argument. + AMOUNT_ROW: ${ROW || 'You send'} +--- +# Rows can sit above or below the fold depending on where the previous step +# left the scroll position, so the row is brought into view from either side +# before it is tapped. A `scrollUntilVisible` that is already satisfied is a +# no-op, and one that scrolls the wrong way is optional and warns. +# +# The trailing `.*` is load-bearing: text matching is a full-string match, and +# the row's title carries its own state word, as in "You send (Guaranteed)". +- scrollUntilVisible: + element: + text: '${AMOUNT_ROW}.*' + direction: UP + timeout: 4000 + centerElement: true + optional: true +- scrollUntilVisible: + element: + text: '${AMOUNT_ROW}.*' + direction: DOWN + timeout: 4000 + centerElement: true + optional: true +- tapOn: '${AMOUNT_ROW}.*' +- waitForAnimationToEnd: + timeout: 3000 +# The flip input opens pre-filled with whatever the row already holds, and +# `inputText` appends. On a first edit the field is empty and this erases +# nothing; on a re-edit it is the difference between setting the amount and +# concatenating onto the old one, which commits a number nobody asked for. +- eraseText +- inputText: '${AMOUNT}' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'Done' +- waitForAnimationToEnd: + timeout: 4000 diff --git a/maestro/common/stealth-slide-to-confirm.yaml b/maestro/common/stealth-slide-to-confirm.yaml new file mode 100644 index 00000000000..c6177c95270 --- /dev/null +++ b/maestro/common/stealth-slide-to-confirm.yaml @@ -0,0 +1,19 @@ +# Drives the confirmation slider. +# +# The slider is a pan-gesture handler that completes only when its thumb +# reaches the far left. A coordinate swipe across the track does NOT activate +# the gesture and leaves the slider untouched, so the swipe has to originate +# from the thumb itself by id. +# +# THIS SPENDS FUNDS on a scene that holds a live quote. Only the flows tagged +# `stealth-spend` call it. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- swipe: + from: + id: 'confirmSliderThumb' + direction: LEFT +- waitForAnimationToEnd: + timeout: 20000 diff --git a/maestro/common/stealth-toggle.yaml b/maestro/common/stealth-toggle.yaml new file mode 100644 index 00000000000..4377beb004f --- /dev/null +++ b/maestro/common/stealth-toggle.yaml @@ -0,0 +1,37 @@ +# Flips the Stealth toggle. +# +# env TOGGLE "Stealth Send" on the Send scene, "Stealth Swap" on the Exchange +# scene. Defaults to the Send scene's label. +# +# Toggling always invalidates any held quote and refetches, because the private +# and transparent routes are different requests, so callers that need a live +# quote afterwards should follow with stealth-await-quote.yaml. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + # Declared under a different name than the one callers pass: a subflow env + # entry that reuses the caller's key overwrites what the caller passed, so + # `TOGGLE: ${TOGGLE || default}` would silently ignore the argument. + TOGGLE_LABEL: ${TOGGLE || 'Stealth Send'} +--- +# Rows can sit above or below the fold depending on where the previous step +# left the scroll position, so the row is brought into view from either side +# before it is tapped. A `scrollUntilVisible` that is already satisfied is a +# no-op, and one that scrolls the wrong way is optional and warns. +- scrollUntilVisible: + element: + text: '${TOGGLE_LABEL}' + direction: UP + timeout: 4000 + centerElement: true + optional: true +- scrollUntilVisible: + element: + text: '${TOGGLE_LABEL}' + direction: DOWN + timeout: 4000 + centerElement: true + optional: true +- tapOn: '${TOGGLE_LABEL}' +- waitForAnimationToEnd: + timeout: 2500 From f0ca0cc28d462e576ef8b26bf7d464930f887d9e Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Tue, 18 Aug 2026 16:33:20 -0700 Subject: [PATCH 15/18] Key radio list rows on a value, and offer an optional search A row's label was also its selection key, which only holds while every label is unique. It is not: the POL ERC-20 on Ethereum carries the same display name and currency code as the Polygon chain, so a list holding both marked both rows selected and resolved either tap to the same row. Rows may now carry a `value`, which defaults to the name so existing callers are unaffected, and which the row's testID follows. `searchPlaceholder` turns on the search box `ListModal` already provides, filtering on the label and its subtext. Lists that omit it are unfiltered as before. Submitting is a no-op rather than resolving the bridge with the raw search text, which would close the modal without picking anything. --- src/components/modals/RadioListModal.tsx | 50 +++++++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/src/components/modals/RadioListModal.tsx b/src/components/modals/RadioListModal.tsx index a255d4298bd..94ddc50a618 100644 --- a/src/components/modals/RadioListModal.tsx +++ b/src/components/modals/RadioListModal.tsx @@ -5,6 +5,7 @@ import IonIcon from 'react-native-vector-icons/Ionicons' import { useHandler } from '../../hooks/useHandler' import { lstrings } from '../../locales/strings' +import { normalizeForSearch } from '../../util/utils' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { UnscaledText } from '../text/UnscaledText' @@ -16,6 +17,15 @@ interface Item { icon: string | number | React.ReactNode name: string text?: string + /** + * What this row resolves to, and what `selected` is matched against. + * Defaults to `name`, which is only safe while every label is unique. Pass + * an id whenever two rows can legitimately carry the same label: the POL + * ERC-20 on Ethereum and the Polygon chain are both named "Polygon", so a + * name-keyed list renders both as selected and resolves either tap to the + * same row. + */ + value?: string } interface Props { @@ -25,17 +35,38 @@ interface Props { selected?: string /** Explanatory copy between the title and the list. */ message?: string + /** + * Placeholder for a search box above the list. Passing it turns searching + * on; omitting it leaves the list unfiltered, which is right for the short + * fixed lists most callers show. + */ + searchPlaceholder?: string } export const RadioListModal: React.FC = props => { - const { bridge, items, message, selected, title } = props + const { bridge, items, message, searchPlaceholder, selected, title } = props const theme = useTheme() const styles = getStyles(theme) + const handleRowDataFilter = useHandler( + (filterText: string, item: Item): boolean => { + const search = normalizeForSearch(filterText) + return ( + normalizeForSearch(item.name).includes(search) || + (item.text != null && normalizeForSearch(item.text).includes(search)) + ) + } + ) + + // `ListModal` resolves its bridge with the raw search text on submit, which + // would close this modal on a return key press without picking anything. + // There is nothing to submit here: the keyboard still dismisses itself. + const handleSubmitEditing = useHandler((): void => {}) + const renderRow = useHandler((item: Item) => { - const { name, icon, text } = item + const { name, icon, text, value = name } = item - const isSelected = selected === name + const isSelected = selected === value const radio = isSelected ? { icon: 'radio-button-on', color: theme.iconTappable } : { icon: 'radio-button-off', color: theme.iconTappable } @@ -61,9 +92,9 @@ export const RadioListModal: React.FC = props => { return ( { - bridge.resolve(name) + bridge.resolve(value) }} > @@ -91,9 +122,16 @@ export const RadioListModal: React.FC = props => { bridge={bridge} title={title} message={message} - textInput={false} + textInput={searchPlaceholder != null} + label={searchPlaceholder} + autoCorrect={false} + autoCapitalize="none" rowsData={items} rowComponent={renderRow} + rowDataFilter={ + searchPlaceholder == null ? undefined : handleRowDataFilter + } + onSubmitEditing={handleSubmitEditing} fullScreen={false} /> ) From a8ef69f6c7c9f1a5492bbcd82ceb0d109c2604f9 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 27 Aug 2026 16:03:55 -0700 Subject: [PATCH 16/18] Show Houdini terms on the swap scene The dedicated swap scene already asks for a one-time terms acknowledgement on every centralized provider it routes through, keyed off the provider's own agreedToTerms user setting. Houdini had no entry, so a plain swap routed by it showed nothing. --- src/components/modals/SwapVerifyTermsModal.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/modals/SwapVerifyTermsModal.tsx b/src/components/modals/SwapVerifyTermsModal.tsx index 0b8e0ccb763..396dba1ece6 100644 --- a/src/components/modals/SwapVerifyTermsModal.tsx +++ b/src/components/modals/SwapVerifyTermsModal.tsx @@ -41,6 +41,16 @@ const pluginData: Record = { privacyUri: 'https://exolix.com/privacy', kycUri: 'https://exolix.com/aml-kyc' }, + houdini: { + // Houdini publishes its terms as a PDF linked from the site footer; the + // rest of its legal copy lives in the docs site. + termsUri: + 'https://cdn.prod.website-files.com/69143df941a2491956546ef7/6a579a7d3db98c279dc6020b_Houdini%20Swap%20-%20Terms%20of%20Service-14673161-v6.pdf', + privacyUri: + 'https://docs.houdiniswap.com/products/swap-transfer/privacy-notice', + kycUri: + 'https://docs.houdiniswap.com/overview/getting-started/privacy-and-compliance' + }, nexchange: { termsUri: 'https://n.exchange/legal/terms' }, From 2cd5c5955bd6dff14f27ff60201577a8b165fa66 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 27 Aug 2026 16:05:40 -0700 Subject: [PATCH 17/18] Warn once when a send becomes a swap Stealth Send and a cross-asset recipient both turn the send scene into a swap-to-address, so the wallet pays the provider and the provider pays the recipient. That two-transaction shape is not visible on the scene, so say it once per account, the same way the send scam warning does. --- src/actions/SwapSendWarningActions.tsx | 50 ++++++++++++++++++++++++++ src/components/scenes/SendScene2.tsx | 21 ++++++++++- src/constants/constantSettings.ts | 1 + src/locales/en_US.ts | 5 +++ src/locales/strings/enUS.json | 3 ++ 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 src/actions/SwapSendWarningActions.tsx diff --git a/src/actions/SwapSendWarningActions.tsx b/src/actions/SwapSendWarningActions.tsx new file mode 100644 index 00000000000..c7366171c7a --- /dev/null +++ b/src/actions/SwapSendWarningActions.tsx @@ -0,0 +1,50 @@ +import type { Disklet } from 'disklet' +import * as React from 'react' +import { sprintf } from 'sprintf-js' + +import { ConfirmContinueModal } from '../components/modals/ConfirmContinueModal' +import { Airship } from '../components/services/AirshipInstance' +import { Paragraph, WarningText } from '../components/themed/EdgeText' +import { SWAP_SEND_WARNING } from '../constants/constantSettings' +import { lstrings } from '../locales/strings' +import { config } from '../theme/appConfig' +import { runOnce } from '../util/runOnce' + +/** + * Explain, the first time a send turns into a swap, that the send scene is no + * longer paying the recipient directly: the wallet pays the swap provider, and + * the provider pays the recipient. Shown once per account, like the send scam + * warning it sits beside. + **/ +export const showSwapSendWarningModal = async ( + disklet: Disklet, + providerName: string +): Promise => { + try { + await disklet.getText(SWAP_SEND_WARNING) + } catch (error: unknown) { + await runOnce('swapSendWarning', async () => { + const routingMessage = sprintf( + lstrings.stealth_swap_send_modal_message_2s, + config.appName, + providerName + ) + await Airship.show(bridge => { + const warningMessage = `• ${routingMessage}\n\n• ${lstrings.stealth_swap_send_modal_message_timing}` + + return ( + + + {warningMessage} + + + ) + }) + await disklet.setText(SWAP_SEND_WARNING, '') + }) + } +} diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 37060a8f478..cf80b73f1e4 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -33,6 +33,7 @@ import type { GuiExchangeRates } from '../../actions/ExchangeRateActions' import { showSendScamWarningModal } from '../../actions/ScamWarningActions' import { checkAndShowGetCryptoModal } from '../../actions/ScanActions' import { playSendSound } from '../../actions/SoundActions' +import { showSwapSendWarningModal } from '../../actions/SwapSendWarningActions' import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' import { FIO_STR, @@ -92,7 +93,8 @@ import { import { parsePaymentUri } from '../../util/paymentUri' import { hasParentFeeRow, - makeStealthSwapRequestOptions + makeStealthSwapRequestOptions, + STEALTH_SWAP_PLUGIN_ID } from '../../util/stealthSwap' import { processSwapQuoteError } from '../../util/swapErrorDisplay' import { @@ -829,6 +831,23 @@ const SendComponent: React.FC = props => { initialMount.current = false } + /** + * The first time this scene turns into a swap, say so. The recipient is no + * longer paid by this wallet, and the two-transaction shape is the part a + * user cannot infer from the scene. Shown once per account; the swap + * provider names itself so the copy survives a provider change. + */ + const stealthSwapConfig = account.swapConfig[STEALTH_SWAP_PLUGIN_ID] + React.useEffect(() => { + if (!swapSendActive || stealthSwapConfig == null) return + showSwapSendWarningModal( + account.disklet, + stealthSwapConfig.swapInfo.displayName + ).catch((err: unknown) => { + showError(err) + }) + }, [account.disklet, stealthSwapConfig, swapSendActive]) + const pendingInsufficientFees = React.useRef< InsufficientFundsError | undefined >(undefined) diff --git a/src/constants/constantSettings.ts b/src/constants/constantSettings.ts index c999c72dbbe..ff0c4d5853b 100644 --- a/src/constants/constantSettings.ts +++ b/src/constants/constantSettings.ts @@ -2,6 +2,7 @@ export const AAVE_WELCOME = 'aaveWelcome.json' export const FIRST_OPEN = 'firstOpen3.json' export const SCAM_WARNING = 'scamWarning.json' // For warnings on sends export const SCAM_WARNING_2 = 'scamWarning2.json' // For other general dangerous actions +export const SWAP_SEND_WARNING = 'swapSendWarning.json' // For sends routed through a swap export const SETTINGS_PERMISSION_LIMITS = 'SETTINGS_PERMISSION_LIMIT' export const SETTINGS_PERMISSION_QUANTITY = 3 export const LOCAL_EXPERIMENT_CONFIG = 'remoteConfigSticky.json' diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 32146b50536..54b8d9df3f6 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1692,6 +1692,11 @@ const strings = { stealth_fixed_to_fallback_body: 'The provider could not guarantee the requested receive amount, so the send amount is now guaranteed instead and the recipient amount is an estimate from current rates. Edit either amount to continue.', stealth_recipient_hidden: 'Hidden for privacy', + stealth_swap_send_modal_title: 'This send uses a swap provider', + stealth_swap_send_modal_message_2s: + '%1$s reaches this recipient by swapping through %2$s. Your wallet pays the provider, and the provider pays the recipient.', + stealth_swap_send_modal_message_timing: + 'The send is not complete until the provider forwards the funds, so it takes longer than a normal send.', stealth_detected_network_title: 'Which network is this address on?', stealth_detected_network_message: 'This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index f74889775e0..b390c91e362 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1327,6 +1327,9 @@ "stealth_fixed_to_fallback_title": "Receive amount is an estimate", "stealth_fixed_to_fallback_body": "The provider could not guarantee the requested receive amount, so the send amount is now guaranteed instead and the recipient amount is an estimate from current rates. Edit either amount to continue.", "stealth_recipient_hidden": "Hidden for privacy", + "stealth_swap_send_modal_title": "This send uses a swap provider", + "stealth_swap_send_modal_message_2s": "%1$s reaches this recipient by swapping through %2$s. Your wallet pays the provider, and the provider pays the recipient.", + "stealth_swap_send_modal_message_timing": "The send is not complete until the provider forwards the funds, so it takes longer than a normal send.", "stealth_detected_network_title": "Which network is this address on?", "stealth_detected_network_message": "This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.", "send_scene_error_title": "Error:", From 1c074e115416d047b1a43536d5416ce50a2d8f7b Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 27 Aug 2026 16:07:05 -0700 Subject: [PATCH 18/18] Warn on the send scene while a swap is queued [skip travis] The one-time modal covers the first send only. A card in the scene's warning area states, for every swap-routed send, that the recipient is paid by a second transaction and the send takes longer than usual. Private routing gets its own copy. --- CHANGELOG.md | 7 ++++++ src/components/scenes/SendScene2.tsx | 32 ++++++++++++++++++++++++++++ src/locales/en_US.ts | 10 ++++++++- src/locales/strings/enUS.json | 7 +++++- 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d583e5242b..b081deff47e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ - added: Sign Message option in the wallet list menu for Bitcoin-family wallets, letting users prove self-hosted wallet ownership to exchanges by signing an exchange-provided message. - added: `edge://buy` and `edge://sell` deep links (and their `https://deep.edge.app` equivalents) that open the buy/sell flow, optionally pinning a provider and payment method to the top of the quote options for that visit. - added: Provider priority in the buy/sell options for affiliated accounts, configured through the info server promo card data. +- added: Stealth Send: the send scene offers a "Recipient receives" asset selector and a Stealth Send toggle, turning the send into a live swap-to-address quote routed exclusively through the Houdini privacy provider, with linked You send/Recipient gets amounts in crypto or fiat, a quote expiry countdown, and a destination tag row on memo-required chains. +- added: Stealth Swap: a toggle on the swap amount-entry scene routes the swap through the Houdini privacy provider as a fixed, non-tappable provider. +- added: Entering a recipient address from another chain now sets up the cross-chain send by itself. Pasting, typing, or scanning an address the sending wallet cannot read no longer reports an invalid address; Edge identifies the network it belongs to, or asks which one when the format is shared, and sets "Recipient receives" to match. +- added: The "Myself" recipient picker offers every asset a send can route to, not just the source asset, with same-asset wallets grouped at the top. +- added: Swap-sends, stealth sends, and stealth swap-sends are titled separately in the transaction list and details. The two private flows do not display the recipient, but still show the exchange order details so a stuck order can be traced. The address row on these transactions reads "Exchange Deposit Address", which is the address the funds were sent to. +- added: The send scene tells you when an amount is under the privacy provider's minimum before contacting them, and the Stealth toggle explains itself when an asset or amount cannot route privately. +- added: Sends and swaps that route through the privacy provider now say so. A send that turns into a swap explains once that the provider pays the recipient, and carries a warning card for as long as it stays a swap; the dedicated swap scene asks for the provider's terms the first time it routes through them, as it already does for every other centralized provider. - changed: Target Android 16 (API level 36), which Google Play requires for app updates submitted after Aug 30, 2026. Predictive back is opted out of for now, since React Native 0.79 cannot handle it, so the back button behaves exactly as it did before. - changed: Sign MoonPay buy/sell widget URLs and bind them to the customer's IP via the info server, for MoonPay's on-ramp IP-matching security upgrade. - changed: Style the entire "Already have an account? Sign in" line in the getting-started USP carousel with the tertiary link color, not just "Sign in". diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index cf80b73f1e4..a82349e4b2e 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -2546,6 +2546,37 @@ const SendComponent: React.FC = props => { ) } + /** + * A send routed through a swap does not reach the recipient in this + * transaction: the provider pays them in a second one, once the deposit + * confirms. That wait is the part the scene does not otherwise show, so it + * sits with the other warning cards for as long as the send stays a swap. + */ + const renderSwapSendWarning = (): React.ReactElement | null => { + if (!swapSendActive) return null + return ( + + + + ) + } + /** * A fixed receive amount (typed, or carried by a scanned payment URI) had * to fall back to a guaranteed SEND amount because the provider offers no @@ -3607,6 +3638,7 @@ const SendComponent: React.FC = props => { {renderScamWarning()} {renderPendingTransactionWarning()} + {renderSwapSendWarning()} {renderFixedToFallbackWarning()} {renderNymWarning()} {renderError()} diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 54b8d9df3f6..6d284151de1 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1692,11 +1692,19 @@ const strings = { stealth_fixed_to_fallback_body: 'The provider could not guarantee the requested receive amount, so the send amount is now guaranteed instead and the recipient amount is an estimate from current rates. Edit either amount to continue.', stealth_recipient_hidden: 'Hidden for privacy', + stealth_swap_send_warning_title: 'Swap before send', + stealth_swap_send_warning_title_private: 'Private swap before send', + stealth_swap_send_warning_body: + 'Your funds are swapped before they reach the recipient.', + stealth_swap_send_warning_body_private: + 'Your funds are swapped over a private route before they reach the recipient.', + transaction_may_take_longer: + 'This transaction may take longer than usual to complete.', stealth_swap_send_modal_title: 'This send uses a swap provider', stealth_swap_send_modal_message_2s: '%1$s reaches this recipient by swapping through %2$s. Your wallet pays the provider, and the provider pays the recipient.', stealth_swap_send_modal_message_timing: - 'The send is not complete until the provider forwards the funds, so it takes longer than a normal send.', + 'The send is not complete until the provider forwards the funds. Expect it to take longer than a normal send.', stealth_detected_network_title: 'Which network is this address on?', stealth_detected_network_message: 'This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index b390c91e362..2ed34f823b6 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1327,9 +1327,14 @@ "stealth_fixed_to_fallback_title": "Receive amount is an estimate", "stealth_fixed_to_fallback_body": "The provider could not guarantee the requested receive amount, so the send amount is now guaranteed instead and the recipient amount is an estimate from current rates. Edit either amount to continue.", "stealth_recipient_hidden": "Hidden for privacy", + "stealth_swap_send_warning_title": "Swap before send", + "stealth_swap_send_warning_title_private": "Private swap before send", + "stealth_swap_send_warning_body": "Your funds are swapped before they reach the recipient.", + "stealth_swap_send_warning_body_private": "Your funds are swapped over a private route before they reach the recipient.", + "transaction_may_take_longer": "This transaction may take longer than usual to complete.", "stealth_swap_send_modal_title": "This send uses a swap provider", "stealth_swap_send_modal_message_2s": "%1$s reaches this recipient by swapping through %2$s. Your wallet pays the provider, and the provider pays the recipient.", - "stealth_swap_send_modal_message_timing": "The send is not complete until the provider forwards the funds, so it takes longer than a normal send.", + "stealth_swap_send_modal_message_timing": "The send is not complete until the provider forwards the funds. Expect it to take longer than a normal send.", "stealth_detected_network_title": "Which network is this address on?", "stealth_detected_network_message": "This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.", "send_scene_error_title": "Error:",