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/CHANGELOG.md b/CHANGELOG.md index 2cebfd37327..9b9ade1f7dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,16 @@ ## Unreleased (develop) +- 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, quote expiry countdown, and a destination tag row on memo-required chains. +- 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. +- 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: 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: Remote enable/disable of gift card providers via the info server's giftCardInfo config, supporting whole-provider disabling for Phaze and Bitrefill and per-brand disabling for Phaze. +- changed: Show fiat values on the Stealth Send amount rows. - changed: Reorganize the wallet list menu so Asset Settings is reached through Wallet Settings, and rename the Monero "Backend" card to "Server Settings". +- fixed: Drop four destination chains the privacy provider does not serve, and correct the native-asset lookup that made six others unquotable. - fixed: Prevent imported Monero wallets from using the Edge LWS backend. Choosing to import now prompts the user to continue with a full node or configure a custom LWS server, matching the wallet settings rule. ## 4.49.0 (staging) diff --git a/eslint.config.mjs b/eslint.config.mjs index 7dc53841b8d..deff58e3036 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', @@ -160,7 +167,7 @@ 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', @@ -219,13 +226,11 @@ 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/TextInputModal.tsx', 'src/components/modals/TransferModal.tsx', 'src/components/modals/WalletListSortModal.tsx', @@ -255,10 +260,8 @@ export default [ 'src/components/rows/CryptoFiatAmountRow.tsx', - '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', @@ -372,7 +375,6 @@ export default [ 'src/components/themed/SceneHeader.tsx', - 'src/components/themed/SearchFooter.tsx', 'src/components/themed/SelectableRow.tsx', 'src/components/themed/ShareButtons.tsx', @@ -386,7 +388,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/maestro/14-stealth/README.md b/maestro/14-stealth/README.md new file mode 100644 index 00000000000..733332cec35 --- /dev/null +++ b/maestro/14-stealth/README.md @@ -0,0 +1,133 @@ +# 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` | `158` | worth more than 25 USD in source units | +| `STEALTH_BELOW_MIN_AMOUNT` | `80` | worth between 10 and 25 USD | +| `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. +- **Balances drift.** The amounts above are chosen against a live price; when the + source asset moves far enough, `STEALTH_AMOUNT` stops clearing 25 USD or + `STEALTH_BELOW_MIN_AMOUNT` stops sitting between the two floors, and the + private branches quietly test the wrong thing. Both are worth re-checking + before reading a failure as a regression. + +`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: '120' +- 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..30b149d0c68 --- /dev/null +++ b/maestro/14-stealth/stealth-below-private-minimum.yaml @@ -0,0 +1,40 @@ +# 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'} + # Worth more than the 10 USD transparent floor and less than the 25 USD + # private one, in the source wallet's own denomination: + AMOUNT: ${STEALTH_BELOW_MIN_AMOUNT || '80'} +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..431aa472605 --- /dev/null +++ b/maestro/14-stealth/stealth-cross-asset-quote.yaml @@ -0,0 +1,35 @@ +# 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'} + AMOUNT: ${STEALTH_AMOUNT || '158'} +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..8459b1a8bad --- /dev/null +++ b/maestro/14-stealth/stealth-execute-send.yaml @@ -0,0 +1,44 @@ +# 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'} + AMOUNT: ${STEALTH_AMOUNT || '158'} +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..fdbda88a8bc --- /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: 'walletListRow.${SRC_WALLET}' + timeout: 20000 +- tapOn: + id: 'walletListRow.${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: 'walletListRow.${DEST_WALLET}' + timeout: 20000 +- tapOn: + id: 'walletListRow.${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..eb38dcf1c76 --- /dev/null +++ b/maestro/14-stealth/stealth-private-quote.yaml @@ -0,0 +1,43 @@ +# 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'} + AMOUNT: ${STEALTH_AMOUNT || '158'} +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..57cc21c258b --- /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..cd2f8c54284 --- /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..15535ae7329 --- /dev/null +++ b/maestro/common/stealth-open-send.yaml @@ -0,0 +1,25 @@ +# Opens the Send scene for one wallet. +# +# env WALLET the source wallet's name, e.g. "My Sonic". +# +# 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'} +--- +- runFlow: + file: stealth-open-wallet.yaml + env: + WALLET: ${WALLET} +- tapOn: 'Send' +- waitForAnimationToEnd: + timeout: 3500 +- runFlow: + when: + visible: 'Wallet Empty' + commands: + - tapOn: 'Not at this time' + - waitForAnimationToEnd: + timeout: 2000 +- assertVisible: 'Send to Address' 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..34415ebcb54 --- /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: 'walletListRow.${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: 'walletListRow.${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..2530737bbd0 --- /dev/null +++ b/maestro/common/stealth-pick-recipient-asset.yaml @@ -0,0 +1,25 @@ +# Changes which asset the recipient receives, without choosing an address. +# +# env DEST_CHAIN the chain's DISPLAY name, exactly as the picker lists it, e.g. +# "Ethereum", "Litecoin", "Stellar". This is the currency +# plugin's `displayName`, which is not always the plugin id +# capitalised: the `ripple` plugin lists as "XRP". +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. +- 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..c8f1adfa3cc --- /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. +# +# AMOUNT lands in whichever denomination the flip input is currently showing. +# The Send scene opens on the wallet's crypto denomination and the Exchange +# scene on fiat, and the input remembers its last mode, so a caller that needs +# a specific denomination should state the amount in the one it expects to see. +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 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" > @@ -615,7 +616,7 @@ exports[`TextInputModal should render with a blank input field 1`] = ` "paddingVertical": 28, } } - testID="undefined.clearIcon" + testID="textInputModal.clearIcon" > @@ -1468,7 +1470,7 @@ exports[`TextInputModal should render with a populated input field 1`] = ` "paddingVertical": 28, } } - testID="undefined.clearIcon" + testID="textInputModal.clearIcon" > @@ -1571,7 +1571,7 @@ exports[`SendScene2 1 spendTarget 1`] = ` "reduceMotionV": "system", } } - nativeID="13" + nativeID="16" > @@ -3580,7 +3581,7 @@ exports[`SendScene2 1 spendTarget with info tiles 1`] = ` "reduceMotionV": "system", } } - nativeID="23" + nativeID="26" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -5589,7 +5703,7 @@ exports[`SendScene2 2 spendTargets 1`] = ` "reduceMotionV": "system", } } - nativeID="34" + nativeID="37" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -7179,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) + + + @@ -8749,7 +9089,7 @@ exports[`SendScene2 2 spendTargets hide tiles 2`] = ` "reduceMotionV": "system", } } - nativeID="54" + nativeID="57" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -10086,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) + + + @@ -11836,7 +12402,7 @@ exports[`SendScene2 2 spendTargets lock tiles 1`] = ` "reduceMotionV": "system", } } - nativeID="75" + nativeID="78" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -13537,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) + + + @@ -15165,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 + + + + + + + + + + + + + + + + true + +describe('detectHoudiniChains', () => { + it('detects the chain a bare address belongs to', () => { + const found = detectHoudiniChains(ADDRESSES.litecoin, { + sourcePluginId: 'bitcoin', + 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', + 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', + 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', + 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', isSupported: supportAll } + ) + expect(found.map(chain => chain.pluginId)).toEqual(['ethereum']) + }) + + it('never offers the sending wallet own chain as a destination', () => { + const found = detectHoudiniChains(ADDRESSES.bitcoin, { + sourcePluginId: 'bitcoin', + 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', + 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', + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('solana') + }) + + it('detects Bitcoin from a Litecoin wallet', () => { + const found = detectHoudiniChains(ADDRESSES.bitcoin, { + sourcePluginId: 'litecoin', + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('bitcoin') + }) + + it('detects a legacy Bitcoin address', () => { + const found = detectHoudiniChains(ADDRESSES.bitcoinLegacy, { + sourcePluginId: 'litecoin', + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('bitcoin') + }) + + it('detects Dogecoin', () => { + const found = detectHoudiniChains(ADDRESSES.dogecoin, { + sourcePluginId: 'bitcoin', + 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', + isSupported: supportAll + }) + expect(found).toEqual([]) + }) + + it('falls back to format matching when the scheme is unknown', () => { + const found = detectHoudiniChains(`madeupchain:${ADDRESSES.litecoin}`, { + sourcePluginId: 'bitcoin', + 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', + 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', + isSupported: pluginId => pluginId === 'cardano' + }) + expect(found).toEqual([]) + }) +}) + +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' + }) + }) +}) diff --git a/src/__tests__/util/paymentUri.test.ts b/src/__tests__/util/paymentUri.test.ts new file mode 100644 index 00000000000..184923a35c5 --- /dev/null +++ b/src/__tests__/util/paymentUri.test.ts @@ -0,0 +1,109 @@ +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') + }) +}) diff --git a/src/__tests__/util/stealthSwap.test.ts b/src/__tests__/util/stealthSwap.test.ts new file mode 100644 index 00000000000..1534fbe50eb --- /dev/null +++ b/src/__tests__/util/stealthSwap.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from '@jest/globals' +import type { EdgeAccount } from 'edge-core-js' + +import { 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({}) + }) +}) 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/actions/CategoriesActions.ts b/src/actions/CategoriesActions.ts index 494d875de17..c07eddda7c7 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,6 +380,21 @@ export const getTxActionDisplayInfo = ( switch (actionType) { case 'swap': { iconPluginId = action.swapInfo.pluginId + // 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) { + 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 @@ -382,8 +402,9 @@ export const getTxActionDisplayInfo = ( ? 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 +702,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/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/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/modals/RadioListModal.tsx b/src/components/modals/RadioListModal.tsx index 04473e092d8..a255d4298bd 100644 --- a/src/components/modals/RadioListModal.tsx +++ b/src/components/modals/RadioListModal.tsx @@ -23,10 +23,12 @@ interface Props { title: string items: Item[] selected?: string + /** Explanatory copy between the title and the list. */ + message?: string } -export function RadioListModal(props: Props) { - const { bridge, items, selected, title } = props +export const RadioListModal: React.FC = 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 30bcf0840d7..76179c63cf1 100644 --- a/src/components/modals/TextInputModal.tsx +++ b/src/components/modals/TextInputModal.tsx @@ -57,7 +57,7 @@ interface Props { secureTextEntry?: boolean } -export function TextInputModal(props: Props) { +export const TextInputModal: React.FC = props => { const { autoCapitalize, autoFocus = true, @@ -82,12 +82,12 @@ export function TextInputModal(props: Props) { const [errorMessage, setErrorMessage] = React.useState() const [text, setText] = React.useState(initialValue) - const handleChangeText = (text: string) => { + const handleChangeText = (text: string): void => { setText(text) setErrorMessage(undefined) } - const handleSubmit = () => { + const handleSubmit = (): void => { if (onSubmit == null) { bridge.resolve(text) return @@ -100,7 +100,7 @@ export function TextInputModal(props: Props) { bridge.resolve(text) } }, - error => { + (error: unknown) => { showError(error) } ) @@ -131,6 +131,7 @@ export function TextInputModal(props: Props) { /> ) : null} = 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/rows/EdgeRow.tsx b/src/components/rows/EdgeRow.tsx index 669ca5e4bfc..e2403c37f59 100644 --- a/src/components/rows/EdgeRow.tsx +++ b/src/components/rows/EdgeRow.tsx @@ -46,6 +46,10 @@ interface Props { maximumHeight?: 'small' | 'medium' | 'large' rightButtonType?: RowActionIcon title?: string + + /** A state word for the title, rendered in parentheses after it. Supply it + * wrapped in a color component to tint the word without tinting the title. */ + titleState?: React.ReactNode testID?: string onLongPress?: () => Promise | void onPress?: () => Promise | void @@ -54,7 +58,7 @@ interface Props { marginRem?: number[] | number } -export const EdgeRow = (props: Props) => { +export const EdgeRow: React.FC = (props: Props) => { const { body, children, @@ -65,6 +69,7 @@ export const EdgeRow = (props: Props) => { maximumHeight = 'medium', testID, title, + titleState, // Handlers: onLongPress, @@ -122,12 +127,13 @@ export const EdgeRow = (props: Props) => { {title == null ? null : ( {title} + {titleState == null ? null : <> ({titleState})} )} - {loading ? ( + {loading === true ? ( = (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 = request.toWallet + if (toWallet == null) { + throw new Error('Swap quote is missing a destination wallet') + } const theme = useTheme() const styles = getStyles(theme) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 58aa9578ff5..ec1d2f6be62 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,14 @@ import { FioError, recordSend } from '../../util/FioAddressUtils' +import { + detectHoudiniChains, + getHoudiniChain, + HOUDINI_CHAINS, + HOUDINI_MIN_USD, + type HoudiniChain, + isValidHoudiniAddress +} from '../../util/houdiniChains' import { logActivity } from '../../util/logger' import { createEdgeMemo, @@ -67,18 +85,23 @@ import { getMemoLabel, getMemoTitle } from '../../util/memoUtils' +import { parsePaymentUri } from '../../util/paymentUri' +import { makeStealthSwapRequestOptions } from '../../util/stealthSwap' +import { processSwapQuoteError } from '../../util/swapErrorDisplay' import { convertTransactionFeeToDisplayFee, darkenHexColor, DECIMAL_PRECISION, zeroString } from '../../util/utils' +import { openBrowserUri } from '../../util/WebUtils' import { AlertCardUi4 } from '../cards/AlertCard' import { EdgeCard } from '../cards/EdgeCard' 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 +109,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,14 +118,20 @@ 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 { @@ -170,8 +200,19 @@ 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 +/** Placeholder pending the final marketing URL. */ +const STEALTH_LEARN_MORE_URI = + 'https://gist.github.com/j0ntz/b3f8101f0a1f79539150fc73511bff8b' + /** * If the prior two spend targets of a multi-out payment have the same amount * within 0.5%, then use the same amount for the new spend target. @@ -240,12 +281,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) @@ -261,6 +311,51 @@ 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) + const countryCode = useSelector(state => state.ui.countryCode) const account = useSelector(state => state.core.account) const exchangeRates = useSelector( @@ -277,6 +372,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 @@ -320,6 +454,250 @@ 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) + 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}` + const pairCaps = routeCaps[routePairKey] ?? {} + const markRouteCap = (cap: 'stealth' | 'fixedTo'): void => { + setRouteCaps(caps => ({ + ...caps, + [routePairKey]: { ...caps[routePairKey], [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) @@ -427,8 +805,25 @@ const SendComponent: React.FC = props => { const handleChangeAddress = (spendTarget: EdgeSpendTarget) => async (changeAddressResult: ChangeAddressResult): Promise => { - const { addressEntryMethod, parsedUri, fioAddress, alias, resolvedName } = - changeAddressResult + const { + addressEntryMethod, + parsedUri, + fioAddress, + alias, + resolvedName, + crossChainDisplayAmount, + 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) { if (parsedUri.metadata != null) { @@ -436,7 +831,28 @@ const SendComponent: React.FC = props => { } 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 @@ -476,11 +892,186 @@ 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 + ): 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: + setDestinationTag(undefined) + 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 { + await coreWallet.saveTxAction({ + txid: tx.txid, + tokenId, + assetAction: tx.assetAction ?? { assetActionType: 'swap' }, + 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, + 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 } = parsePaymentUri(address) + const publicAddress = addressCandidates.find(candidate => + isValidHoudiniAddress(chain, candidate) + ) + if (publicAddress == null) return false + + await adoptCrossChainDestination(spendTarget)( + chain.pluginId, + publicAddress, + addressEntryMethod, + displayAmount + ) + 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. @@ -526,9 +1117,23 @@ 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. + clearSwapError() + clearPlainSendError() + setAddressExpired(false) 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. @@ -558,6 +1163,14 @@ 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) => + destChain != null && 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} /> ) @@ -648,6 +1271,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 = @@ -702,12 +1327,52 @@ 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, EVERY one of them. Clearing the + // destination CHAIN while leaving an address behind drops the scene + // back into plain-send mode still displaying a foreign-chain + // address, which the new source wallet cannot pay: the user is then + // one slide away from a send that can only fail. Written once, as + // the whole spend: a second `setSpendInfo` here would close over the + // pre-reset value and put the old targets back. + setSpendInfo({ + tokenId: assetChanged ? result.tokenId : tokenId, + spendTargets: [{}], + memos: [] + }) } }) .catch((error: unknown) => { @@ -735,7 +1400,535 @@ 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 handleStealthLearnMore = useHandler((): void => { + openBrowserUri(STEALTH_LEARN_MORE_URI).catch((err: unknown) => { + showError(err) + }) + }) + + const handlePickRecipientAsset = useHandler((): void => { + if (multipleTargets) return + // The radio row's `name` is BOTH the label and the selection key, so it + // must be unique. Several chains share a currency code (ETH on Base / + // Arbitrum / Ethereum), so the label is the unique network display name + // with the currency code as subtext, and a map resolves it back to the + // destination pluginId. + const sourceDisplayName = + tokenId == null + ? coreWallet.currencyInfo.displayName + : coreWallet.currencyConfig.allTokens[tokenId]?.displayName ?? + currencyCode + const displayNameToPluginId = new Map() + displayNameToPluginId.set(sourceDisplayName, undefined) + + const items = [ + { + name: sourceDisplayName, + text: currencyCode, + icon: + }, + ...HOUDINI_CHAINS.filter( + chain => + account.currencyConfig[chain.pluginId] != null && + !(chain.pluginId === pluginId && tokenId == null) + ).map(chain => { + const { currencyCode: chainCode, displayName } = + account.currencyConfig[chain.pluginId].currencyInfo + displayNameToPluginId.set(displayName, chain.pluginId) + return { + name: displayName, + text: chainCode, + icon: ( + + ) + } + }) + ] + const selectedName = + recipientPluginId == null + ? sourceDisplayName + : account.currencyConfig[recipientPluginId]?.currencyInfo.displayName ?? + sourceDisplayName + + Airship.show(bridge => ( + + )) + .then(selected => { + if (selected == null || selected === selectedName) return + if (!displayNameToPluginId.has(selected)) 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(displayNameToPluginId.get(selected)) + }) + .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. + 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 + setDestinationTag(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 recipientCurrencyCode = + recipientPluginId == null + ? currencyCode + : destCurrencyInfo?.currencyCode ?? currencyCode + const recipientDisplayName = + recipientPluginId == null + ? tokenId == null + ? coreWallet.currencyInfo.displayName + : coreWallet.currencyConfig.allTokens[tokenId]?.displayName ?? + currencyCode + : destCurrencyInfo?.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 ? ( + + + {stealthBlockedReason} + + + ) : stealth ? ( + + + {lstrings.stealth_send_info}{' '} + + {lstrings.stealth_learn_more} + + + + ) : 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 @@ -773,7 +1966,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, @@ -804,6 +2003,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 @@ -956,6 +2156,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] @@ -1182,6 +2385,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 @@ -1276,7 +2502,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) { @@ -1287,6 +2515,39 @@ 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) { + if (swapQuote == null || isApprovingSwapRef.current) return + isApprovingSwapRef.current = true + isSendingRef.current = true + try { + const result = await swapQuote.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 + }) + 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) { @@ -1365,6 +2626,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 === '') ) { @@ -1574,12 +2840,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 @@ -1595,30 +2877,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) { @@ -1665,7 +2923,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) @@ -1737,15 +2997,299 @@ 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' + ) + + // 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) && @@ -1765,6 +3309,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' @@ -1833,19 +3383,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()} @@ -1857,6 +3421,7 @@ const SendComponent: React.FC = props => { {renderScamWarning()} {renderPendingTransactionWarning()} + {renderFixedToFallbackWarning()} {renderNymWarning()} {renderError()} {sliderTopNode} @@ -1866,6 +3431,11 @@ const SendComponent: React.FC = props => { @@ -1895,6 +3465,35 @@ 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 + }, + stealthInfo: { + paddingHorizontal: theme.rem(1), + paddingBottom: theme.rem(0.75) + }, + stealthInfoText: { + fontSize: theme.rem(0.75), + color: theme.secondaryText + }, + stealthLearnMoreLink: { + fontSize: theme.rem(0.75), + color: theme.textLink + }, calcFeeSpinner: { marginLeft: theme.rem(1) }, diff --git a/src/components/scenes/SwapConfirmationScene.tsx b/src/components/scenes/SwapConfirmationScene.tsx index 5aba519cc4b..76167d62304 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,7 @@ import type { GuiSwapInfo } from '../../types/types' import { getSwapPluginIconUri } from '../../util/CdnUris' import { CryptoAmount } from '../../util/CryptoAmount' import { logActivity } from '../../util/logger' +import { makeStealthSwapRequestOptions } from '../../util/stealthSwap' import { logEvent } from '../../util/tracking' import { convertNativeToExchange, DECIMAL_PRECISION } from '../../util/utils' import { AlertCardUi4 } from '../cards/AlertCard' @@ -46,20 +51,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 +85,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 +107,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 +142,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 +192,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 +218,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 +299,11 @@ 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 = request.toWallet + if (toWallet == null) { + throw new Error('Swap quote is missing a destination wallet') + } 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,11 @@ 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 = request.toWallet + if (toWallet == null) { + throw new Error('Swap quote is missing a destination wallet') + } // Format from amount: const fromDisplayDenomination = selectDisplayDenom( diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index f42ec8d90b1..1d8a70c6c74 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -25,11 +25,15 @@ 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 } from '../../util/stealthSwap' +import type { SwapErrorDisplayInfo } from '../../util/swapErrorDisplay' import { zeroString } from '../../util/utils' +import { openBrowserUri } from '../../util/WebUtils' 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, @@ -46,9 +50,16 @@ import { WalletListModal, type WalletListResult } from '../modals/WalletListModal' -import { Airship, showToast, showWarning } from '../services/AirshipInstance' -import { useTheme } from '../services/ThemeContext' +import { + Airship, + showError, + showToast, + showWarning +} from '../services/AirshipInstance' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { SettingsSwitchRow } from '../settings/SettingsSwitchRow' import { UnscaledText } from '../text/UnscaledText' +import { EdgeText } from '../themed/EdgeText' import { LineTextDivider } from '../themed/LineTextDivider' import { SwapInput, @@ -66,14 +77,19 @@ 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 } +export type { SwapErrorDisplayInfo } + +/** Placeholder pending the final marketing URL. */ +const STEALTH_LEARN_MORE_URI = + 'https://gist.github.com/j0ntz/b3f8101f0a1f79539150fc73511bff8b' + interface Props extends SwapTabSceneProps<'swapCreate'> {} export const SwapCreateScene: React.FC = props => { @@ -83,9 +99,11 @@ export const SwapCreateScene: React.FC = props => { fromTokenId = null, toWalletId, toTokenId = null, - errorDisplayInfo + errorDisplayInfo, + disableStealth } = route.params ?? {} const theme = useTheme() + const styles = getStyles(theme) const dispatch = useDispatch() // Input state is the state of the user input @@ -95,6 +113,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 +171,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 +259,12 @@ 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 = swapRequest.toWallet + if (toWallet == null) { + throw new Error('Swap request is missing a destination wallet') + } if (exchangeInfo != null) { const disableSrc = checkDisableAsset( exchangeInfo.swap.disableAssets.source, @@ -247,7 +284,7 @@ export const SwapCreateScene: React.FC = props => { const disableDest = checkDisableAsset( exchangeInfo.swap.disableAssets.destination, - swapRequest.toWallet.id, + toWallet.id, toTokenId ) if (disableDest) { @@ -255,7 +292,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 +303,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 +321,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 +505,16 @@ export const SwapCreateScene: React.FC = props => { Keyboard.dismiss() }) + const handleToggleStealth = useHandler(() => { + setStealth(value => !value) + }) + + const handleStealthLearnMore = useHandler(() => { + openBrowserUri(STEALTH_LEARN_MORE_URI).catch((err: unknown) => { + showError(err) + }) + }) + const handleFromAmountChange = useHandler((amounts: SwapInputCardAmounts) => { navigation.setParams({ // Update the error state: @@ -607,6 +681,30 @@ export const SwapCreateScene: React.FC = props => { /> )} + {fromWallet != null && toWallet != null ? ( + + + + {stealth ? ( + + + {lstrings.stealth_swap_info}{' '} + + {lstrings.stealth_learn_more} + + + + ) : null} + + + ) : null} {renderAlert()} {isNextHidden || isKeyboardOpen ? null : ( @@ -636,3 +734,18 @@ const MaxButtonText = styled(UnscaledText)(theme => ({ fontSize: theme.rem(0.75), includeFontPadding: false })) + +const getStyles = cacheStyles((theme: Theme) => ({ + stealthInfo: { + paddingHorizontal: theme.rem(1), + paddingBottom: theme.rem(0.75) + }, + stealthInfoText: { + color: theme.secondaryText, + fontSize: theme.rem(0.75) + }, + stealthLearnMoreLink: { + color: theme.textLink, + fontSize: theme.rem(0.75) + } +})) diff --git a/src/components/scenes/SwapProcessingScene.tsx b/src/components/scenes/SwapProcessingScene.tsx index 3305f8a2458..862bcb55f1c 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,39 @@ 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 { 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 +56,40 @@ 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 = swapRequest.toWallet + if (toWallet == null) { + throw new Error('Swap request is missing a destination wallet') + } + 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 +97,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 +116,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 +192,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 +216,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 +246,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/components/scenes/TransactionDetailsScene.tsx b/src/components/scenes/TransactionDetailsScene.tsx index 1d17b7a482c..286b8908c92 100644 --- a/src/components/scenes/TransactionDetailsScene.tsx +++ b/src/components/scenes/TransactionDetailsScene.tsx @@ -101,6 +101,13 @@ 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. + const isStealthSend = + action != null && + action.actionType === 'swap' && + (action.swapType === 'stealthSend' || action.swapType === 'stealthSwapSend') + const thumbnailPath = useContactThumbnail(mergedData.name) ?? pluginIdIcons[iconPluginId ?? ''] @@ -636,6 +643,7 @@ export const TransactionDetailsComponent: React.FC = props => { swapData={swapData} transaction={transaction} wallet={wallet} + hidePayoutAddress={isStealthSend} /> )} @@ -771,6 +779,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/components/themed/EdgeText.tsx b/src/components/themed/EdgeText.tsx index 2109a1ddca7..aebd94a2052 100644 --- a/src/components/themed/EdgeText.tsx +++ b/src/components/themed/EdgeText.tsx @@ -137,6 +137,25 @@ export const WarningText: React.FC<{ children: React.ReactNode }> = (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 }, diff --git a/src/components/themed/ExchangeQuoteComponent.tsx b/src/components/themed/ExchangeQuoteComponent.tsx index 70b110a5db6..86bd3b68eee 100644 --- a/src/components/themed/ExchangeQuoteComponent.tsx +++ b/src/components/themed/ExchangeQuoteComponent.tsx @@ -6,7 +6,6 @@ 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' @@ -16,6 +15,7 @@ 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 +27,13 @@ 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 = request.toWallet + if (toWallet == null) { + throw new Error('Swap quote is missing a destination wallet') + } const theme = useTheme() const styles = getStyles(theme) @@ -188,17 +194,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 +237,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/components/themed/SafeSlider.tsx b/src/components/themed/SafeSlider.tsx index 323a936858c..ae6626f5351 100644 --- a/src/components/themed/SafeSlider.tsx +++ b/src/components/themed/SafeSlider.tsx @@ -125,6 +125,7 @@ export const SafeSlider: React.FC = props => { sliderDisabled ? styles.disabledThumb : null, scrollTranslationStyle ]} + testID="confirmSliderThumb" > = 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) => { 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 ]) diff --git a/src/components/themed/WalletListCurrencyRow.tsx b/src/components/themed/WalletListCurrencyRow.tsx index e7f1c9abeb3..ca4655ddcd5 100644 --- a/src/components/themed/WalletListCurrencyRow.tsx +++ b/src/components/themed/WalletListCurrencyRow.tsx @@ -188,6 +188,10 @@ const WalletListCurrencyRowComponent = ( ) : 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. + testID={`walletListRow.${walletName}`} onLongPress={handleLongPress} onPress={handlePress} paddingRem={0.5} diff --git a/src/components/tiles/AddressTile2.tsx b/src/components/tiles/AddressTile2.tsx index 4ce6c84385a..a362d4553c4 100644 --- a/src/components/tiles/AddressTile2.tsx +++ b/src/components/tiles/AddressTile2.tsx @@ -21,10 +21,12 @@ import { lstrings } from '../../locales/strings' import { PaymentProtoError } from '../../types/PaymentProtoError' import { useSelector } from '../../types/reactRedux' import type { NavigationBase } from '../../types/routerTypes' +import type { EdgeAsset } from '../../types/types' import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { parseDeepLink } from '../../util/DeepLinkParser' import { checkPubAddress } from '../../util/FioAddressUtils' import { type NameService, reverseLookupName } from '../../util/nameServices' +import { parsePaymentUri } from '../../util/paymentUri' import { resolveName } from '../../util/resolveName' import { isEmail } from '../../util/utils' import { isZnsName, resolveZnsName } from '../../util/zns' @@ -58,6 +60,20 @@ export interface ChangeAddressResult { * persist the name into transaction metadata. */ resolvedName?: { name: string; service: NameService } + /** + * Display-units amount carried by a cross-chain payment URI, denominated in + * the destination chain's primary asset. The tile has no destination + * denomination to convert with, so the consumer converts to native units. + */ + crossChainDisplayAmount?: string + + /** + * Destination chain inferred from the address itself, when the consumer + * adopted an address belonging to a chain other than the sending wallet's. + * Set only on that path, where the consumer's own destination state has not + * re-rendered yet and so cannot be read back. + */ + detectedDestPluginId?: string } export interface AddressTileRef { @@ -86,6 +102,37 @@ interface Props { * which carry no name-service identity. */ recipientNameService?: NameService | null + /** + * Validates an entered address that belongs to a DIFFERENT chain than + * `coreWallet`'s (a cross-asset send-to-address destination), bypassing the + * wallet's own URI parsing and name-service resolution. Return false to + * reject the address. + */ + crossChainAddressValidation?: (address: string) => 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 +147,11 @@ export const AddressTile2 = React.forwardRef( lockInputs, navigation, onChangeAddress, + onUnparsedAddress, + selfTransfer, recipientAddress, resetSendTransaction, + crossChainAddressValidation, title } = props @@ -156,9 +206,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 +234,36 @@ 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 } = parsePaymentUri(address) + const crossChainAddress = addressCandidates.find(candidate => + crossChainAddressValidation(candidate) + ) + 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 + }) + return + } + setLoading(true) const enteredInput = address.trim() address = enteredInput @@ -364,6 +458,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 +548,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 +578,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 +637,7 @@ export const AddressTile2 = React.forwardRef( = { + 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/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md new file mode 100644 index 00000000000..59e35f58952 --- /dev/null +++ b/src/docs/stealth-send-swap.md @@ -0,0 +1,931 @@ +# 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-07-28 | +| 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, as of gui `56ac4df27`, core `c12ad450`, and exchange-plugins `a17062d`. 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 and every code block below is quoted from the shipped branches. + +## 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. [References](#12-references) +13. [Post-implementation retrospective](#13-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 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 byte-for-byte unchanged: payment protocol, 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 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 | [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 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. As landed, `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: + +```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: + +```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)). + +### 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. As landed, `src/types/error.ts`: + +```ts + const toPluginId = + toWallet?.currencyConfig.currencyInfo.pluginId ?? + toAddressInfo?.toPluginId ?? + '' +``` + +## 6. Detailed design: edge-exchange-plugins + +### Plugin identity and transport + +```ts +export const swapInfo: EdgeSwapInfo = { + pluginId, + isDex: false, + displayName: 'HoudiniSwap', + supportEmail: 'support@houdiniswap.com' +} +``` + +Two transport facts are load-bearing and both are non-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: + +```ts +async function getDestinationMemos( + toWallet: EdgeCurrencyWallet +): Promise { + const { getMemos } = toWallet as EdgeCurrencyWallet & + SyntheticDestinationMethods + if (getMemos == null) return [] + return await getMemos() +} +``` + +and, in `fetchSwapQuoteInner`: + +```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-required chains (XRP, XLM, TON, Cosmos-family, 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-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 session instead of once per quote. A lookup the provider FAILED to answer is deliberately not cached: 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 session. + +### Route selection + +Quotes are filtered by route type, and the caller decides which types are acceptable: + +```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. That distinction is load-bearing rather than cosmetic: 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. + +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, as landed: + +```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 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: + +```ts + const destPluginId = recipientPluginId ?? pluginId + const sameAsset = destPluginId === pluginId && tokenId == null + const crossAsset = recipientPluginId != null && !sameAsset + const swapSendActive = swapSendAllowed && (stealth || crossAsset) +``` + +### 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: + +```ts +export function makeStealthSwapRequestOptions( + account: EdgeAccount, + opts: EdgeSwapRequestOptions = {} +): EdgeSwapRequestOptions { + const disabled: EdgePluginMap = { ...opts.disabled } + for (const swapPluginId of Object.keys(account.swapConfig)) { + if (swapPluginId !== 'houdini') disabled[swapPluginId] = true + } + return { + ...opts, + disabled, + preferPluginId: undefined, + preferType: undefined + } +} +``` + +Clearing `preferPluginId`/`preferType` matters: a user's saved provider preference would otherwise fight the restriction. + +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. + +### 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: + +```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`: + +```ts +export function detectHoudiniChains( + text: string, + opts: { + /** The sending wallet's chain, never a candidate destination. */ + sourcePluginId: string + /** 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 chain table entry is: + +```ts +export interface HoudiniChain { + pluginId: string + houdiniShortName: string + memoNeeded: boolean + addressValidation: RegExp +} +``` + +`HOUDINI_CHAINS` is a snapshot of Houdini `GET /chains` (fetched 2026-07-02) intersected with Edge pluginIds. The table holds 38 chains, 6 of them memo-required. 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). + +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: + +```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: + +```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-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 | 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. + +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 served, 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. + +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"). + +### 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: + +```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 | + +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. + +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 + +Six files across three repos hold 100 tests, all passing. + +In the gui, 65 across four files: + +1. `src/__tests__/util/paymentUri.test.ts` (11): bare address passthrough, whitespace, BIP-21 with and without a query, cashaddr prefix retention, 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` (29): the 15 address-detection cases (single-chain detection, all-EVM fan-out for a bare `0x`, scheme resolution including a scheme differing from the pluginId, source chain never offered, 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-required set; and the floor constants ordered dex < standard < private, shaped as biggystring-comparable strings, and equal to the values the provider published. +3. `src/__tests__/util/stealthSwap.test.ts` (8): 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. +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. + +In edge-core-js, 12: `test/core/synthetic-wallet.test.ts` (4) for the synthetic wallet's shape and bridge survival, and 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. + +In edge-exchange-plugins, 23 in `test/houdini.test.ts`: 4 acceptance tests replaying recorded fixtures (quote retrieval both directions, order creation, destination-tag threading), and 19 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, and a `VALIDATION_ERROR` surfacing its field message instead of the generic "Validation Failed". + +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/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. **Entry-path and chain matrix**, 10 cases: 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 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. + +## 10. Phase history + +### Phase 1: prototype and the bridge verdict + +Queued: 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 bridge, which moved the synthetic wallet into the core and set the shape of the whole design. + +### Phase 2: production implementation + +Queued: replace every prototype hack with real wiring. Shipped: the core `toAddressInfo` seam, the HoudiniSwap plugin, and `SendScene2` integrated in place with the full 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. A same-phase operator followup then changed a behavior: the first cut restricted only stealth sends to Houdini and fanned plain cross-asset sends out to every provider; the operator directed that send-to-any route through Houdini only, and the fix was autosquashed into the feature commit. Neither the PR body nor (later) this document recorded the change, which set up the phase 5 regression. + +### Phase 3: scanned payment URIs + +Queued: a review follow-up noting that a scanned QR carries a URI, not a bare address. 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 + +Queued: an operator comment relaying a user report that an Ethereum address could not be pasted or typed when sending from a Bitcoin wallet, and that the full URL failed too, plus a request to 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 reported 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 after the user had already changed "Recipient receives", which nobody does before entering an address. + +### Phase 5: route availability in the UI + +Queued: an operator followup asking that the UI reflect what is actually available. The stealth toggle should turn itself off (with a toast) on a pair with no private route, a fixed receive amount should fall back to a guaranteed send amount (toast plus a warning card that clears on edit) when no receive-priced route exists, and disabled controls should 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 ChangeNOW swap) and PIVX to PIVX (fixed-to fallback on a funded wallet). Diverged twice from the sketch, and the second divergence was a regression: the unconditional Houdini restriction (intended behavior since the phase 2 followup) read as drift because the PR body still described the original fan-out and the autosquash had made the restriction look original, so this phase made the restriction conditional on the toggle and re-routed the stealth fallback through a provider fan-out (the executed ChangeNOW swap in [Section 9](#9-testing) item 10 ran on that code state). Phase 6 reverted it. + +### Phase 6: Houdini exclusivity restored + +Queued: an operator correction: all send and swap functionality is Houdini-exclusive, the PR body is out of date, trace the history through the run reports and fix the documentation so the confusion cannot recur. A same-turn addition asked for the swap-send amount modals to 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 + +Queued: an operator followup with two features and a standing rule. The rule: 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 + +Queued: nothing from the operator. A re-fire's 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 now 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 below as a mistake worth keeping. + +### Phase 9: real failures in the error UI + +Queued: an operator followup. Swap and send failures must show the real cause (provider message, limit floor, network error) instead of a catch-all alert, plus the in-app verification the previous segment could not perform. 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 of them 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), the 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 corrections: `upgradeSwapData` receives the payout wallet rather than the source wallet, so a payout currency code is resolved 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, so changing only this path would make the two disagree. + +### Phase 10: the provider feedback round + +Queued: a feedback round from the Houdini team plus two internal reviewers, arriving as one operator followup with a standing rule (every supported-destination decision must be route-derived) and two hard constraints (no availability probing or per-pair caching of any kind, and graceful rate-limit handling). Shipped, in the order the asks came: the Myself picker's same-asset capability now reads Houdini's own `hasSelfPrivate` flag instead of being assumed, 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 same `FiatText` component the rest of the app formats fiat with. Diverged: nothing was dropped, but the standing-rule audit found more than the asks did. Four chains were being 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 here; 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 of them 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 + +Queued: an operator followup instructing that the previous round's own follow-up list be worked rather than carried, 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, to 88 across three repos, with the plugin's route filter, native-token matching and rate-limit backoff moved onto deterministic local responses; a composable 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 below 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 + +Queued: an operator followup with three items in its own order. 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, swapping only for assets a flow actually needs. Two more arrived mid-turn: 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-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-run 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; that suite grew from 17 to 23. + +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 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. Then the reviewer's quota ran out again before the last push, which the corrected classifier reports as an unchecked gate box rather than as coverage. + +### Phase 13: the state word moves into the row title + +Queued: an operator followup on the two linked amount rows. The state word sat on its own line under the amount, so each row 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 in parentheses 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: the raw text now sits inside the row's own header `EdgeText`, where the rule already allows it, and callers supply nothing but the word. + +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. + +### 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 `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). | + +## 11. Decisions + +### 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 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 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 all 38 entries 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 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. 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, `GET /chains` snapshot dated 2026-07-02 in `src/util/houdiniChains.ts` + +## 13. 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 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 | 38 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 38 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, 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. +- 26 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: 556 tests, 92 suites, 107 snapshots. diff --git a/src/envConfig.ts b/src/envConfig.ts index 149d1383f28..d33ab4d1e5c 100644 --- a/src/envConfig.ts +++ b/src/envConfig.ts @@ -322,6 +322,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/locales/en_US.ts b/src/locales/en_US.ts index 49926310b8e..2544859f9a4 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -939,6 +939,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', @@ -1210,6 +1213,8 @@ const strings = { wallet_list_modal_header_parent: 'Parent Wallet', wallet_list_modal_header_mru: 'Most Recent Wallets', wallet_list_modal_header_other: 'Other Wallets', + wallet_list_modal_header_same_asset: 'Same Asset', + wallet_list_modal_header_other_assets: 'Other Assets', wallet_list_modal_creating_wallet: 'Creating Wallet. Please Wait', wallet_list_modal_enabling_token: 'Enabling Token. Please Wait', wallet_list_modal_confirm_s_bank_withdrawal: @@ -1609,6 +1614,45 @@ 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_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', + 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_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.', + 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_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:', 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 fe138434cfc..21029594684 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -757,6 +757,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", @@ -968,6 +971,8 @@ "wallet_list_modal_header_parent": "Parent Wallet", "wallet_list_modal_header_mru": "Most Recent Wallets", "wallet_list_modal_header_other": "Other Wallets", + "wallet_list_modal_header_same_asset": "Same Asset", + "wallet_list_modal_header_other_assets": "Other Assets", "wallet_list_modal_creating_wallet": "Creating Wallet. Please Wait", "wallet_list_modal_enabling_token": "Enabling Token. Please Wait", "wallet_list_modal_confirm_s_bank_withdrawal": "Borrowing funds for deposit directly into a bank account requires a linked bank account with an %1$s exchange partner. Tap Continue to proceed to link a bank account. (For US users only)", @@ -1271,6 +1276,33 @@ "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_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", + "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_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.", + "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_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:", "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/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, diff --git a/src/util/corePlugins.ts b/src/util/corePlugins.ts index 7205fec20b5..2eb23546373 100644 --- a/src/util/corePlugins.ts +++ b/src/util/corePlugins.ts @@ -94,6 +94,7 @@ export const swapPlugins = { changenow: ENV.CHANGE_NOW_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..57912148b8d --- /dev/null +++ b/src/util/houdiniChains.ts @@ -0,0 +1,383 @@ +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 + 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, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'avalanche', + houdiniShortName: 'avalanche', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'base', + houdiniShortName: 'base', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'binancesmartchain', + houdiniShortName: 'bsc', + memoNeeded: false, + hasSelfPrivate: true, + 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, + addressValidation: + /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$|^[0-9A-Za-z]{42,42}$|^(ecash:)[0-9A-Za-z]{30,70}$/ + }, + { + pluginId: 'ethereum', + houdiniShortName: 'ethereum', + memoNeeded: false, + hasSelfPrivate: true, + 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, + 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, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'optimism', + houdiniShortName: 'optimism', + memoNeeded: false, + hasSelfPrivate: true, + 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, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'pulsechain', + houdiniShortName: 'pulsechain', + memoNeeded: false, + hasSelfPrivate: true, + 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, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'solana', + houdiniShortName: 'solana', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/ + }, + { + pluginId: 'sonic', + houdiniShortName: 'sonic', + memoNeeded: false, + hasSelfPrivate: true, + 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, + 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, never a candidate destination. */ + sourcePluginId: string + /** Whether the account has a currency plugin for this chain. */ + isSupported: (pluginId: string) => boolean + } +): HoudiniChain[] { + const { sourcePluginId, isSupported } = opts + const { addressCandidates, scheme } = parsePaymentUri(text) + + const served = HOUDINI_CHAINS.filter( + chain => chain.pluginId !== sourcePluginId && isSupported(chain.pluginId) + ) + const matchesAddress = (chain: HoudiniChain): boolean => + addressCandidates.some(candidate => isValidHoudiniAddress(chain, candidate)) + + if (scheme != null) { + const schemeLower = scheme.toLowerCase() + const named = served.find( + chain => + chain.pluginId === schemeLower || + chain.houdiniShortName.toLowerCase() === schemeLower + ) + if (named != null && matchesAddress(named)) return [named] + } + + return served.filter(matchesAddress) +} diff --git a/src/util/paymentUri.ts b/src/util/paymentUri.ts new file mode 100644 index 00000000000..6b6e22d8675 --- /dev/null +++ b/src/util/paymentUri.ts @@ -0,0 +1,81 @@ +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 +} + +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 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('@') + if (atIndex >= 0) bareAddress = bareAddress.slice(0, atIndex) + + const addressCandidates: string[] = [] + for (const candidate of [trimmed, `${scheme}:${path}`, bareAddress]) { + if (candidate === '' || addressCandidates.includes(candidate)) continue + addressCandidates.push(candidate) + } + + return { addressCandidates, displayAmount, scheme } +} diff --git a/src/util/stealthSwap.ts b/src/util/stealthSwap.ts new file mode 100644 index 00000000000..ed1bcc45af6 --- /dev/null +++ b/src/util/stealthSwap.ts @@ -0,0 +1,44 @@ +import type { + EdgeAccount, + EdgePluginMap, + EdgeSwapRequestOptions +} from 'edge-core-js' + +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 !== 'houdini') disabled[swapPluginId] = true + } + return { + ...opts, + disabled, + forceEnabled: + flags.ignoreProviderSetting === true + ? { ...opts.forceEnabled, houdini: true } + : opts.forceEnabled, + preferPluginId: undefined, + preferType: undefined + } +} diff --git a/src/util/swapErrorDisplay.ts b/src/util/swapErrorDisplay.ts new file mode 100644 index 00000000000..128af7b1c3b --- /dev/null +++ b/src/util/swapErrorDisplay.ts @@ -0,0 +1,202 @@ +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) + + // 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): 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.fromWallet, + swapRequest.toTokenId + ), + swapToWalletKind: (swapRequest.toWallet ?? swapRequest.fromWallet) + .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" + // The destination's own type. This read the SOURCE wallet before, so + // every Sentry report claimed the swap ended on the chain it started + // on. A swap-to-address request has no destination wallet, so it falls + // back and the tag above still records the real payout currency. + toWalletType: (swapRequest.toWallet ?? swapRequest.fromWallet).type, + quoteFor: swapRequest.quoteFor + }) + return scope + }) +}