diff --git a/.gitignore b/.gitignore index da3f7fb27fa..ed3fe53b116 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,6 @@ yarn-error.log !.yarn/sdks !.yarn/versions /.husky/_ + +# Maestro run output +/maestro/screenshots diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b3a31194a..d667fa07e49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ - added: WalletConnect Bitcoin (bip122) message signing for proof of ownership with existing BTC wallets - added: Native Edge API HMAC signer (`edgeKey.json` + XOR-split C shards) so login-server requests can be signed outside the JS bundle via `apiSigner`, with JS `KEYS.EDGE_API_*` remaining as a fallback. - added: Remote signed `GET /v1/infoRollup/:appId` `appKeys` fetch so plugin secrets can rotate without an app release, with DeviceSettings cache and baked-in `keys.json` fallback +- added: Stealth Send, sending privately to an address on any chain the Houdini provider serves +- added: Stealth Swap, routing a swap through the Houdini privacy provider +- added: A pasted address from another chain sets up the cross-chain send +- added: Swap-sends and stealth sends get their own titles in the transaction list and details +- added: The send scene notes when a swap provider pays the recipient - changed: Standardize wallet list automation test IDs to use period separators. - changed: Lock the send confirmation slider for the rest of the scene once a broadcast has been attempted, whether the broadcast reported success or failure, and replace the generic failure card with a message that the transaction may have gone through, pointing at the block explorer or confirmation email before trying again. - changed: Prevent sending to the same wallet's own address for EVM assets. diff --git a/eslint.config.mjs b/eslint.config.mjs index 17295731ec9..124f8aa58d7 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', @@ -165,7 +172,6 @@ export default [ 'src/components/cards/StakingOptionCard.tsx', 'src/components/cards/StakingReturnsCard.tsx', 'src/components/cards/SupportCard.tsx', - 'src/components/cards/SwapDetailsCard.tsx', 'src/components/cards/TappableAccountCard.tsx', 'src/components/cards/TappableCard.tsx', 'src/components/cards/UnderlinedNumInputCard.tsx', @@ -224,10 +230,8 @@ export default [ 'src/components/modals/PasswordReminderModal.tsx', 'src/components/modals/PermissionsSettingModal.tsx', - 'src/components/modals/RadioListModal.tsx', 'src/components/modals/RawTextModal.tsx', 'src/components/modals/ScamWarningModal.tsx', - 'src/components/modals/StateProvinceListModal.tsx', 'src/components/modals/TransferModal.tsx', @@ -257,10 +261,7 @@ 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', @@ -380,7 +381,6 @@ export default [ 'src/components/themed/TransactionListComponents.tsx', 'src/components/themed/VectorIcon.tsx', - 'src/components/themed/WalletList.tsx', 'src/components/themed/WalletListErrorRow.tsx', 'src/components/themed/WalletListHeader.tsx', diff --git a/maestro/14-stealth/README.md b/maestro/14-stealth/README.md new file mode 100644 index 00000000000..663500c2810 --- /dev/null +++ b/maestro/14-stealth/README.md @@ -0,0 +1,134 @@ +# Stealth Send and Stealth Swap flows + +Maestro coverage for the send-to-address swap UI. Every user-visible branch of +the feature has a flow, and the pieces those flows are built from live in +`../common/stealth-*.yaml` so a later session can drive one specific state +without walking the whole thing by hand. + +## Running + +The whole suite, minus the two flows that spend money: + +```bash +npm run maestro -- test --include-tags stealth maestro +``` + +One flow: + +```bash +npm run maestro -- test maestro/14-stealth/stealth-private-quote.yaml +``` + +The two funded flows are tagged `stealth-spend` and nothing else, so the command +above never triggers them. Run them deliberately, and in this order: + +```bash +npm run maestro -- test maestro/14-stealth/stealth-execute-send.yaml +npm run maestro -- test maestro/14-stealth/stealth-execute-swap.yaml +``` + +They run opposite directions, so the pair returns the funds to where they +started and costs two spreads rather than emptying one wallet. Let the first +one's deposit confirm before starting the second: a wallet with an unconfirmed +outgoing transaction cannot quote a new send. + +Run flows ONE AT A TIME rather than as a tagged batch. A failing flow takes the +maestro driver down with it, and every flow after it then reports +`Failed to connect`, which reads as a suite-wide breakage instead of one bad +flow. + +On a machine with more than one simulator booted, pin the device and the driver +port, or the run may attach to the wrong one: + +```bash +maestro --device --driver-host-port test maestro/14-stealth +``` + +## Account expectations + +The flows read wallet names from env vars so they can point at whatever the +signed-in account actually holds. Defaults assume the `edge-funds` roster +account as of 2026-07-30: + +| Env var | Default | Needs | +| -------------------------- | ------------ | -------------------------------------------- | +| `STEALTH_SRC_WALLET` | `My Stellar` | funded above 25 USD for the private branches | +| `STEALTH_DEST_WALLET` | `My Sonic` | exists; no balance needed | +| `STEALTH_AMOUNT` | `30` | fiat, above the 25 USD private floor | +| `STEALTH_BELOW_MIN_AMOUNT` | `15` | fiat, between the 10 and 25 USD floors | +| `STEALTH_PIN_DIGIT` | `0` | the account's single repeated relogin digit | +| `STEALTH_MEMO_CHAIN` | `Ripple` | a memo-required destination chain | + +Two preconditions the flows cannot check for you: + +- **The source wallet must have no unconfirmed outgoing transaction.** A pending + send blocks the next one, so the quote never arms and the flow fails on the + slider rather than on anything it is testing. Running the funded flows + back-to-back on one wallet hits this. +- **The source wallet must hold more than the amount asked for.** The amounts are + fiat, so they sit against the floors on their own and need no re-checking as + prices move, but a wallet that has drifted below `STEALTH_AMOUNT` in value + fails the private branches on the balance rather than on the branch. + +`stealth-send.yaml`, `stealth-swap.yaml` and `stealth-qr-payment-uri.yaml` +request no quote, so they need no balance at all. + +## The flows + +| Flow | Branch it drives | +| ----------------------------------- | -------------------------------------------------------------------- | +| `stealth-send.yaml` | Send scene controls before address entry, plus a memo-chain tag row | +| `stealth-swap.yaml` | Exchange scene toggle card | +| `stealth-qr-payment-uri.yaml` | scanned payment URI, cross-chain, amount on the recipient side | +| `stealth-myself-picker.yaml` | own-wallet destinations grouped same-asset first | +| `stealth-cross-asset-quote.yaml` | plain Swap & Send on a transparent route | +| `stealth-private-quote.yaml` | the toggle invalidating a held quote and refetching a private route | +| `stealth-below-private-minimum.yaml`| the toggle refusing under the private floor, with no request sent | +| `stealth-tx-details.yaml` | a completed stealth transaction's identity rows | +| `stealth-execute-send.yaml` | **spends** a private Stealth Send to the success scene | +| `stealth-execute-swap.yaml` | **spends** a Stealth Swap from the Exchange scene | + +## Composing your own + +Each subflow states its env contract in its header. A walk that needs a live +private quote on a pair the suite does not cover is five `runFlow` steps: + +```yaml +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: My Tron 2 + WALLET_CODE: TRX +- runFlow: + file: ../common/stealth-pick-myself.yaml + env: + DEST_WALLET: My Litecoin (new) + DEST_CODE: LTC +- runFlow: + file: ../common/stealth-set-amount.yaml + env: + ROW: You send + AMOUNT: '30' +- runFlow: + file: ../common/stealth-toggle.yaml +- runFlow: + file: ../common/stealth-await-quote.yaml +``` + +Two things bite when writing these by hand: + +- **The confirm slider is a pan gesture.** A coordinate swipe across the track + does nothing at all. `stealth-slide-to-confirm.yaml` swipes from the thumb by + id, which is the only form that completes it. +- **Notification cards float over the bottom of every scene**, including the + slider and the amount rows. `stealth-launch.yaml` swipes them away; skip it + and later taps land on a card instead of the control underneath. + +## Assertions + +These flows are for driving the app to a state, not for asserting behavior. Each +one asserts only what it must to gate the next step (that a scene arrived, that +a quote settled, that the slider is live). Behavioral claims belong in the unit +tests and in `src/docs/stealth-send-swap.md`. diff --git a/maestro/14-stealth/stealth-below-private-minimum.yaml b/maestro/14-stealth/stealth-below-private-minimum.yaml new file mode 100644 index 00000000000..40f6621fe6b --- /dev/null +++ b/maestro/14-stealth/stealth-below-private-minimum.yaml @@ -0,0 +1,43 @@ +# 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'} + SRC_CODE: ${STEALTH_SRC_CODE || 'XLM'} + DEST_WALLET: ${STEALTH_DEST_WALLET || 'My Sonic'} + DEST_CODE: ${STEALTH_DEST_CODE || 'S'} + # Fiat, between the 10 USD transparent floor and the 25 USD private one: + AMOUNT: ${STEALTH_BELOW_MIN_AMOUNT || '15'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} + WALLET_CODE: ${SRC_CODE} +- runFlow: + file: ../common/stealth-pick-myself.yaml + env: + DEST_WALLET: ${DEST_WALLET} + DEST_CODE: ${DEST_CODE} +- 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..86f62557b28 --- /dev/null +++ b/maestro/14-stealth/stealth-cross-asset-quote.yaml @@ -0,0 +1,40 @@ +# 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'} + SRC_CODE: ${STEALTH_SRC_CODE || 'XLM'} + DEST_WALLET: ${STEALTH_DEST_WALLET || 'My Sonic'} + DEST_CODE: ${STEALTH_DEST_CODE || 'S'} + # Fiat, and above the 25 USD private floor: + AMOUNT: ${STEALTH_AMOUNT || '30'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} + WALLET_CODE: ${SRC_CODE} +- runFlow: + file: ../common/stealth-pick-myself.yaml + env: + DEST_WALLET: ${DEST_WALLET} + DEST_CODE: ${DEST_CODE} +- 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..97deed4fdb4 --- /dev/null +++ b/maestro/14-stealth/stealth-execute-send.yaml @@ -0,0 +1,49 @@ +# 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'} + SRC_CODE: ${STEALTH_SRC_CODE || 'XLM'} + DEST_WALLET: ${STEALTH_DEST_WALLET || 'My Sonic'} + DEST_CODE: ${STEALTH_DEST_CODE || 'S'} + # Fiat, and above the 25 USD private floor: + AMOUNT: ${STEALTH_AMOUNT || '30'} +tags: + - stealth-spend +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} + WALLET_CODE: ${SRC_CODE} +- runFlow: + file: ../common/stealth-pick-myself.yaml + env: + DEST_WALLET: ${DEST_WALLET} + DEST_CODE: ${DEST_CODE} +- 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..67378d89f2e --- /dev/null +++ b/maestro/14-stealth/stealth-execute-swap.yaml @@ -0,0 +1,81 @@ +# 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'} + SRC_CODE: ${STEALTH_SWAP_SRC_CODE || 'S'} + DEST_WALLET: ${STEALTH_SWAP_DEST_WALLET || 'My Stellar'} + DEST_CODE: ${STEALTH_SWAP_DEST_CODE || 'XLM'} + AMOUNT_FIAT: ${STEALTH_SWAP_AMOUNT_FIAT || '25'} +tags: + - stealth-spend +--- +- runFlow: + file: ../common/stealth-launch.yaml +- tapOn: 'Exchange' +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: 'Select Source Wallet' +- waitForAnimationToEnd: + timeout: 2500 +- tapOn: 'Search Wallets' +- inputText: '${SRC_WALLET}' +# Wait for the row the filter should produce: text typed before the search field +# settles is dropped, leaving the list unfiltered and the row absent. +- extendedWaitUntil: + visible: + id: 'walletPickerRow.${SRC_WALLET}.${SRC_CODE}' + timeout: 20000 +- tapOn: + id: 'walletPickerRow.${SRC_WALLET}.${SRC_CODE}' +- waitForAnimationToEnd: + timeout: 3500 +- tapOn: 'Select Receiving Wallet' +- waitForAnimationToEnd: + timeout: 2500 +- tapOn: 'Search Wallets' +- inputText: '${DEST_WALLET}' +# Wait for the row the filter should produce: text typed before the search field +# settles is dropped, leaving the list unfiltered and the row absent. +- extendedWaitUntil: + visible: + id: 'walletPickerRow.${DEST_WALLET}.${DEST_CODE}' + timeout: 20000 +- tapOn: + id: 'walletPickerRow.${DEST_WALLET}.${DEST_CODE}' +- 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..194da4877c0 --- /dev/null +++ b/maestro/14-stealth/stealth-myself-picker.yaml @@ -0,0 +1,29 @@ +# 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'} + SRC_CODE: ${STEALTH_SRC_CODE || 'XLM'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} + WALLET_CODE: ${SRC_CODE} +- 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..99c4a36d09d --- /dev/null +++ b/maestro/14-stealth/stealth-private-quote.yaml @@ -0,0 +1,48 @@ +# 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'} + SRC_CODE: ${STEALTH_SRC_CODE || 'XLM'} + DEST_WALLET: ${STEALTH_DEST_WALLET || 'My Sonic'} + DEST_CODE: ${STEALTH_DEST_CODE || 'S'} + # Fiat, and above the 25 USD private floor: + AMOUNT: ${STEALTH_AMOUNT || '30'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-send.yaml + env: + WALLET: ${SRC_WALLET} + WALLET_CODE: ${SRC_CODE} +- runFlow: + file: ../common/stealth-pick-myself.yaml + env: + DEST_WALLET: ${DEST_WALLET} + DEST_CODE: ${DEST_CODE} +- 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..02ad566ca5b --- /dev/null +++ b/maestro/14-stealth/stealth-qr-payment-uri.yaml @@ -0,0 +1,40 @@ +# 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'} + SRC_CODE: ${STEALTH_SRC_CODE || 'TRX'} + 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} + WALLET_CODE: ${SRC_CODE} + +# 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..55f8ec8cf1f --- /dev/null +++ b/maestro/14-stealth/stealth-send.yaml @@ -0,0 +1,43 @@ +# 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'} + SRC_CODE: ${STEALTH_SRC_CODE || 'TRX'} + 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} + WALLET_CODE: ${SRC_CODE} + +# 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..363fde15f1a --- /dev/null +++ b/maestro/14-stealth/stealth-swap.yaml @@ -0,0 +1,26 @@ +# 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'} + SRC_CODE: ${STEALTH_SRC_CODE || 'TRX'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-swap.yaml + env: + WALLET: ${SRC_WALLET} + WALLET_CODE: ${SRC_CODE} +- 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..6bc554f7674 --- /dev/null +++ b/maestro/14-stealth/stealth-tx-details.yaml @@ -0,0 +1,54 @@ +# 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'} + SRC_CODE: ${STEALTH_SRC_CODE || 'XLM'} +tags: + - stealth +--- +- runFlow: + file: ../common/stealth-launch.yaml +- runFlow: + file: ../common/stealth-open-wallet.yaml + env: + WALLET: ${SRC_WALLET} + WALLET_CODE: ${SRC_CODE} +# 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-dismiss-password-prompt.yaml b/maestro/common/stealth-dismiss-password-prompt.yaml new file mode 100644 index 00000000000..28803fbb982 --- /dev/null +++ b/maestro/common/stealth-dismiss-password-prompt.yaml @@ -0,0 +1,17 @@ +# Closes the "Verify your password" prompt if it is up. +# +# The prompt covers the scene, so nothing under it is reachable until it +# closes. Closing postpones the check without touching the password, and the +# walk carries on signed in with the PIN alone. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- runFlow: + when: + visible: 'Verify your password' + commands: + - tapOn: + id: 'modal-close-button' + - waitForAnimationToEnd: + timeout: 2500 diff --git a/maestro/common/stealth-dismiss-swap-warning.yaml b/maestro/common/stealth-dismiss-swap-warning.yaml new file mode 100644 index 00000000000..aa6dddd7435 --- /dev/null +++ b/maestro/common/stealth-dismiss-swap-warning.yaml @@ -0,0 +1,18 @@ +# Accepts the "This send uses a swap provider" notice if it is up. +# +# The notice shows once per account, the first time a send turns into a swap, +# so an account that has already seen it never raises it again. Every step that +# can change the destination asset calls this, because which one fires it +# depends on the order a walk takes and on the account's history. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- runFlow: + when: + visible: 'This send uses a swap provider' + commands: + - tapOn: 'I understand and agree to the terms' + - tapOn: 'Confirm & Finish' + - waitForAnimationToEnd: + timeout: 2500 diff --git a/maestro/common/stealth-enter-address.yaml b/maestro/common/stealth-enter-address.yaml new file mode 100644 index 00000000000..904d8137dc7 --- /dev/null +++ b/maestro/common/stealth-enter-address.yaml @@ -0,0 +1,21 @@ +# 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: 'addressModal.textInput' +- inputText: '${ADDRESS}' +- tapOn: 'Next' +- waitForAnimationToEnd: + timeout: 4000 +- runFlow: + file: stealth-dismiss-swap-warning.yaml diff --git a/maestro/common/stealth-launch.yaml b/maestro/common/stealth-launch.yaml new file mode 100644 index 00000000000..e1ceffdd33b --- /dev/null +++ b/maestro/common/stealth-launch.yaml @@ -0,0 +1,78 @@ +# 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 +# Signed in once either the tab bar or the "Verify your password" prompt shows. +# The prompt covers the tab bar, so waiting for "Assets" alone times out on the +# launches where the account is due a password check. +- extendedWaitUntil: + visible: 'Assets|Verify your password' + timeout: 40000 +- runFlow: + file: stealth-dismiss-password-prompt.yaml +- 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 +# A swipe that the card reads as a press opens the same prompt, so it gets a +# second chance to close once the gesture settles. +- waitForAnimationToEnd: + timeout: 2500 +- runFlow: + file: stealth-dismiss-password-prompt.yaml +# 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..6414c46e08c --- /dev/null +++ b/maestro/common/stealth-open-send.yaml @@ -0,0 +1,59 @@ +# Opens the Send scene for one wallet. +# +# env WALLET the source wallet's name, e.g. "My Sonic". +# env WALLET_CODE its currency code, e.g. "S". Row ids carry both. +# +# Reached through the home scene's Send button rather than the wallet's own +# transaction list: the balance card's Send opens the transfer modal, whose +# "To Another Wallet/Exchange" row leads to a wallet picker and then straight +# to the Send scene. That route is one modal deep and never depends on which +# scene the Assets tab lands on for a given wallet row. +# +# The picker's rows carry `walletPickerRow..`, not the scene's +# `walletListRow..`: the picker floats over a scene whose rows repeat the +# same names, and one shared namespace resolved a tap to the COVERED row, which +# dismissed the sheet instead of choosing a wallet. +# +# An unfunded source wallet offers to buy or exchange first; that modal is +# declined so the walk reaches the Send scene either way. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- tapOn: 'Home' +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: 'Send' +- extendedWaitUntil: + visible: 'To Another Wallet/Exchange' + timeout: 15000 +- tapOn: 'To Another Wallet/Exchange' +- extendedWaitUntil: + visible: 'Select Wallet to Send From' + timeout: 15000 +# By id, not by placeholder: the placeholder is gone once the field holds a +# query, and a previous walk's term would make the field unfindable. +- tapOn: + id: 'walletPickerSearch' +- inputText: '${WALLET}' +# Waiting for the ROW rather than for an animation: the search field is inside +# a sheet that is still settling, and text typed before it settles is dropped +# silently, leaving the list unfiltered and the row absent. +- extendedWaitUntil: + visible: + id: 'walletPickerRow.${WALLET}.${WALLET_CODE}' + timeout: 20000 +- tapOn: + id: 'walletPickerRow.${WALLET}.${WALLET_CODE}' +- waitForAnimationToEnd: + timeout: 3500 +- runFlow: + when: + visible: 'Wallet Empty' + commands: + - tapOn: 'Not at this time' + - waitForAnimationToEnd: + timeout: 2000 +- extendedWaitUntil: + visible: 'Send to Address' + timeout: 20000 diff --git a/maestro/common/stealth-open-swap.yaml b/maestro/common/stealth-open-swap.yaml new file mode 100644 index 00000000000..6d2e5dfaa20 --- /dev/null +++ b/maestro/common/stealth-open-swap.yaml @@ -0,0 +1,18 @@ +# 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". +# env WALLET_CODE its currency code, e.g. "PIVX". +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- runFlow: + file: stealth-open-wallet.yaml + env: + WALLET: ${WALLET} + WALLET_CODE: ${WALLET_CODE} +- 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..bd102801cc9 --- /dev/null +++ b/maestro/common/stealth-open-wallet.yaml @@ -0,0 +1,39 @@ +# Opens one wallet's transaction list from the Assets scene. +# +# env WALLET the wallet's name, e.g. "My Sonic". Matched against the row +# env WALLET_CODE its currency code, e.g. \"S\". Row ids carry both. +# 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: 'WalletListScene-SearchFooter.clearIcon' + commands: + - tapOn: + id: 'WalletListScene-SearchFooter.clearIcon' +- tapOn: + id: '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}.${WALLET_CODE}' + timeout: 20000 +- tapOn: + id: 'walletListRow.${WALLET}.${WALLET_CODE}' +- assertVisible: 'Receive' diff --git a/maestro/common/stealth-pick-myself.yaml b/maestro/common/stealth-pick-myself.yaml new file mode 100644 index 00000000000..05851fef50d --- /dev/null +++ b/maestro/common/stealth-pick-myself.yaml @@ -0,0 +1,34 @@ +# 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". +# env DEST_CODE its currency code, e.g. "XLM". Row ids carry both. +# +# The picker lists same-asset wallets under a "Same Asset" heading first, then +# every other wallet whose asset Houdini can pay out to. The source wallet is +# never offered. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- tapOn: + id: 'addressTileMyself' +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: + id: 'walletPickerSearch.textInput' +- inputText: '${DEST_WALLET}' +# Same race as the Assets search: wait for the row the filter should produce, +# not for a fixed animation. +- extendedWaitUntil: + visible: + id: 'walletPickerRow.${DEST_WALLET}.${DEST_CODE}' + timeout: 20000 +# Rows carry their wallet name as a testID. A plain text selector would match +# the search field, which holds the same string, rather than the row. +- tapOn: + id: 'walletPickerRow.${DEST_WALLET}.${DEST_CODE}' +- waitForAnimationToEnd: + timeout: 4000 +- runFlow: + file: stealth-dismiss-swap-warning.yaml diff --git a/maestro/common/stealth-pick-recipient-asset.yaml b/maestro/common/stealth-pick-recipient-asset.yaml new file mode 100644 index 00000000000..d1e364a7ec4 --- /dev/null +++ b/maestro/common/stealth-pick-recipient-asset.yaml @@ -0,0 +1,29 @@ +# Changes which asset the recipient receives, without choosing an address. +# +# env DEST_CHAIN the destination chain's Edge pluginId, e.g. "ethereum", +# "litecoin", "ripple". The picker keys its rows on the asset +# rather than on a display name, because a token can share both +# its name and its code with a chain (the POL ERC-20 and +# Polygon). +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- tapOn: 'Recipient receives' +- waitForAnimationToEnd: + timeout: 2500 +# The list holds every served chain, so most of it is below the fold and the +# row has to be scrolled to before it can be tapped. The search box above it is +# the faster path by hand, but the row id is stable either way. +- scrollUntilVisible: + element: + id: 'radioListItem_${DEST_CHAIN}' + direction: DOWN + timeout: 20000 + centerElement: true +- tapOn: + id: 'radioListItem_${DEST_CHAIN}' +- waitForAnimationToEnd: + timeout: 2500 +- runFlow: + file: stealth-dismiss-swap-warning.yaml diff --git a/maestro/common/stealth-scan-address.yaml b/maestro/common/stealth-scan-address.yaml new file mode 100644 index 00000000000..1aa7ded6f67 --- /dev/null +++ b/maestro/common/stealth-scan-address.yaml @@ -0,0 +1,24 @@ +# Delivers an address or payment URI the way a scanned QR does. +# +# env ADDRESS the address or payment URI to resolve. +# +# The simulator has no camera, so the value goes through the scan modal's +# keyboard-entry affordance, which runs the same code path a decoded QR takes. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- tapOn: + id: 'addressTileScan' +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: + id: 'scanModalTextInput' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: + id: 'textInputModal.textInput' +- inputText: '${ADDRESS}' +- tapOn: 'Submit' +- waitForAnimationToEnd: + timeout: 4000 diff --git a/maestro/common/stealth-set-amount.yaml b/maestro/common/stealth-set-amount.yaml new file mode 100644 index 00000000000..ef5b1e91506 --- /dev/null +++ b/maestro/common/stealth-set-amount.yaml @@ -0,0 +1,54 @@ +# Sets one side of the linked swap-send amounts. +# +# env ROW which row to edit: "You send" or "Recipient gets". The edited +# side becomes the guaranteed amount and the other tracks the +# quote as an estimate. +# env AMOUNT the number to type, in the account's fiat currency. +# +# Both rows open their flip input on fiat, so AMOUNT is a fiat figure and the +# crypto amount is whatever the live rate makes of it. That is the denomination +# the provider's floors are stated in, so a caller aiming above or below one of +# them says so directly instead of converting into source units first. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + # Declared under a different name than the one callers pass: a subflow env + # entry that reuses the caller's key overwrites what the caller passed, so + # `ROW: ${ROW || default}` would silently ignore the argument. + AMOUNT_ROW: ${ROW || 'You send'} +--- +# Rows can sit above or below the fold depending on where the previous step +# left the scroll position, so the row is brought into view from either side +# before it is tapped. A `scrollUntilVisible` that is already satisfied is a +# no-op, and one that scrolls the wrong way is optional and warns. +# +# The trailing `.*` is load-bearing: text matching is a full-string match, and +# the row's title carries its own state word, as in "You send (Guaranteed)". +- scrollUntilVisible: + element: + text: '${AMOUNT_ROW}.*' + direction: UP + timeout: 4000 + centerElement: true + optional: true +- scrollUntilVisible: + element: + text: '${AMOUNT_ROW}.*' + direction: DOWN + timeout: 4000 + centerElement: true + optional: true +- tapOn: '${AMOUNT_ROW}.*' +- waitForAnimationToEnd: + timeout: 3000 +# The flip input opens pre-filled with whatever the row already holds, and +# `inputText` appends. On a first edit the field is empty and this erases +# nothing; on a re-edit it is the difference between setting the amount and +# concatenating onto the old one, which commits a number nobody asked for. +- eraseText +- inputText: '${AMOUNT}' +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: 'Done' +- waitForAnimationToEnd: + timeout: 4000 diff --git a/maestro/common/stealth-slide-to-confirm.yaml b/maestro/common/stealth-slide-to-confirm.yaml new file mode 100644 index 00000000000..c6177c95270 --- /dev/null +++ b/maestro/common/stealth-slide-to-confirm.yaml @@ -0,0 +1,19 @@ +# Drives the confirmation slider. +# +# The slider is a pan-gesture handler that completes only when its thumb +# reaches the far left. A coordinate swipe across the track does NOT activate +# the gesture and leaves the slider untouched, so the swipe has to originate +# from the thumb itself by id. +# +# THIS SPENDS FUNDS on a scene that holds a live quote. Only the flows tagged +# `stealth-spend` call it. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} +--- +- swipe: + from: + id: 'confirmSliderThumb' + direction: LEFT +- waitForAnimationToEnd: + timeout: 20000 diff --git a/maestro/common/stealth-toggle.yaml b/maestro/common/stealth-toggle.yaml new file mode 100644 index 00000000000..4377beb004f --- /dev/null +++ b/maestro/common/stealth-toggle.yaml @@ -0,0 +1,37 @@ +# Flips the Stealth toggle. +# +# env TOGGLE "Stealth Send" on the Send scene, "Stealth Swap" on the Exchange +# scene. Defaults to the Send scene's label. +# +# Toggling always invalidates any held quote and refetches, because the private +# and transparent routes are different requests, so callers that need a live +# quote afterwards should follow with stealth-await-quote.yaml. +appId: ${APP_ID} +env: + APP_ID: ${MAESTRO_APP_ID || 'co.edgesecure.app'} + # Declared under a different name than the one callers pass: a subflow env + # entry that reuses the caller's key overwrites what the caller passed, so + # `TOGGLE: ${TOGGLE || default}` would silently ignore the argument. + TOGGLE_LABEL: ${TOGGLE || 'Stealth Send'} +--- +# Rows can sit above or below the fold depending on where the previous step +# left the scroll position, so the row is brought into view from either side +# before it is tapped. A `scrollUntilVisible` that is already satisfied is a +# no-op, and one that scrolls the wrong way is optional and warns. +- scrollUntilVisible: + element: + text: '${TOGGLE_LABEL}' + direction: UP + timeout: 4000 + centerElement: true + optional: true +- scrollUntilVisible: + element: + text: '${TOGGLE_LABEL}' + direction: DOWN + timeout: 4000 + centerElement: true + optional: true +- tapOn: '${TOGGLE_LABEL}' +- waitForAnimationToEnd: + timeout: 2500 diff --git a/scripts/splitEnvJson.ts b/scripts/splitEnvJson.ts index 1d0d539b64e..d06b2e3b322 100644 --- a/scripts/splitEnvJson.ts +++ b/scripts/splitEnvJson.ts @@ -86,6 +86,7 @@ export const SWAP_INIT_MAP: Record = { CHANGELLY_INIT: 'changelly', EXOLIX_INIT: 'exolix', GODEX_INIT: 'godex', + HOUDINI_INIT: 'houdini', LIFI_INIT: 'lifi', LETSEXCHANGE_INIT: 'letsexchange', MPTRADE_INIT: 'mptrade', diff --git a/src/__tests__/actions/CategoriesActions.test.ts b/src/__tests__/actions/CategoriesActions.test.ts new file mode 100644 index 00000000000..05bf0eba047 --- /dev/null +++ b/src/__tests__/actions/CategoriesActions.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from '@jest/globals' +import type { + EdgeAccount, + EdgeAssetActionType, + EdgeCurrencyWallet, + EdgeMetadata, + EdgeTransaction +} from 'edge-core-js' + +import { + getPluginIdIcon, + getTxActionDisplayInfo +} from '../../actions/CategoriesActions' +import { lstrings } from '../../locales/strings' +import { edgeDark } from '../../theme/variables/edgeDark' +import { edgeLight } from '../../theme/variables/edgeLight' + +const BITCOIN_WALLET_ID = 'bitcoin-wallet-id' +const RECIPIENT_ADDRESS = 'bc1qrecipientaddressthepayeecontrols' +const DEPOSIT_ADDRESS = '13e6qqcAZCgApTDyMNG8brru4PmtjbReUd' + +// Only the fields `getTxActionDisplayInfo` actually reads: +const account = { + currencyWallets: {}, + currencyConfig: { + bitcoin: { + currencyInfo: { currencyCode: 'BTC' }, + allTokens: {} + }, + ethereum: { + currencyInfo: { currencyCode: 'ETH' }, + allTokens: { + '0000000000000000000000000000000000000001': { currencyCode: 'USDC' } + } + } + } +} as unknown as EdgeAccount + +const bitcoinWallet = { + id: BITCOIN_WALLET_ID, + currencyInfo: { pluginId: 'bitcoin', assetDisplayName: 'Bitcoin' }, + currencyConfig: account.currencyConfig.bitcoin +} as unknown as EdgeCurrencyWallet + +const ethereumWallet = { + id: 'ethereum-wallet-id', + currencyInfo: { pluginId: 'ethereum', assetDisplayName: 'Ethereum' }, + currencyConfig: account.currencyConfig.ethereum +} as unknown as EdgeCurrencyWallet + +interface SwapTxOpts { + assetActionType?: EdgeAssetActionType + fromPluginId?: string + fromTokenId?: string | null + metadata?: EdgeMetadata + privacy?: boolean + tokenId?: string | null + toPluginId?: string +} + +/** + * A broadcast send, as the Houdini plugin leaves it: the spend target is the + * provider's deposit address, and the payee rides on the saved action alone. + */ +const makeSwapSendTx = (opts: SwapTxOpts = {}): EdgeTransaction => { + const { + assetActionType = 'swap', + fromPluginId = 'bitcoin', + fromTokenId = null, + metadata, + privacy = false, + tokenId = null, + toPluginId = 'bitcoin' + } = opts + + return { + txid: 'txid', + tokenId, + currencyCode: 'BTC', + nativeAmount: '-38693', + isSend: true, + metadata, + assetAction: { assetActionType }, + spendTargets: [{ publicAddress: DEPOSIT_ADDRESS, nativeAmount: '38693' }], + savedAction: { + actionType: 'swapSend', + swapInfo: { pluginId: 'houdini', displayName: 'HoudiniSwap' }, + orderId: '9zdiWHWi2Q4Y7NRPB8k7mL', + isEstimate: true, + fromAsset: { + pluginId: fromPluginId, + tokenId: fromTokenId, + nativeAmount: '38693' + }, + toAsset: { pluginId: toPluginId, tokenId: null, nativeAmount: '37580' }, + payoutAddress: RECIPIENT_ADDRESS, + privacy + } + } as unknown as EdgeTransaction +} + +describe('getTxActionDisplayInfo, send titles', () => { + it('titles a private same-asset send as a Stealth Send', () => { + const { mergedData } = getTxActionDisplayInfo( + makeSwapSendTx({ privacy: true }), + account, + bitcoinWallet + ) + expect(mergedData.name).toBe(lstrings.transaction_details_stealth_send) + }) + + it('titles a private cross-asset send as a Stealth Swap & Send', () => { + const { mergedData } = getTxActionDisplayInfo( + makeSwapSendTx({ privacy: true, toPluginId: 'ethereum' }), + account, + bitcoinWallet + ) + expect(mergedData.name).toBe( + lstrings.transaction_details_stealth_swap_and_send + ) + }) + + it('titles a transparent send as a Swap & Send', () => { + const { mergedData } = getTxActionDisplayInfo( + makeSwapSendTx({ toPluginId: 'ethereum' }), + account, + bitcoinWallet + ) + expect(mergedData.name).toBe(lstrings.transaction_details_swap_and_send) + }) + + it('outranks a stored metadata name on a private send', () => { + // A recipient-style name reaching the transaction by any route must not + // win the merge, or the flow displays what it exists to conceal. + const { mergedData } = getTxActionDisplayInfo( + makeSwapSendTx({ + privacy: true, + metadata: { name: RECIPIENT_ADDRESS } + }), + account, + bitcoinWallet + ) + expect(mergedData.name).toBe(lstrings.transaction_details_stealth_send) + expect(mergedData.name).not.toContain(RECIPIENT_ADDRESS) + }) + + it('lets a stored name stand on a transparent send', () => { + const { mergedData } = getTxActionDisplayInfo( + makeSwapSendTx({ metadata: { name: 'Alice' } }), + account, + bitcoinWallet + ) + expect(mergedData.name).toBe('Alice') + }) +}) + +describe('getTxActionDisplayInfo, the parent network-fee row', () => { + // A token send files its fee under `tokenId: null` with the same send + // action, so the private-send display rules hold there too, but the row is + // the fee, not the send. + const feeRow = makeSwapSendTx({ + assetActionType: 'swapNetworkFee', + fromPluginId: 'ethereum', + fromTokenId: '0000000000000000000000000000000000000001', + privacy: true, + tokenId: null + }) + + it('keeps the network-fee title rather than the flow title', () => { + const { mergedData } = getTxActionDisplayInfo( + feeRow, + account, + ethereumWallet + ) + expect(mergedData.name).toBe(lstrings.transaction_details_swap_network_fee) + expect(mergedData.name).not.toBe(lstrings.transaction_details_stealth_send) + }) + + it('still outranks a stored metadata name on the fee row', () => { + const namedFeeRow: EdgeTransaction = { + ...feeRow, + metadata: { name: RECIPIENT_ADDRESS } + } + const { mergedData } = getTxActionDisplayInfo( + namedFeeRow, + account, + ethereumWallet + ) + expect(mergedData.name).not.toContain(RECIPIENT_ADDRESS) + }) + + it('keeps the network-fee category', () => { + const { mergedData } = getTxActionDisplayInfo( + feeRow, + account, + ethereumWallet + ) + expect(mergedData.category).toContain(lstrings.wc_smartcontract_network_fee) + }) +}) + +describe('getPluginIdIcon', () => { + it('follows the theme and fits a single-color logo', () => { + expect(getPluginIdIcon('houdini', edgeDark)).toEqual({ + uri: 'https://content.edge.app/exchangeIcons/houdini/icon.png', + fit: true + }) + expect(getPluginIdIcon('houdini', edgeLight)).toEqual({ + uri: 'https://content.edge.app/exchangeIcons/houdini/icon-light.png', + fit: true + }) + }) + + it('keeps the one cropped image for every other provider', () => { + const lifiIcon = { uri: 'https://content.edge.app/lifi.png', fit: false } + expect(getPluginIdIcon('lifi', edgeDark)).toEqual(lifiIcon) + expect(getPluginIdIcon('lifi', edgeLight)).toEqual(lifiIcon) + }) + + it('has no logo for an unknown or missing provider', () => { + expect(getPluginIdIcon('notAProvider', edgeDark)).toBeUndefined() + expect(getPluginIdIcon(undefined, edgeDark)).toBeUndefined() + }) +}) 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" > @@ -696,7 +697,7 @@ exports[`AddressModalComponent should render with loaded props 1`] = ` "paddingVertical": 28, } } - testID="undefined.clearIcon" + testID="addressModal.clearIcon" > @@ -616,7 +617,7 @@ exports[`TextInputModal should render with a blank input field 1`] = ` "paddingVertical": 28, } } - testID="undefined.clearIcon" + testID="textInputModal.clearIcon" > @@ -1470,7 +1472,7 @@ exports[`TextInputModal should render with a populated input field 1`] = ` "paddingVertical": 28, } } - testID="undefined.clearIcon" + testID="textInputModal.clearIcon" > { {...fakeSwapTabSceneProps('swapConfirmation', { quotes: [quote], selectedQuote: quote, - onApprove: () => undefined + onApprove: () => undefined, + swapRequest: quote.request, + swapRequestOptions: {} })} /> @@ -195,7 +197,9 @@ describe('SwapConfirmationScene', () => { const props = fakeSwapTabSceneProps('swapConfirmation', { quotes: [quote], selectedQuote: quote, - onApprove: () => undefined + onApprove: () => undefined, + swapRequest: quote.request, + swapRequestOptions: {} }) const rendered = render( diff --git a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap index f5915ca2a1d..228e641c502 100644 --- a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap @@ -248,7 +248,7 @@ exports[`SendScene2 1 spendTarget 1`] = ` "reduceMotionV": "system", } } - nativeID="6" + nativeID="9" > @@ -1571,7 +1571,7 @@ exports[`SendScene2 1 spendTarget 1`] = ` "reduceMotionV": "system", } } - nativeID="13" + nativeID="16" > @@ -3581,7 +3581,7 @@ exports[`SendScene2 1 spendTarget with info tiles 1`] = ` "reduceMotionV": "system", } } - nativeID="23" + nativeID="26" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -5591,7 +5703,7 @@ exports[`SendScene2 2 spendTargets 1`] = ` "reduceMotionV": "system", } } - nativeID="34" + nativeID="37" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -7182,7 +7406,7 @@ exports[`SendScene2 2 spendTargets hide tiles 1`] = ` "reduceMotionV": "system", } } - nativeID="44" + nativeID="47" > - Network Fee: + Total Amount - 0 (0) + 0.00013579 BTC - + + + + + Network Fee: + + + 0 (0) + + + @@ -8753,7 +9089,7 @@ exports[`SendScene2 2 spendTargets hide tiles 2`] = ` "reduceMotionV": "system", } } - nativeID="54" + nativeID="57" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -10091,7 +10539,7 @@ exports[`SendScene2 2 spendTargets hide tiles 3`] = ` "reduceMotionV": "system", } } - nativeID="63" + nativeID="66" > - Network Fee: + Total Amount - 0 (0) + 0.00013579 BTC - + + + + + Network Fee: + + + 0 (0) + + + @@ -11842,7 +12402,7 @@ exports[`SendScene2 2 spendTargets lock tiles 1`] = ` "reduceMotionV": "system", } } - nativeID="75" + nativeID="78" > + + + + Total Amount + + + 0.00013579 BTC + + + + @@ -13544,7 +14216,7 @@ exports[`SendScene2 2 spendTargets lock tiles 2`] = ` "reduceMotionV": "system", } } - nativeID="86" + nativeID="89" > - Network Fee: + Total Amount - 0 (0) + 0.00013579 BTC - + + + + + Network Fee: + + + 0 (0) + + + @@ -15173,7 +15957,7 @@ exports[`SendScene2 2 spendTargets lock tiles 3`] = ` "reduceMotionV": "system", } } - nativeID="97" + nativeID="100" > - Send to Address + Recipient receives - -  - - - Enter - + "bottom": 0, + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + } + } + /> + - + Bitcoin (BTC) + + + + + +  + + + + + + + + Send to Address + + + + +  + + + Enter + + + + +  + + + Myself + + + + + + + + + Stealth Send + + + + + + + + + + + + + + + + true + +describe('detectHoudiniChains', () => { + it('detects the chain a bare address belongs to', () => { + const found = detectHoudiniChains(ADDRESSES.litecoin, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('litecoin') + }) + + it('returns every EVM chain for a bare 0x address', () => { + const found = detectHoudiniChains(ADDRESSES.ethereum, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + const pluginIds = found.map(chain => chain.pluginId) + expect(pluginIds).toContain('ethereum') + expect(pluginIds).toContain('polygon') + expect(pluginIds.length).toBeGreaterThan(2) + }) + + it('resolves an ambiguous address outright when the URI names the chain', () => { + const found = detectHoudiniChains(`ethereum:${ADDRESSES.ethereum}`, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toEqual(['ethereum']) + }) + + it('honors a URI scheme that differs from the plugin id', () => { + const found = detectHoudiniChains(`polygon:${ADDRESSES.ethereum}`, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toEqual(['polygon']) + }) + + it('carries the amount through to the caller-visible candidates', () => { + const found = detectHoudiniChains( + `ethereum:${ADDRESSES.ethereum}?amount=0.007`, + { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + } + ) + expect(found.map(chain => chain.pluginId)).toEqual(['ethereum']) + }) + + it('offers the source chain when the source asset is a token', () => { + // USDC on Ethereum paying out native ETH is a real cross-asset route, so a + // pasted Ethereum address must offer Ethereum. Excluding it unconditionally + // left the picker naming every OTHER EVM network and not the one the + // recipient actually holds. + const found = detectHoudiniChains(ADDRESSES.ethereum, { + sourcePluginId: 'ethereum', + sourceTokenId: 'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('ethereum') + }) + + it('never offers the sending wallet own chain as a destination', () => { + const found = detectHoudiniChains(ADDRESSES.bitcoin, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).not.toContain('bitcoin') + }) + + it('skips chains the account has no plugin for', () => { + const found = detectHoudiniChains(ADDRESSES.ethereum, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: pluginId => pluginId === 'polygon' + }) + expect(found.map(chain => chain.pluginId)).toEqual(['polygon']) + }) + + it('detects Solana, whose format overlaps no EVM chain', () => { + const found = detectHoudiniChains(ADDRESSES.solana, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('solana') + }) + + it('detects Bitcoin from a Litecoin wallet', () => { + const found = detectHoudiniChains(ADDRESSES.bitcoin, { + sourcePluginId: 'litecoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('bitcoin') + }) + + it('detects a legacy Bitcoin address', () => { + const found = detectHoudiniChains(ADDRESSES.bitcoinLegacy, { + sourcePluginId: 'litecoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('bitcoin') + }) + + it('detects Dogecoin', () => { + const found = detectHoudiniChains(ADDRESSES.dogecoin, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('dogecoin') + }) + + it('returns nothing for input that addresses no served chain', () => { + const found = detectHoudiniChains('not an address', { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found).toEqual([]) + }) + + it('falls back to format matching when the scheme is unknown', () => { + const found = detectHoudiniChains(`madeupchain:${ADDRESSES.litecoin}`, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + expect(found.map(chain => chain.pluginId)).toContain('litecoin') + }) + + it('ignores a scheme whose address does not validate on that chain', () => { + // A mislabeled URI must not be trusted into sending to the wrong chain: + const found = detectHoudiniChains(`ethereum:${ADDRESSES.litecoin}`, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: supportAll + }) + const pluginIds = found.map(chain => chain.pluginId) + expect(pluginIds).toContain('litecoin') + expect(pluginIds).not.toContain('ethereum') + }) + + it('rejects a Cardano regex catch-all that would accept any text', () => { + // Houdini's published Cardano regex matches every string; detection is + // meaningless unless that is corrected. + const found = detectHoudiniChains('hello world', { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: pluginId => pluginId === 'cardano' + }) + expect(found).toEqual([]) + }) + + it('resolves an EIP-681 chain id to that chain, not to the scheme', () => { + // Every EVM network's payment code writes `ethereum:`, so reading the + // scheme alone sent a Polygon code to Ethereum mainnet. + const found = detectHoudiniChains(`ethereum:${ADDRESSES.ethereum}@137`, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: () => true + }) + expect(found.map(chain => chain.pluginId)).toEqual(['polygon']) + }) + + it('resolves an EIP-681 chain id to Robinhood Chain', () => { + // Robinhood Chain is an EVM network, so its bare address matches every + // other EVM entry; the chain id is what names it. + const found = detectHoudiniChains(`ethereum:${ADDRESSES.ethereum}@4663`, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: () => true + }) + expect(found.map(chain => chain.pluginId)).toEqual(['robinhood']) + }) + + it('resolves an EIP-681 chain id to Monad', () => { + const found = detectHoudiniChains(`ethereum:${ADDRESSES.ethereum}@143`, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: () => true + }) + expect(found.map(chain => chain.pluginId)).toEqual(['monad']) + }) + + it('detects a TON address and no other chain', () => { + const found = detectHoudiniChains(ADDRESSES.ton, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: () => true + }) + expect(found.map(chain => chain.pluginId)).toEqual(['ton']) + }) + + it('resolves nothing for a chain id no served chain claims', () => { + // Falling back to the scheme here would pay Ethereum for a code that named + // some other network, which is the misdirection the chain id exists to stop. + const found = detectHoudiniChains(`ethereum:${ADDRESSES.ethereum}@999999`, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: () => true + }) + expect(found).toEqual([]) + }) + + it('does not offer Solana for a legacy UTXO address', () => { + // Solana's published pattern reaches down to 32 base58 characters, which is + // the band the Bitcoin-family legacy forms sit in, so a Litecoin address + // offered Solana as a network to pay. + const found = detectHoudiniChains(ADDRESSES.litecoin, { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: pluginId => pluginId === 'solana' + }) + expect(found).toEqual([]) + }) + + it('does not offer eCash for an EVM address', () => { + // Houdini's published eCash regex spells the prefix-less cashaddr form as + // `[0-9A-Za-z]{42}`, which is exactly the shape of an `0x` EVM address, so + // every EVM paste offered eCash as a candidate network to pay. + const found = detectHoudiniChains( + '0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e', + { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: pluginId => pluginId === 'ecash' + } + ) + expect(found).toEqual([]) + }) + + it('still detects a real eCash address, prefixed or bare', () => { + const opts = { + sourcePluginId: 'bitcoin', + sourceTokenId: null, + isSupported: (pluginId: string) => pluginId === 'ecash' + } + const bare = detectHoudiniChains( + 'qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a', + opts + ) + const prefixed = detectHoudiniChains( + 'ecash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a', + opts + ) + expect(bare.map(chain => chain.pluginId)).toEqual(['ecash']) + expect(prefixed.map(chain => chain.pluginId)).toEqual(['ecash']) + }) +}) + +describe('getHoudiniChain', () => { + it('finds a served chain by its Edge plugin id', () => { + const chain = getHoudiniChain('litecoin', null) + expect(chain?.houdiniShortName).toEqual('litecoin') + }) + + it('returns nothing for a chain Houdini does not serve', () => { + expect(getHoudiniChain('piratechain', null)).toBeUndefined() + }) + + it('returns nothing for the chains with no mainnet native coin', () => { + // Houdini publishes no mainnet native for these, so a quote naming one can + // never be built. The plugin declines them at runtime either way; keeping + // them out of this table is about not OFFERING a destination the provider + // cannot pay out to. + for (const pluginId of ['celo', 'fantom', 'polkadot']) { + expect(getHoudiniChain(pluginId, null)).toBeUndefined() + } + }) + + it('returns nothing for Telos, whose Houdini route is a different network', () => { + // Houdini's `telos` is Telos EVM, paid at `0x` addresses. Edge's `telos` + // wallets are EOSIO accounts, so they can neither fund nor receive it. + expect(getHoudiniChain('telos', 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', + 'ton' + ]) + ) + 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('rejects a pipe in a Dash or Monero address', () => { + // Inside a character class `|` is a literal, not alternation, so a + // provider pattern written `[X|7]` accepts it as an address character. + const dash = getHoudiniChain('dash', null) + const monero = getHoudiniChain('monero', null) + expect(dash).toBeDefined() + expect(monero).toBeDefined() + if (dash == null || monero == null) return + expect( + isValidHoudiniAddress(dash, 'XpESxaUmonkq8RaLLp46Brx2K39ggQe226') + ).toEqual(true) + expect(isValidHoudiniAddress(dash, '|' + 'a'.repeat(33))).toEqual(false) + const moneroAddress = + '44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A' + expect(isValidHoudiniAddress(monero, moneroAddress)).toEqual(true) + expect( + isValidHoudiniAddress( + monero, + moneroAddress.slice(0, 50) + '|' + moneroAddress.slice(51) + ) + ).toEqual(false) + }) + + it('accepts and rejects addresses on a chain that needs a memo', () => { + const ripple = getHoudiniChain('ripple', null) + expect(ripple).toBeDefined() + if (ripple == null) return + expect( + isValidHoudiniAddress(ripple, 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe') + ).toEqual(true) + expect(isValidHoudiniAddress(ripple, 'notanaddress')).toEqual(false) + }) + + it('trims surrounding whitespace before validating', () => { + const litecoin = getHoudiniChain('litecoin', null) + expect(litecoin).toBeDefined() + if (litecoin == null) return + expect( + isValidHoudiniAddress(litecoin, ' MQMcJhpWHYVeQArcZR3sBgyPZxxRtnH441 ') + ).toEqual(true) + }) +}) + +describe('HOUDINI_MIN_USD', () => { + it('orders the floors from the strictest route to the loosest', () => { + // Confirmed against the live API: a pair answers with no route at all + // below 10 USD, standard routes from 10 up, and private routes from 25. + expect(lt(HOUDINI_MIN_USD.dex, HOUDINI_MIN_USD.standard)).toEqual(true) + expect(lt(HOUDINI_MIN_USD.standard, HOUDINI_MIN_USD.private)).toEqual(true) + }) + + it('states the floors as biggystring-comparable decimal strings', () => { + // These are compared against a converted USD order value with `lt`, which + // needs plain decimal strings rather than numbers. + for (const floor of Object.values(HOUDINI_MIN_USD)) { + expect(typeof floor).toEqual('string') + expect(floor).toMatch(/^[0-9]+(\.[0-9]+)?$/) + } + }) + + it('holds the values Houdini published', () => { + expect(HOUDINI_MIN_USD).toEqual({ + private: '25', + standard: '10', + dex: '5' + }) + }) +}) + +describe('schemeNamesChain', () => { + const getChain = (pluginId: string): (typeof HOUDINI_CHAINS)[number] => { + const chain = HOUDINI_CHAINS.find(entry => entry.pluginId === pluginId) + if (chain == null) throw new Error(`no ${pluginId} in HOUDINI_CHAINS`) + return chain + } + + it('matches a scheme naming the chain, by plugin id or provider name', () => { + expect(schemeNamesChain('ethereum', getChain('ethereum'))).toEqual(true) + expect(schemeNamesChain('ETHEREUM', getChain('ethereum'))).toEqual(true) + expect(schemeNamesChain('litecoin', getChain('litecoin'))).toEqual(true) + }) + + it('rejects a scheme naming a different EVM chain', () => { + // The case that made an `ethereum:` code payable on a picked Polygon + // destination: the two share an address format, so the address alone + // cannot tell them apart and only the scheme can. + expect(schemeNamesChain('ethereum', getChain('polygon'))).toEqual(false) + expect(schemeNamesChain('polygon', getChain('ethereum'))).toEqual(false) + }) +}) + +// A USDT contract on Ethereum, standing in for any token source: +const USDT_TOKEN_ID = 'dac17f958d2ee523a2206206994597c13d831ec7' +// The POL ERC-20 on Ethereum. Its `displayName` is "Polygon" and its +// `currencyCode` is POL, both identical to the Polygon chain's: +const POL_TOKEN_ID = '455e53cbb86018ac2b8092fdcd39d8444affc3f6' + +describe('getRecipientAsset', () => { + it('gives the source asset for a plain send, token included', () => { + expect( + getRecipientAsset({ + sourcePluginId: 'ethereum', + sourceTokenId: USDT_TOKEN_ID, + destPluginId: 'ethereum', + swapSendActive: false + }) + ).toEqual({ pluginId: 'ethereum', tokenId: USDT_TOKEN_ID }) + }) + + it('gives the destination chain native for a swap-send', () => { + // The quote asks for `toTokenId: null`, so a USDT source pays out ETH even + // with no destination chain picked. Naming the source token here told a + // USDT sender their recipient receives USDT. + expect( + getRecipientAsset({ + sourcePluginId: 'ethereum', + sourceTokenId: USDT_TOKEN_ID, + destPluginId: 'ethereum', + swapSendActive: true + }) + ).toEqual({ pluginId: 'ethereum', tokenId: null }) + + expect( + getRecipientAsset({ + sourcePluginId: 'ethereum', + sourceTokenId: USDT_TOKEN_ID, + destPluginId: 'litecoin', + swapSendActive: true + }) + ).toEqual({ pluginId: 'litecoin', tokenId: null }) + }) +}) + +describe('getRecipientAssetChoices', () => { + const servedPluginIds = ['bitcoin', 'ethereum', 'litecoin', 'polygon'] + + it('names what picking the first row pays out', () => { + // Picking the first row clears the adopted chain, so the swap state that + // matters is the one Stealth alone gives. Labelling it from the current + // state named ETH for a USDT sender whose pick reverted to a USDT send. + for (const stealthActive of [false, true]) { + for (const sourceTokenId of [null, USDT_TOKEN_ID]) { + const [first] = getRecipientAssetChoices({ + sourcePluginId: 'ethereum', + sourceTokenId, + stealthActive, + servedPluginIds + }) + expect(first.recipientPluginId).toEqual(undefined) + expect(first.asset).toEqual( + getRecipientAsset({ + sourcePluginId: 'ethereum', + sourceTokenId, + destPluginId: 'ethereum', + swapSendActive: stealthActive + }) + ) + } + } + }) + + it('offers a token source its own chain native without Stealth', () => { + const choices = getRecipientAssetChoices({ + sourcePluginId: 'ethereum', + sourceTokenId: USDT_TOKEN_ID, + stealthActive: false, + servedPluginIds + }) + expect(choices.map(choice => choice.asset)).toEqual([ + { pluginId: 'ethereum', tokenId: USDT_TOKEN_ID }, + { pluginId: 'bitcoin', tokenId: null }, + { pluginId: 'ethereum', tokenId: null }, + { pluginId: 'litecoin', tokenId: null }, + { pluginId: 'polygon', tokenId: null } + ]) + expect(choices[2].recipientPluginId).toEqual('ethereum') + }) + + it('never lists one payout twice', () => { + // Two rows for the same payout quote identically and differ only in + // `crossAssetPicked`, which decides whether turning Stealth off degrades + // to a plain send. No user can tell them apart. + for (const stealthActive of [false, true]) { + for (const sourceTokenId of [null, USDT_TOKEN_ID]) { + const keys = getRecipientAssetChoices({ + sourcePluginId: 'ethereum', + sourceTokenId, + stealthActive, + servedPluginIds + }).map(choice => recipientAssetKey(choice.asset)) + expect(new Set(keys).size).toEqual(keys.length) + } + } + }) + + it('offers every served chain but the source to a native source', () => { + const choices = getRecipientAssetChoices({ + sourcePluginId: 'ethereum', + sourceTokenId: null, + stealthActive: true, + servedPluginIds + }) + expect(choices.map(choice => choice.recipientPluginId)).toEqual([ + undefined, + 'bitcoin', + 'litecoin', + 'polygon' + ]) + }) +}) + +describe('recipientAssetKey', () => { + it('separates a token from a chain that shares its name and code', () => { + // The POL ERC-20 on Ethereum and the Polygon chain are both displayed as + // "Polygon (POL)". Keying the picker on the label marked both rows + // selected and resolved either tap to the same destination, which left + // Polygon unreachable from a POL wallet. + const polToken = recipientAssetKey({ + pluginId: 'ethereum', + tokenId: POL_TOKEN_ID + }) + const polygonChain = recipientAssetKey({ + pluginId: 'polygon', + tokenId: null + }) + expect(polToken).not.toEqual(polygonChain) + }) + + it('gives every row of a picker a distinct key', () => { + const choices = getRecipientAssetChoices({ + sourcePluginId: 'ethereum', + sourceTokenId: POL_TOKEN_ID, + stealthActive: false, + servedPluginIds: HOUDINI_CHAINS.map(chain => chain.pluginId) + }) + const keys = choices.map(choice => recipientAssetKey(choice.asset)) + expect(new Set(keys).size).toEqual(keys.length) + }) +}) diff --git a/src/__tests__/util/paymentUri.test.ts b/src/__tests__/util/paymentUri.test.ts new file mode 100644 index 00000000000..58903eb7a67 --- /dev/null +++ b/src/__tests__/util/paymentUri.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from '@jest/globals' +import type { EdgeCurrencyConfig, EdgeParsedUri } from 'edge-core-js' + +import { makeFakeCurrencyConfig } from '../../util/fake/fakeCurrencyConfig' +import { parseCrossChainPayment, peekPaymentUri } from '../../util/paymentUri' + +describe('peekPaymentUri', () => { + it('passes a bare address through as its own candidate', () => { + const address = '0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372' + expect(peekPaymentUri(address)).toEqual({ + addressCandidates: [address] + }) + }) + + it('trims surrounding whitespace', () => { + expect( + peekPaymentUri(' bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq \n') + ).toEqual({ + addressCandidates: ['bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'] + }) + }) + + it('reads the scheme of a BIP-21 URI', () => { + const uri = + 'bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.0123' + expect(peekPaymentUri(uri)).toEqual({ + addressCandidates: [ + uri, + 'bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', + 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' + ], + scheme: 'bitcoin', + evmChainId: undefined + }) + }) + + it('keeps the scheme-prefixed candidate for cashaddr-style addresses', () => { + const result = peekPaymentUri( + 'bitcoincash:qqkv9wr69ry2p9l53lxp635va4h86wv435995w8p2h?amount=1.5' + ) + expect(result.addressCandidates).toContain( + 'bitcoincash:qqkv9wr69ry2p9l53lxp635va4h86wv435995w8p2h' + ) + expect(result.addressCandidates).toContain( + 'qqkv9wr69ry2p9l53lxp635va4h86wv435995w8p2h' + ) + }) + + it('strips the EIP-681 pay- prefix', () => { + const result = peekPaymentUri( + 'ethereum:pay-0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372@1?value=5e17' + ) + expect(result.addressCandidates).toContain( + '0x1f36BF25aE6c07Ae5B6cB6BF6b0b13B1B4d1B372' + ) + expect(result.evmChainId).toEqual(1) + }) + + it('keeps the case of a slashed form address', () => { + // url-parse lowercases a `scheme://` host, which would corrupt base58. + const result = peekPaymentUri( + 'ripple://rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh?amount=20' + ) + expect(result.scheme).toEqual('ripple') + expect(result.addressCandidates).toContain( + 'rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh' + ) + }) +}) + +describe('peekPaymentUri EIP-681 chain id', () => { + it('reports the @chainId suffix without it reaching the address', () => { + const peek = peekPaymentUri( + 'ethereum:0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e@137' + ) + expect(peek.evmChainId).toEqual(137) + expect(peek.scheme).toEqual('ethereum') + expect(peek.addressCandidates).toContain( + '0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e' + ) + }) + + it('reads a hex chain id', () => { + const peek = peekPaymentUri( + 'ethereum:0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e@0x89' + ) + expect(peek.evmChainId).toEqual(137) + }) + + it('leaves evmChainId unset when the URI names no chain', () => { + const peek = peekPaymentUri( + 'ethereum:0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e' + ) + expect(peek.evmChainId).toBeUndefined() + }) + + it('offers no address for a token-transfer code', () => { + // The path holds the token CONTRACT and the payee rides in a parameter, so + // matching a chain on the path would offer to pay a contract. + const peek = peekPaymentUri( + 'ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@8453/transfer?address=0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e&uint256=1e6' + ) + expect(peek.addressCandidates).toEqual([]) + expect(peek.evmChainId).toEqual(8453) + }) +}) + +describe('parseCrossChainPayment', () => { + const makeConfig = ( + parseUri: (uri: string, currencyCode?: string) => Promise + ): EdgeCurrencyConfig => ({ + ...makeFakeCurrencyConfig({ pluginId: 'ripple', currencyCode: 'XRP' }), + parseUri + }) + + it("returns the chain parser's address, native amount and memo", async () => { + const config = makeConfig(async () => ({ + publicAddress: 'rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh', + nativeAmount: '20000000', + uniqueIdentifier: '987654' + })) + expect( + await parseCrossChainPayment( + config, + 'ripple:rEb8TK3g?amount=20&dt=987654' + ) + ).toEqual({ + publicAddress: 'rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh', + nativeAmount: '20000000', + memo: '987654' + }) + }) + + it("asks for the chain's own coin", async () => { + let asked: string | undefined + const config = makeConfig(async (uri, currencyCode) => { + asked = currencyCode + return { publicAddress: uri } + }) + await parseCrossChainPayment(config, ' rEb8TK3g ') + expect(asked).toEqual('XRP') + }) + + it('rejects what the chain parser rejects', async () => { + const config = makeConfig(async () => { + throw new Error('InvalidPublicAddressError') + }) + expect(await parseCrossChainPayment(config, 'nope')).toBeUndefined() + }) + + it('rejects a code for one of the chain tokens', async () => { + // This flow pays out the chain's own coin, so a token code is refused + // rather than paid in the wrong asset. + const config = makeConfig(async () => ({ + publicAddress: '0xF0825Aec2c79189C6bB1FEe9293F9478103c9B9e', + tokenId: 'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + })) + expect(await parseCrossChainPayment(config, 'ethereum:0x')).toBeUndefined() + }) + + it('rejects a result with no address', async () => { + const config = makeConfig(async () => ({})) + expect(await parseCrossChainPayment(config, 'ripple:')).toBeUndefined() + }) +}) diff --git a/src/__tests__/util/stealthSwap.test.ts b/src/__tests__/util/stealthSwap.test.ts new file mode 100644 index 00000000000..bac7f952f22 --- /dev/null +++ b/src/__tests__/util/stealthSwap.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from '@jest/globals' +import type { EdgeAccount } from 'edge-core-js' + +import { + disableAssetsCover, + makeStealthSwapRequestOptions +} from '../../util/stealthSwap' + +// Only `swapConfig`'s key set is read, to find the plugins to switch off: +const fakeAccount = (swapPluginIds: string[]): EdgeAccount => { + const swapConfig: Record = {} + for (const pluginId of swapPluginIds) swapConfig[pluginId] = {} + return { swapConfig } as unknown as EdgeAccount +} + +const account = fakeAccount(['houdini', 'changenow', 'letsexchange', 'unizen']) + +describe('makeStealthSwapRequestOptions', () => { + it('disables every provider except Houdini', () => { + const { disabled } = makeStealthSwapRequestOptions(account) + expect(disabled).toEqual({ + changenow: true, + letsexchange: true, + unizen: true + }) + expect(disabled?.houdini).toBeUndefined() + }) + + it('clears a preferred provider that would fight the restriction', () => { + // A leftover `preferPluginId` cannot override `disabled`, but leaving it + // set makes the request self-contradictory and its intent unreadable. + const options = makeStealthSwapRequestOptions(account, { + preferPluginId: 'changenow', + preferType: 'CEX' + }) + expect(options.preferPluginId).toBeUndefined() + expect(options.preferType).toBeUndefined() + }) + + it('leaves the exchange setting alone by default', () => { + // The Exchange scene keeps honoring the user's provider settings, so a + // stealth swap started there must not force-enable anything. + const options = makeStealthSwapRequestOptions(account) + expect(options.forceEnabled).toBeUndefined() + }) + + it('force-enables Houdini when the caller ignores the provider setting', () => { + // The send scene's path: the swap setting governs which providers the + // aggregator may pick among, so it must not switch off a send feature that + // happens to be powered by one of them. + const options = makeStealthSwapRequestOptions(account, undefined, { + ignoreProviderSetting: true + }) + expect(options.forceEnabled).toEqual({ houdini: true }) + }) + + it('keeps a caller force-enabling other plugins', () => { + const options = makeStealthSwapRequestOptions( + account, + { forceEnabled: { changenow: true } }, + { ignoreProviderSetting: true } + ) + expect(options.forceEnabled).toEqual({ changenow: true, houdini: true }) + }) + + it('preserves unrelated options', () => { + const options = makeStealthSwapRequestOptions(account, { + promoCodes: { houdini: 'edge' }, + slowResponseMs: 1234 + }) + expect(options.promoCodes).toEqual({ houdini: 'edge' }) + expect(options.slowResponseMs).toEqual(1234) + }) + + it('keeps a caller own disabled entries alongside its own', () => { + // `disabled` wins over `forceEnabled` in the core, so a caller that + // disabled Houdini itself still gets no Houdini quote. + const options = makeStealthSwapRequestOptions( + account, + { disabled: { houdini: true } }, + { ignoreProviderSetting: true } + ) + expect(options.disabled?.houdini).toEqual(true) + }) + + it('handles an account with Houdini as its only provider', () => { + const { disabled } = makeStealthSwapRequestOptions(fakeAccount(['houdini'])) + expect(disabled).toEqual({}) + }) +}) + +describe('disableAssetsCover', () => { + const usdt = 'a614f803b6fd780986a42c78ec9c7f77e6ded13c' + + it('matches an entry without a token to the chain coin only', () => { + const disableAssets = [{ pluginId: 'tron', tokenId: undefined }] + expect(disableAssetsCover(disableAssets, 'tron', null)).toBe(true) + expect(disableAssetsCover(disableAssets, 'tron', usdt)).toBe(false) + }) + + it('matches one named token', () => { + const disableAssets = [{ pluginId: 'tron', tokenId: usdt }] + expect(disableAssetsCover(disableAssets, 'tron', usdt)).toBe(true) + expect(disableAssetsCover(disableAssets, 'tron', null)).toBe(false) + }) + + it('matches every token but the coin for allTokens', () => { + const disableAssets = [{ pluginId: 'tron', tokenId: 'allTokens' }] + expect(disableAssetsCover(disableAssets, 'tron', usdt)).toBe(true) + expect(disableAssetsCover(disableAssets, 'tron', null)).toBe(false) + }) + + it('matches the coin and every token for allCoins', () => { + const disableAssets = [{ pluginId: 'tron', tokenId: 'allCoins' }] + expect(disableAssetsCover(disableAssets, 'tron', usdt)).toBe(true) + expect(disableAssetsCover(disableAssets, 'tron', null)).toBe(true) + }) + + it('ignores entries for other chains', () => { + const disableAssets = [{ pluginId: 'ethereum', tokenId: 'allCoins' }] + expect(disableAssetsCover(disableAssets, 'tron', null)).toBe(false) + }) +}) diff --git a/src/__tests__/util/swapErrorDisplay.test.ts b/src/__tests__/util/swapErrorDisplay.test.ts new file mode 100644 index 00000000000..e88d93f4cb3 --- /dev/null +++ b/src/__tests__/util/swapErrorDisplay.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from '@jest/globals' +import { + type EdgeCurrencyWallet, + type EdgeDenomination, + type EdgeSwapInfo, + type EdgeSwapRequest, + type EdgeSwapSendRequest, + 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 send request, which is the shape the send scene builds: no destination + * wallet, the destination chain and address in its place. + */ +const sendSceneRequest: EdgeSwapSendRequest = { + fromWallet: tronWallet, + fromTokenId: null, + toTokenId: null, + nativeAmount: '80000000', + quoteFor: 'from', + toPluginId: 'litecoin', + toAddresses: [ + { + addressType: 'publicAddress', + publicAddress: 'MQMcJhpWHYVeQArcZR3sBgyPZxxRtnH441' + } + ] +} + +/** + * The same request as a plugin holds it once the core resolved it, which is + * what a plugin's `SwapCurrencyError` carries. + */ +const resolvedRequest: EdgeSwapRequest = { + fromWallet: tronWallet, + fromTokenId: null, + toTokenId: null, + nativeAmount: '80000000', + quoteFor: 'from', + toWallet: litecoinWallet +} + +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, resolvedRequest), + '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, resolvedRequest)) + // Without a supplied code the send request still names the destination + // chain, so the message never tells the user TRX cannot reach TRX. + expect(info?.message).toContain('TRX') + expect(info?.message).toContain('litecoin') + }) + + 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, resolvedRequest), + swapRequest: resolvedRequest, + fromDenomination: trxDenomination, + toDenomination: ltcDenomination + }) + expect(info?.message).toContain('LTC') + }) +}) diff --git a/src/actions/CategoriesActions.ts b/src/actions/CategoriesActions.ts index 0ece8f2055e..469aec4feaa 100644 --- a/src/actions/CategoriesActions.ts +++ b/src/actions/CategoriesActions.ts @@ -15,6 +15,8 @@ import { EDGE_CONTENT_SERVER_URI } from '../constants/CdnConstants' import { TX_ACTION_LABEL_MAP } from '../constants/txActionConstants' import { lstrings } from '../locales/strings' import type { ThunkAction } from '../types/reduxTypes' +import type { Theme } from '../types/Theme' +import { getSwapPluginIconUri, hasThemedSwapPluginIcon } from '../util/CdnUris' import { getCurrencyCodeWithAccount } from '../util/CurrencyInfoHelpers' import { cleanFiatCurrencyCode } from '../util/CurrencyWalletHelpers' @@ -450,6 +452,46 @@ export const getTxActionDisplayInfo = ( } break } + case 'swapSend': { + iconPluginId = action.swapInfo.pluginId + switch (assetActionType) { + case 'transferNetworkFee': + case 'swapNetworkFee': { + edgeCategory = { + category: 'expense', + subcategory: lstrings.wc_smartcontract_network_fee + } + break + } + default: { + // A send is titled by the flow the user ran, so the three are + // distinguishable in the list. The private flavors name no + // recipient; the payout address stays on the action for support. + const { fromAsset, toAsset } = action + const sameAsset = + fromAsset.pluginId === toAsset.pluginId && + fromAsset.tokenId === toAsset.tokenId + payeeText = !action.privacy + ? lstrings.transaction_details_swap_and_send + : sameAsset + ? lstrings.transaction_details_stealth_send + : lstrings.transaction_details_stealth_swap_and_send + edgeCategory = { + category: 'exchange', + subcategory: sprintf( + lstrings.transaction_details_swap_to_subcat_1s, + getCurrencyCodeWithAccount( + account, + toAsset.pluginId, + toAsset.tokenId + ) + ) + } + direction = 'send' + } + } + break + } case 'stake': { iconPluginId = action.pluginId switch (assetActionType) { @@ -679,9 +721,14 @@ export const getTxActionDisplayInfo = ( notes } + // A private send exists to keep the recipient off the screen, so its title + // outranks any stored name: a recipient-style name reaching the transaction + // by any route would otherwise display exactly what the flow conceals. + const isPrivateSend = action?.actionType === 'swapSend' && action.privacy + const mergedData: EdgeMetadata = { name: - metadata?.name != null && metadata.name.length > 0 + !isPrivateSend && metadata?.name != null && metadata.name.length > 0 ? metadata.name : savedData.name, category: @@ -705,7 +752,7 @@ export const getTxActionDisplayInfo = ( } } -export const pluginIdIcons: Record = { +const pluginIdIcons: Record = { '0xgasless': EDGE_CONTENT_SERVER_URI + '/0xgasless.png', bitrefill: EDGE_CONTENT_SERVER_URI + '/bitrefill.png', bitsofgold: EDGE_CONTENT_SERVER_URI + '/bits-of-gold-logo.png', @@ -737,3 +784,31 @@ export const pluginIdIcons: Record = { xgram: EDGE_CONTENT_SERVER_URI + '/xgram.png', xrpdex: EDGE_CONTENT_SERVER_URI + '/xrpdex.png' } + +export interface PluginIdIcon { + uri: string + + /** + * True for a full logo with its own outline, which a thumbnail must fit + * whole. False for an image drawn to be cropped to a circle. + */ + fit: boolean +} + +/** + * The provider logo for a transaction. A provider whose logo is a single-color + * mark uses the logo the swap scenes show: it follows the theme, so it stays + * visible on a light background, and it is fitted instead of cropped. Every + * other provider has one static image drawn for the circular thumbnail. + */ +export function getPluginIdIcon( + pluginId: string | undefined, + theme: Theme +): PluginIdIcon | undefined { + if (pluginId == null) return undefined + if (hasThemedSwapPluginIcon(pluginId)) { + return { uri: getSwapPluginIconUri(pluginId, theme), fit: true } + } + const uri = pluginIdIcons[pluginId] + return uri == null ? undefined : { uri, fit: false } +} diff --git a/src/actions/SwapSendWarningActions.tsx b/src/actions/SwapSendWarningActions.tsx new file mode 100644 index 00000000000..641b9a2196f --- /dev/null +++ b/src/actions/SwapSendWarningActions.tsx @@ -0,0 +1,63 @@ +import type { Disklet } from 'disklet' +import * as React from 'react' +import { sprintf } from 'sprintf-js' + +import { ConfirmContinueModal } from '../components/modals/ConfirmContinueModal' +import { Airship } from '../components/services/AirshipInstance' +import { Paragraph, WarningText } from '../components/themed/EdgeText' +import { SWAP_SEND_WARNING } from '../constants/constantSettings' +import { lstrings } from '../locales/strings' +import { config } from '../theme/appConfig' +import { runOnce } from '../util/runOnce' + +/** + * Explain, the first time a send turns into a swap, that the send scene is no + * longer paying the recipient directly: the wallet pays the swap provider, and + * the provider pays the recipient. It also names the two risks of that shape: + * a rare compliance hold, and that a held swap is the provider's to resolve. + * Informative only, it never blocks the quote. Shown once per account, like + * the send scam warning it sits beside. + **/ +export const showSwapSendWarningModal = async ( + disklet: Disklet, + providerName: string +): Promise => { + try { + await disklet.getText(SWAP_SEND_WARNING) + } catch (error: unknown) { + await runOnce('swapSendWarning', async () => { + const routingMessage = sprintf( + lstrings.stealth_swap_send_modal_message_2s, + config.appName, + providerName + ) + const recoveryMessage = sprintf( + lstrings.stealth_swap_send_modal_message_recovery_1s, + config.appName + ) + await Airship.show(bridge => { + const warningMessage = [ + routingMessage, + lstrings.stealth_swap_send_modal_message_timing, + lstrings.stealth_swap_send_modal_message_kyc, + recoveryMessage + ] + .map(message => `• ${message}`) + .join('\n\n') + + return ( + + + {warningMessage} + + + ) + }) + await disklet.setText(SWAP_SEND_WARNING, '') + }) + } +} 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..e7a37552803 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,31 @@ export function SwapDetailsCard(props: Props) { : selectDisplayDenom(state, wallet.currencyConfig, tokenId) ) - // The wallet may have been deleted: + // A send pays out to an address rather than a 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 destinationWalletName = destinationWallet == null ? '' : getWalletName(destinationWallet) + // The payout asset's own currency config. A send has no payout 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 send, + // taking the order id and provider with it. + const { savedAction } = transaction + const payoutSwapAction = + savedAction?.actionType === 'swap' || savedAction?.actionType === 'swapSend' + ? savedAction + : undefined + const payoutConfig = + destinationWallet?.currencyConfig ?? + (payoutSwapAction == null + ? undefined + : account.currencyConfig[payoutSwapAction.toAsset.pluginId]) + const { isEstimate, orderId, @@ -88,7 +110,7 @@ export function SwapDetailsCard(props: Props) { payoutTokenId, plugin, refundAddress - } = upgradeSwapData(wallet, swapData) + } = upgradeSwapData(payoutConfig, swapData) const formattedOrderUri = orderUri == null ? undefined @@ -124,12 +146,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 +162,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 +174,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 +198,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 +208,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 +247,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/AddressModal.tsx b/src/components/modals/AddressModal.tsx index ada750e5b9f..897ca2fef77 100644 --- a/src/components/modals/AddressModal.tsx +++ b/src/components/modals/AddressModal.tsx @@ -520,6 +520,7 @@ export class AddressModalComponent extends React.Component { error={errorLabel} valid={validLabel} showSpinner={showSpinner} + testID="addressModal" /> {!userFioAddressesLoading ? ( = props => { + const { bridge, items, message, searchPlaceholder, selected, title } = props const theme = useTheme() const styles = getStyles(theme) + const handleRowDataFilter = useHandler( + (filterText: string, item: Item): boolean => { + const search = normalizeForSearch(filterText) + return ( + normalizeForSearch(item.name).includes(search) || + (item.text != null && normalizeForSearch(item.text).includes(search)) + ) + } + ) + + // `ListModal` resolves its bridge with the raw search text on submit, which + // would close this modal on a return key press without picking anything. + // There is nothing to submit here: the keyboard still dismisses itself. + const handleSubmitEditing = useHandler((): void => {}) + const renderRow = useHandler((item: Item) => { - const { name, icon, text } = item + const { name, icon, text, value = name } = item - const isSelected = selected === name + const isSelected = selected === value const radio = isSelected ? { icon: 'radio-button-on', color: theme.iconTappable } : { icon: 'radio-button-off', color: theme.iconTappable } @@ -59,8 +92,9 @@ export function RadioListModal(props: Props) { return ( { - bridge.resolve(name) + bridge.resolve(value) }} > @@ -87,9 +121,17 @@ export function RadioListModal(props: Props) { ) diff --git a/src/components/modals/ScanModal.tsx b/src/components/modals/ScanModal.tsx index 3eede46cb45..94e8c5c9dae 100644 --- a/src/components/modals/ScanModal.tsx +++ b/src/components/modals/ScanModal.tsx @@ -256,6 +256,7 @@ export const ScanModal: React.FC = props => { diff --git a/src/components/modals/SwapVerifyTermsModal.tsx b/src/components/modals/SwapVerifyTermsModal.tsx index 0b8e0ccb763..396dba1ece6 100644 --- a/src/components/modals/SwapVerifyTermsModal.tsx +++ b/src/components/modals/SwapVerifyTermsModal.tsx @@ -41,6 +41,16 @@ const pluginData: Record = { privacyUri: 'https://exolix.com/privacy', kycUri: 'https://exolix.com/aml-kyc' }, + houdini: { + // Houdini publishes its terms as a PDF linked from the site footer; the + // rest of its legal copy lives in the docs site. + termsUri: + 'https://cdn.prod.website-files.com/69143df941a2491956546ef7/6a579a7d3db98c279dc6020b_Houdini%20Swap%20-%20Terms%20of%20Service-14673161-v6.pdf', + privacyUri: + 'https://docs.houdiniswap.com/products/swap-transfer/privacy-notice', + kycUri: + 'https://docs.houdiniswap.com/overview/getting-started/privacy-and-compliance' + }, nexchange: { termsUri: 'https://n.exchange/legal/terms' }, diff --git a/src/components/modals/TextInputModal.tsx b/src/components/modals/TextInputModal.tsx index a8660007aee..91ff0cf0a6c 100644 --- a/src/components/modals/TextInputModal.tsx +++ b/src/components/modals/TextInputModal.tsx @@ -131,6 +131,7 @@ export const TextInputModal: React.FC = props => { /> ) : null} = props => { 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..8b3cf9936b3 100644 --- a/src/components/rows/EdgeRow.tsx +++ b/src/components/rows/EdgeRow.tsx @@ -46,6 +46,13 @@ interface Props { maximumHeight?: 'small' | 'medium' | 'large' rightButtonType?: RowActionIcon title?: string + + /** A state node for the title, rendered after it. Supply it wrapped in a + * color component to tint it without tinting the title, and include any + * punctuation you want tinted with it: the row adds only the separating + * space, so parentheses supplied here take the state colour rather than the + * title's. */ + titleState?: React.ReactNode testID?: string onLongPress?: () => Promise | void onPress?: () => Promise | void @@ -54,7 +61,7 @@ interface Props { marginRem?: number[] | number } -export const EdgeRow = (props: Props) => { +export const EdgeRow: React.FC = (props: Props) => { const { body, children, @@ -65,6 +72,7 @@ export const EdgeRow = (props: Props) => { maximumHeight = 'medium', testID, title, + titleState, // Handlers: onLongPress, @@ -122,12 +130,13 @@ export const EdgeRow = (props: Props) => { {title == null ? null : ( {title} + {titleState == null ? null : <> {titleState}} )} - {loading ? ( + {loading === true ? ( = props => { 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) @@ -280,6 +336,61 @@ 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) + // The quote an approval in flight is signing. Retiring that quote must not + // close it under the approval, so the approval closes it once it finishes. + const approvingSwapQuoteRef = React.useRef( + undefined + ) + const closeAfterApprovalRef = React.useRef(false) + // Quote requests are not cancellable, so each one carries a generation and + // only the newest is allowed to write state. Without this a slow response + // for a superseded amount lands last and wins. + const swapQuoteGeneration = React.useRef(0) + // The live quote, for the one read that resumes after an await. The slider + // awaits the PIN check before approving, and every term the quote was priced + // against retires it meanwhile, so the closed-over `swapQuote` can still name + // an order the scene has already dropped. + const swapQuoteRef = React.useRef(undefined) + const countryCode = useSelector(state => state.ui.countryCode) const account = useSelector(state => state.core.account) const exchangeRates = useSelector( @@ -295,6 +406,54 @@ const SendComponent: React.FC = props => { state => state.ui.settings.defaultIsoFiat ) const hasNotifications = useSelector(state => state.ui.notificationHeight > 0) + // The info server's swap kill switch. A send-to-address quote is a swap, so + // a provider or asset switched off there must stop it the same as on the + // Exchange scene: + const swapDisablePlugins = useSelector( + state => state.ui.exchangeInfo.swap.disablePlugins + ) + const swapDisableAssets = useSelector( + state => state.ui.exchangeInfo.swap.disableAssets + ) + + /** + * 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] @@ -339,6 +498,269 @@ 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 + const swapSendActive = swapSendAllowed && (stealth || crossAssetPicked) + /** + * The asset the recipient ends up with, which the "Recipient receives" row + * and its picker both name. A swap-send pays out the destination chain's + * native asset; a plain send delivers the source asset, token and all. + */ + const recipientAsset = getRecipientAsset({ + sourcePluginId: pluginId, + sourceTokenId: tokenId, + destPluginId, + swapSendActive + }) + const destChain = swapSendActive + ? getHoudiniChain(destPluginId, null) + : undefined + const destCurrencyConfig = account.currencyConfig[destPluginId] + const destCurrencyInfo = destCurrencyConfig?.currencyInfo + const destExchangeDenom = + destCurrencyConfig == null + ? undefined + : getExchangeDenom(destCurrencyConfig, null) + // Called unconditionally to keep hook order stable; the value is only read + // once a destination chain is actually selected. Limits are quoted in the + // denomination the user reads elsewhere, not the exchange one. + const destDisplayDenom = useDisplayDenom( + destCurrencyConfig ?? coreWallet.currencyConfig, + null + ) + + /** + * Whether Houdini can route the source asset to ITSELF privately, which is + * what a same-asset Stealth Send asks for. Read off the chain table rather + * than learned from a quote, since it is a property of the asset. + */ + const selfPrivateAvailable = + getHoudiniChain(pluginId, tokenId)?.hasSelfPrivate === true + + /** + * The order size in USD, the unit Houdini states its minimums in. Priced off + * whichever side the user fixed, so it matches the number they typed. + * `undefined` when no rate is known, which stands the floor check down rather + * than blocking a send on a missing rate. + */ + const orderUsdValue = React.useMemo(() => { + const useSendSide = guaranteedSide === 'send' + const nativeAmount = useSendSide + ? spendInfo.spendTargets[0].nativeAmount + : receiveNativeAmount + const multiplier = useSendSide + ? cryptoExchangeDenomination.multiplier + : destExchangeDenom?.multiplier + if ( + nativeAmount == null || + zeroString(nativeAmount) || + multiplier == null + ) { + return undefined + } + const usdValue = convertCurrency( + exchangeRates, + useSendSide ? pluginId : destPluginId, + useSendSide ? tokenId : null, + 'iso:USD', + div(nativeAmount, multiplier, DECIMAL_PRECISION) + ) + return zeroString(usdValue) ? undefined : usdValue + }, [ + cryptoExchangeDenomination.multiplier, + destExchangeDenom?.multiplier, + destPluginId, + exchangeRates, + guaranteedSide, + pluginId, + receiveNativeAmount, + spendInfo, + tokenId + ]) + + /** + * Whether the order clears Houdini's minimum for the route it would take. + * Private routes start at 25 USD and standard ones at 10, so a Stealth Send + * is pre-empted well before a plain Swap & Send is. Both are checked before + * any request goes out: the provider is an aggregator that rate-limits tight + * traffic, so a quote we already know will be refused is not worth sending. + */ + const belowPrivateFloor = + orderUsdValue != null && lt(orderUsdValue, HOUDINI_MIN_USD.private) + const belowStandardFloor = + orderUsdValue != null && lt(orderUsdValue, HOUDINI_MIN_USD.standard) + + /** + * A provider floor, stated in USD, rendered in the user's own display fiat. + * + * The floors are USD because that is the unit Houdini enforces them in, but + * the user reads every other amount on this scene in their display currency, + * so writing the raw figure with a dollar sign is wrong twice for anyone not + * on USD: the wrong symbol, and a number that is not the threshold in their + * currency. Converted through the same rates the rest of the scene uses and + * formatted by the same helper, so it reads like every other fiat figure. + * + * With no rate between USD and the display fiat there is nothing honest to + * convert to, so the USD figure is shown carrying its OWN symbol rather than + * a wrong number wearing the user's. + */ + const formatUsdFloor = (usdFloor: string): string => { + const rate = getFiatRate(exchangeRates, 'iso:USD', defaultIsoFiat) + const isoFiat = rate === 0 ? 'iso:USD' : defaultIsoFiat + const amount = rate === 0 ? usdFloor : mul(usdFloor, String(rate)) + return `${getFiatSymbol(isoFiat)}${formatFiatString({ + fiatAmount: amount + })}` + } + + // The PIN spending limit gates every outbound flow, swap-send included. + // Derived during render rather than written from the makeSpend effect: that + // effect runs AFTER the render that already holds a live quote, so a flag it + // wrote would lag a frame behind an armed slider. Computing it here 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, + toPluginId: destPluginId, + toAddresses: [ + { + addressType: 'publicAddress', + publicAddress: spendInfo.spendTargets[0].publicAddress ?? '' + } + ], + nativeAmount: spendInfo.spendTargets[0].nativeAmount ?? '0', + quoteFor: guaranteedSide === 'send' ? 'from' : 'to' + }, + fromDenomination: cryptoDisplayDenomination, + toDenomination: destDisplayDenom, + toCurrencyCode: destCurrencyInfo?.currencyCode + }) + return info == null ? error : new I18nError(info.title, info.message) + } + const multipleTargets = spendInfo.spendTargets.length > 1 + + // What the provider is known to offer for the current pair. `false` means a + // live quote already came back without that capability this session; + // `undefined` means untested, so the UI assumes available until told + // otherwise. + const routePairKey = `${pluginId}:${String(tokenId)}->${destPluginId}` + // `fixedTo` is a property of the ROUTE, not of the pair. Houdini prices + // exact-out on fixed-rate quotes alone, which its private routing does not + // serve, so a receive-priced failure learned with privacy required says + // nothing about the standard route the same pair takes with Stealth off, and + // vice versa. Sharing one key let a failure on either side refuse the editor + // on the other, for a capability that was never tested there. + const routeCapKeys = { + stealth: routePairKey, + fixedTo: `${routePairKey}:${stealth ? 'private' : 'any'}` + } + const pairCaps = { + stealth: routeCaps[routeCapKeys.stealth]?.stealth, + fixedTo: routeCaps[routeCapKeys.fixedTo]?.fixedTo + } + const markRouteCap = (cap: 'stealth' | 'fixedTo'): void => { + const key = routeCapKeys[cap] + setRouteCaps(caps => ({ + ...caps, + [key]: { ...caps[key], [cap]: false } + })) + } + + /** + * Why the Stealth toggle cannot be armed right now, or `undefined` when it + * can. Ordered most specific first, so the user reads the reason that + * applies to the send in front of them rather than the first one that fires. + */ + const stealthBlockedReason: string | undefined = multipleTargets + ? lstrings.stealth_multi_recipient_unsupported + : sameAsset && !selfPrivateAvailable + ? sprintf(lstrings.stealth_self_private_unsupported_1s, currencyCode) + : belowPrivateFloor + ? sprintf( + lstrings.stealth_below_private_minimum_1s, + formatUsdFloor(HOUDINI_MIN_USD.private) + ) + : pairCaps.stealth === false + ? lstrings.stealth_route_unavailable_info + : undefined + + /** The floor this send must clear for the route it would actually take. */ + const belowActiveFloor = stealth ? belowPrivateFloor : belowStandardFloor + const updatePendingTxState = React.useCallback(async (): Promise => { if (coreWallet == null || !isEvmWallet(coreWallet)) { setHasPendingTx(false) @@ -428,6 +850,23 @@ const SendComponent: React.FC = props => { initialMount.current = false } + /** + * The first time this scene turns into a swap, say so. The recipient is no + * longer paid by this wallet, and the two-transaction shape is the part a + * user cannot infer from the scene. Shown once per account; the swap + * provider names itself so the copy survives a provider change. + */ + const stealthSwapConfig = account.swapConfig[STEALTH_SWAP_PLUGIN_ID] + React.useEffect(() => { + if (!swapSendActive || stealthSwapConfig == null) return + showSwapSendWarningModal( + account.disklet, + stealthSwapConfig.swapInfo.displayName + ).catch((err: unknown) => { + showError(err) + }) + }, [account.disklet, stealthSwapConfig, swapSendActive]) + const pendingInsufficientFees = React.useRef< InsufficientFundsError | undefined >(undefined) @@ -443,19 +882,104 @@ const SendComponent: React.FC = props => { }) } + /** + * Sets the destination tag for a user- or URI-driven change, retiring any + * held quote when the value actually moves. + * + * The tag rides the request's `toMemos` into the quote request, so it is one + * of the terms the order was created against, exactly like the amount and the + * address. Leaving a quote armed across a tag change lets a slide submit the + * memo the order was built with while the screen shows a different one, which + * on a memo-required payout is the difference between the recipient being + * credited and not. + */ + const changeDestinationTag = (tag: string | undefined): void => { + setDestinationTag(previous => { + if (previous !== tag) setSwapQuote(undefined) + return tag + }) + } + const handleChangeAddress = (spendTarget: EdgeSpendTarget) => async (changeAddressResult: ChangeAddressResult): Promise => { - const { addressEntryMethod, parsedUri, fioAddress, alias, resolvedName } = - changeAddressResult + const { + addressEntryMethod, + parsedUri, + fioAddress, + alias, + resolvedName, + crossChainNativeAmount, + crossChainMemo, + detectedDestPluginId + } = changeAddressResult + + // A destination detected from the address itself makes this a cross-asset + // send. `setRecipientPluginId` has not re-rendered yet, so the routing + // below reads the detected chain rather than the stale render-time state. + const uriGuaranteesReceiveSide = + detectedDestPluginId != null || (swapSendActive && !sameAsset) if (parsedUri != null) { + // The recipient is one of the terms a quote was priced against, so a + // new address retires it exactly as a new amount, wallet or toggle + // does. Dropping the quote rather than only re-requesting one matters + // because the slider gates on a quote being PRESENT: leaving the old + // one up keeps the slider armed, and a slide during the re-quote would + // approve an order created for the address the user just replaced. + if (spendTarget.publicAddress !== parsedUri.publicAddress) { + setSwapQuote(undefined) + } + // A scanned code's tag reaches the row here too, not only through the + // detect-and-adopt path: picking the recipient asset BEFORE scanning is + // the ordinary order, and the tag credits the recipient either way. + // + // Gated on the destination needing a memo, exactly as the adopt path + // is. The Destination Tag row only renders for a `memoNeeded` chain, so + // taking a BIP-21 `message` on any other chain would ride a value into + // `toMemos` that the user can neither see nor clear. + // + // A same-chain destination is parsed by the sending wallet instead, + // which reports the tag as `uniqueIdentifier`. The quote reads the tag + // row alone, so leaving that value on the plain-send memo would quote + // an exchange deposit with no tag and pay it uncredited. + const memoDestChain = + detectedDestPluginId == null + ? destChain + : getHoudiniChain(detectedDestPluginId, null) + const uriMemo = crossChainMemo ?? parsedUri.uniqueIdentifier + if ( + memoDestChain?.memoNeeded === true && + uriMemo != null && + uriMemo !== '' + ) { + changeDestinationTag(uriMemo) + } if (parsedUri.metadata != null) { spendInfo.metadata = parsedUri.metadata } spendTarget.uniqueIdentifier = parsedUri?.uniqueIdentifier spendTarget.publicAddress = parsedUri?.publicAddress - spendTarget.nativeAmount = parsedUri?.nativeAmount + + if (uriGuaranteesReceiveSide) { + // A payment URI's amount is what the recipient should receive, so a + // cross-asset send guarantees the destination side and prices the + // send side off the quote. Both a cross-chain and a same-chain URI + // amount arrive in destination-native units. + // + // 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 = + crossChainNativeAmount ?? 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 @@ -495,11 +1019,171 @@ const SendComponent: React.FC = props => { setLastAddressEntryMethod(addressEntryMethod) setMinNativeAmount(parsedUri.minNativeAmount) setExpireDate(parsedUri?.expireDate) + setAddressExpired(false) setSpendInfo({ ...spendInfo, memos }) needsScrollToEnd.current = true } } + /** + * 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, + crossChainNativeAmount?: string, + /** + * A destination memo the new destination arrived with, from a scanned + * URI. Passed in rather than written by the caller beforehand, because + * the reset below clears the tag and would drop it. + */ + crossChainMemo?: string + ): Promise => { + setRecipientPluginId(destPluginId) + // A new destination chain invalidates any tag and quote held for the old + // one, exactly as picking the recipient asset by hand does. A memo that + // came WITH the new destination survives, since it describes this + // destination rather than the one being left: + setDestinationTag(crossChainMemo) + setSwapQuote(undefined) + setReceiveNativeAmount(undefined) + setGuaranteedSide('send') + setFixedToFallback(false) + + await handleChangeAddress(spendTarget)({ + parsedUri: { publicAddress }, + addressEntryMethod, + crossChainNativeAmount, + 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. + * + * The source chain's own coin is skipped only when it IS the source asset. + * A token source pays out that coin like any other chain's, and the + * recipient picker already offers it, so Myself does too. + */ + const selfTransferAssets = React.useMemo(() => { + if (!swapSendAllowed || multipleTargets) return undefined + const assets: EdgeAsset[] = [{ pluginId, tokenId }] + for (const chain of HOUDINI_CHAINS) { + if (chain.pluginId === pluginId && tokenId == null) continue + if (account.currencyConfig[chain.pluginId] == null) continue + assets.push({ pluginId: chain.pluginId, tokenId: null }) + } + return assets + }, [account, multipleTargets, pluginId, swapSendAllowed, tokenId]) + + const handleSelfTransferAsset = + (spendTarget: EdgeSpendTarget) => + async (destPluginId: string, address: string): Promise => { + await adoptCrossChainDestination(spendTarget)( + destPluginId, + address, + 'other' + ) + return 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. + */ + const handleUnparsedAddress = + (spendTarget: EdgeSpendTarget) => + async ( + address: string, + addressEntryMethod: AddressEntryMethod + ): Promise => { + if (!swapSendAllowed || multipleTargets) return false + + const candidates = detectHoudiniChains(address, { + sourcePluginId: pluginId, + sourceTokenId: tokenId, + isSupported: id => account.currencyConfig[id] != null + }) + if (candidates.length === 0) return false + + // An address format shared by several chains (any EVM `0x…`) cannot be + // resolved from the address alone, and guessing would send the funds to + // the wrong network, so the user names the network. + let chain = candidates[0] + if (candidates.length > 1) { + const items = candidates.map(candidate => { + const { currencyCode: chainCode, displayName } = + account.currencyConfig[candidate.pluginId].currencyInfo + return { + name: displayName, + text: chainCode, + value: candidate.pluginId, + icon: ( + + ) + } + }) + const selected = await Airship.show(bridge => ( + + )) + const picked = candidates.find( + candidate => candidate.pluginId === selected + ) + // Dismissing the picker is a deliberate cancel, not a bad address. + if (picked == null) return true + chain = picked + } + + const payment = await parseCrossChainPayment( + account.currencyConfig[chain.pluginId], + address + ) + if (payment == null) return false + const { publicAddress, nativeAmount, memo } = payment + + // A scanned exchange deposit code carries the tag that credits the + // recipient. Adopting the address and dropping the tag pays the exchange + // with nothing to attribute it to, which is a loss the user cannot see. + const crossChainMemo = + chain.memoNeeded && memo != null && memo !== '' ? memo : undefined + + await adoptCrossChainDestination(spendTarget)( + chain.pluginId, + publicAddress, + addressEntryMethod, + nativeAmount, + crossChainMemo + ) + return true + } + const handleAddressAmountPress = (index: number) => (): void => { // This is deleting the combo address/amount tile. If this happens, remove the // lastAddressEntryMethod so we don't auto launch the camera again. @@ -545,10 +1229,31 @@ const SendComponent: React.FC = props => { spendTarget.publicAddress = undefined spendTarget.nativeAmount = undefined spendTarget.memo = spendTarget.uniqueIdentifier = undefined - // Keep the locked-state card if a broadcast has been attempted: - if (!broadcastAttemptedRef.current) setError(undefined) + // Keep the locked-state card if a broadcast has been attempted. Otherwise + // clear through the owners, not `setError` directly: a bare clear leaves + // `swapErrorShown` believing a swap error is still on screen, and the next + // plain-send failure then cannot retract itself. + // + // The expiry flag is lowered BEFORE the retraction, not after: the + // retraction reads that flag and declines while it is raised, so clearing + // in the other order leaves the expiry card on screen over an address the + // user just removed, with the slider re-enabled beneath it. + if (!broadcastAttemptedRef.current) { + clearSwapError() + setAddressExpired(false) + clearPlainSendError() + } setExpireDate(undefined) setPinValue(undefined) + setFixedToFallback(false) + // Clearing the address ends the swap-send: leaving the destination chain, + // tag, receive amount or standing quote behind means the next address + // entered gets quoted against the previous recipient's state. + setSwapQuote(undefined) + setReceiveNativeAmount(undefined) + setGuaranteedSide('send') + setRecipientPluginId(undefined) + setDestinationTag(undefined) setSpendInfo({ ...spendInfo }) // This is deleting the amount tile. If this happens, remove the // lastAddressEntryMethod so we don't auto launch the camera again. @@ -578,6 +1283,43 @@ 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; parse it with the destination chain's own plugin: + const parseCrossChainAddress = + swapSendActive && destPluginId !== pluginId + ? async (text: string): Promise => { + if (destChain == null) return + const uri = peekPaymentUri(text) + // What the code says about its own chain wins over the fact that + // the address happens to validate here. Refusing hands the input + // to `onUnparsedAddress`, which adopts the chain the code names; + // accepting would pay whichever chain was already picked and read + // the URI's amount in that chain's asset. + // + // The chain id is checked first and on its own: every EVM network + // writes `ethereum:`, so on that family the scheme agrees with a + // picked Polygon destination while the id is the only thing that + // disagrees. + if ( + uri.evmChainId != null && + uri.evmChainId !== destChain.evmChainId + ) { + return + } + if ( + uri.scheme != null && + uri.evmChainId == null && + !schemeNamesChain(uri.scheme, destChain) + ) { + return + } + return await parseCrossChainPayment( + account.currencyConfig[destChain.pluginId], + text + ) + } + : undefined + return ( = props => { isCameraOpen={doOpenCamera} recipientName={recipientName} recipientNameService={recipientNameService} + parseCrossChainAddress={parseCrossChainAddress} + onUnparsedAddress={handleUnparsedAddress(spendTarget)} + selfTransfer={ + selfTransferAssets == null + ? undefined + : { + allowedAssets: selfTransferAssets, + onPickCrossAsset: handleSelfTransferAsset(spendTarget) + } + } navigation={navigation as NavigationBase} /> ) @@ -674,6 +1426,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 = @@ -728,12 +1482,60 @@ 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, and that holds + // between two wallets on the same asset. 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. + // A locked-state card stays, as it does on an address reset. + if (!broadcastAttemptedRef.current) { + clearSwapError() + setAddressExpired(false) + clearPlainSendError() + } + // The recipients go with them whenever the new wallet could not pay + // them: a foreign-chain destination adopted for a swap-send, or a + // different asset. Clearing the destination CHAIN while leaving such + // an address behind drops the scene back into plain-send mode still + // displaying an address the new source wallet cannot pay, one slide + // from a send that can only fail. A plain switch between two wallets + // on the SAME asset is the case that must NOT clear: that address is + // still payable, and wiping it only makes the user type it again. + // Written once, as the whole spend: a second `setSpendInfo` here + // would close over the pre-reset value and put the old targets back. + if (assetChanged || recipientPluginId != null) { + setSpendInfo({ + tokenId: assetChanged ? result.tokenId : tokenId, + spendTargets: [{}], + memos: [] + }) + } } }) .catch((error: unknown) => { @@ -761,7 +1563,524 @@ 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) + // Arming Stealth on a plain send turns it into a swap-send to the same + // address, and the swap quote reads its tag from the tag row alone. A tag + // the plain send already holds, scanned or typed, moves into that row, or + // a memo-required deposit would be paid with none. + if (!swapSendActive && (destinationTag == null || destinationTag === '')) { + const plainMemo = spendInfo.memos?.[0]?.value + if ( + getHoudiniChain(destPluginId, null)?.memoNeeded === true && + plainMemo != null && + plainMemo !== '' + ) { + setDestinationTag(plainMemo) + } + } + setStealth(value => !value) + setPinValue(undefined) + }) + + const handlePickRecipientAsset = useHandler((): void => { + if (multipleTargets) return + // Rows are keyed on the ASSET, never on its label: several chains share a + // currency code (ETH on Base / Arbitrum / Ethereum) and a token can share + // both name and code with a chain (the POL ERC-20 and Polygon), so a + // label-keyed list marks the wrong rows selected and resolves either tap + // to the same destination. + const keyToRecipientPluginId = new Map() + const items = getRecipientAssetChoices({ + sourcePluginId: pluginId, + sourceTokenId: tokenId, + stealthActive: swapSendAllowed && stealth, + servedPluginIds: HOUDINI_CHAINS.filter( + chain => account.currencyConfig[chain.pluginId] != null + ).map(chain => chain.pluginId) + }).flatMap(choice => { + const described = describeAsset(account, choice.asset) + if (described == null) return [] + const value = recipientAssetKey(choice.asset) + keyToRecipientPluginId.set(value, choice.recipientPluginId) + return [ + { + value, + name: described.displayName, + text: described.currencyCode, + icon: ( + + ) + } + ] + }) + + Airship.show(bridge => ( + + )) + .then(selected => { + if (selected == null || !keyToRecipientPluginId.has(selected)) return + // Re-picking the asset already shown is a no-op even when its row maps + // to a different `recipientPluginId`: a chain adopted before Stealth + // was armed names the same payout as the first row, and switching + // between the two would wipe the address for no visible change. + if (selected === recipientAssetKey(recipientAsset)) return + const nextPluginId = keyToRecipientPluginId.get(selected) + if (nextPluginId === recipientPluginId) return + // A new destination chain invalidates the entered address and tag, so + // clear the whole recipient first. The reset drops the destination + // chain too, which is why the new one is applied AFTER it: setting it + // first would leave the reset's undefined as the last write. + handleResetSendTransaction(spendInfo.spendTargets[0])() + setRecipientPluginId(nextPluginId) + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + // Swap-send amounts are entered through the standard crypto/fiat flip + // input. The modal resolves its final amounts on close; an untouched or + // zero amount is a dismissal, matching the old text-modal semantics, so + // quotes still fire on commit rather than per keystroke. Max is hidden + // because max spend is not offered in swap-send mode. + // + // Both sides open on fiat, which is what the Exchange scene's amount entry + // and the plain send's already do. It is also the denomination the decision + // is made in here: the provider states its floors in USD, and the two sides + // of a cross-asset send have no common crypto unit to compare in. + const handleEditYouSend = useHandler((): void => { + Airship.show(bridge => ( + + )) + .then(({ nativeAmount }) => { + if (zeroString(nativeAmount)) return + spendInfo.spendTargets[0].nativeAmount = nativeAmount + // The standing quote priced the previous amount, so it is dead the + // moment a new one commits. Drop it here rather than leaving it up + // until the refetch lands, which would keep the slider armed against + // an amount the user just replaced. + setSwapQuote(undefined) + setGuaranteedSide('send') + setFixedToFallback(false) + setSpendInfo({ ...spendInfo }) + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + // The destination is an address, not a wallet, so the flip input borrows + // the user's own wallet on the destination chain for denominations and + // rates. Without one, a plain text modal is the fallback. + const destFlipWallet = Object.values(currencyWallets).find( + wallet => wallet.currencyInfo.pluginId === destPluginId + ) + + const handleEditRecipientGets = useHandler((): void => { + if (destExchangeDenom == null) return + // The pair is known to have no receive-priced route, so an exact receive + // amount cannot be honored. Explain rather than opening an editor whose + // value would immediately bounce back to the send side. + if (pairCaps.fixedTo === false) { + showToast(lstrings.stealth_fixed_to_unavailable_toast) + return + } + if (destFlipWallet != null) { + Airship.show(bridge => ( + + )) + .then(({ nativeAmount }) => { + if (zeroString(nativeAmount)) return + setReceiveNativeAmount(nativeAmount) + setSwapQuote(undefined) + setGuaranteedSide('receive') + setFixedToFallback(false) + }) + .catch((error: unknown) => { + showError(error) + }) + return + } + const startAmount = + receiveNativeAmount == null || zeroString(receiveNativeAmount) + ? '' + : div( + receiveNativeAmount, + destExchangeDenom.multiplier, + DECIMAL_PRECISION + ) + Airship.show(bridge => ( + + )) + .then(amount => { + if (amount == null || amount === '') return + setReceiveNativeAmount(mul(amount, destExchangeDenom.multiplier)) + setSwapQuote(undefined) + setGuaranteedSide('receive') + setFixedToFallback(false) + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + const handleEditDestinationTag = useHandler((): void => { + Airship.show(bridge => ( + + )) + .then(tag => { + if (tag == null) return + changeDestinationTag(tag === '' ? undefined : tag.trim()) + }) + .catch((error: unknown) => { + showError(error) + }) + }) + + const handleSwapQuoteExpired = useHandler((): void => { + // Drop the quote, do not just ask for a new one. Bumping the nonce alone + // left the expired quote in state until the effect got around to running, + // and the slider gates on `swapQuote != null`, so there was a window where + // a slide would approve an order the provider had already retired. + setSwapQuote(undefined) + setSwapQuoteNonce(nonce => nonce + 1) + }) + + /** + * One side of the linked flip inputs. The edited side is the guaranteed + * amount; the other tracks the live quote as an estimate. The state word + * rides in the row's own header, tinted, so the amount below it reads as a + * single uninterrupted line. + */ + const renderSwapAmountRow = ( + title: string, + displayAmount: string, + displayCode: string, + isGuaranteed: boolean, + onPress: () => void, + fiat: SwapRowFiat | undefined + ): React.ReactElement => ( + {`(${lstrings.stealth_guaranteed})`} + ) : ( + {`(${lstrings.stealth_estimated})`} + ) + } + onPress={onPress} + > + + {`${isGuaranteed ? '' : '~ '}${displayAmount} ${displayCode}`} + {fiat == null ? null : ( + <> + {' ('} + + ) + + )} + + + ) + + /** + * What one side of the swap needs for its inline fiat value, or `undefined` + * when there is no amount to convert. `FiatText` owns the formatting, so the + * parenthesised `1.23 LTC ($45.67)` shape matches the rest of the app. + */ + const swapRowFiat = ( + nativeAmount: string | undefined, + rowCurrencyConfig: EdgeCurrencyConfig | undefined, + rowTokenId: EdgeTokenId + ): SwapRowFiat | undefined => { + if ( + nativeAmount == null || + zeroString(nativeAmount) || + rowCurrencyConfig == null + ) { + return undefined + } + return { + nativeAmount, + tokenId: rowTokenId, + currencyConfig: rowCurrencyConfig + } + } + + const renderYouSendRow = (): React.ReactElement => { + const nativeAmount = spendInfo.spendTargets[0].nativeAmount + const displayAmount = zeroString(nativeAmount) + ? '0' + : div( + nativeAmount ?? '0', + cryptoDisplayDenomination.multiplier, + DECIMAL_PRECISION + ) + return renderSwapAmountRow( + lstrings.stealth_you_send, + displayAmount, + currencyCode, + guaranteedSide === 'send', + handleEditYouSend, + swapRowFiat(nativeAmount, coreWallet.currencyConfig, tokenId) + ) + } + + const renderRecipientGetsRow = (): React.ReactElement | null => { + if (destExchangeDenom == null) return null + const displayAmount = + receiveNativeAmount == null || zeroString(receiveNativeAmount) + ? '0' + : div( + receiveNativeAmount, + destExchangeDenom.multiplier, + DECIMAL_PRECISION + ) + return renderSwapAmountRow( + lstrings.stealth_recipient_gets, + displayAmount, + destCurrencyInfo?.currencyCode ?? '', + guaranteedSide === 'receive', + handleEditRecipientGets, + swapRowFiat(receiveNativeAmount, destCurrencyConfig, null) + ) + } + + const renderRecipientReceives = (): React.ReactElement | null => { + if (!swapSendAllowed) return null + const described = describeAsset(account, recipientAsset) + const recipientCurrencyCode = described?.currencyCode ?? currencyCode + const recipientDisplayName = described?.displayName ?? recipientCurrencyCode + return ( + + + + {`${recipientDisplayName} (${recipientCurrencyCode})`} + + + ) + } + + const renderDestinationTagRow = (): React.ReactElement | null => { + if (destChain?.memoNeeded !== true) return null + return ( + + {destinationTag ?? ''} + + ) + } + + const renderSwapQuoteRow = (): React.ReactElement | null => { + if (spendInfo.spendTargets[0].publicAddress == null) return null + if (fetchingSwapQuote) { + return ( + + + {lstrings.stealth_getting_quote} + + + + ) + } + if (swapQuote == null) return null + + // Rate in exchange (standard) units, plus the provider that quoted and + // the shared price-delta indicator: + const fromExchangeAmount = div( + swapQuote.fromNativeAmount, + cryptoExchangeDenomination.multiplier, + DECIMAL_PRECISION + ) + const toExchangeAmount = + destExchangeDenom == null + ? '0' + : div( + swapQuote.toNativeAmount, + destExchangeDenom.multiplier, + DECIMAL_PRECISION + ) + const rate = zeroString(fromExchangeAmount) + ? '0' + : div(toExchangeAmount, fromExchangeAmount, 8) + const providerName = + account.swapConfig[swapQuote.pluginId]?.swapInfo.displayName ?? + swapQuote.pluginId + const priceImpact = calculateQuotePriceImpact( + swapQuote, + exchangeRates, + defaultIsoFiat + ) + + return ( + <> + + + + {`1 ${currencyCode} = ${rate} ${ + destCurrencyInfo?.currencyCode ?? '' + }`} + + + {providerName} + + + {swapQuote.expirationDate == null ? null : ( + + )} + + ) + } + + const renderSwapFeeRow = (): React.ReactElement | null => { + if (swapQuote == null) return null + const { networkFee } = swapQuote + const feeDenom = getExchangeDenom( + coreWallet.currencyConfig, + networkFee.tokenId + ) + const feeDisplayAmount = div( + networkFee.nativeAmount, + feeDenom.multiplier, + DECIMAL_PRECISION + ) + return ( + + {`${feeDisplayAmount} ${feeDenom.name}`} + + ) + } + + const renderStealthToggle = (): React.ReactElement | null => { + if (!swapSendAllowed) return null + return ( + + + + {stealthBlockedReason != null && !stealth ? ( + + ) : stealth ? ( + + ) : null} + + + ) + } + + /** + * With multiple recipients, show the aggregate on one row instead of making + * the reviewer sum the per-recipient amounts. + */ + const renderMultiRecipientTotal = (): React.ReactElement | null => { + if (!multipleTargets) return null + const totalNativeAmount = spendInfo.spendTargets.reduce( + (prev, target) => add(target.nativeAmount ?? '0', prev), + '0' + ) + const totalDisplayAmount = div( + totalNativeAmount, + cryptoDisplayDenomination.multiplier, + DECIMAL_PRECISION + ) + return ( + + {`${totalDisplayAmount} ${currencyCode}`} + + ) + } + const renderAddAddress = (): React.ReactElement | null => { + // Stealth and cross-asset sends support exactly one recipient: + if (swapSendActive) return null const { pluginId } = coreWallet.currencyInfo const maxSpendTargets = getSpecialCurrencyInfo(pluginId)?.maxSpendTargets ?? 1 @@ -808,7 +2127,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, @@ -831,14 +2156,18 @@ const SendComponent: React.FC = props => { ) } + // The scene's warning area shows one card at a time: a shown error + // supersedes the swap-send warnings below it. + const showErrorCard = + error != null && asMaybeNoAmountSpecifiedError(error) == null + const renderError = (): React.ReactElement | null => { - if (error != null && asMaybeNoAmountSpecifiedError(error) == null) { - return - } + if (showErrorCard) return return null } const renderFees = (): React.ReactElement | null => { + if (swapSendActive) return null if ( spendInfo.spendTargets[0].publicAddress != null && spendInfo.spendTargets[0].nativeAmount != null @@ -991,6 +2320,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] @@ -1222,6 +2554,65 @@ const SendComponent: React.FC = props => { ) } + /** + * A send routed through a swap does not reach the recipient in this + * transaction: the provider pays them in a second one, once the deposit + * confirms. That wait is the part the scene does not otherwise show, so it + * sits with the other warning cards for as long as the send stays a swap, + * giving way to the more specific fallback warning (which repeats it) or an + * error. + */ + const swapSendWarningBody = `${ + stealth + ? lstrings.stealth_swap_send_warning_body_private + : lstrings.stealth_swap_send_warning_body + } ${lstrings.transaction_may_take_longer}` + + const renderSwapSendWarning = (): React.ReactElement | null => { + if (!swapSendActive || fixedToFallback || showErrorCard) return null + return ( + + + + ) + } + + /** + * A fixed receive amount (typed, or carried by a scanned payment URI) had + * to fall back to a guaranteed SEND amount because the provider offers no + * receive-priced route for this pair. It takes the swap warning's place, + * so it carries that warning's text too, and clears as soon as the user + * edits an amount. + */ + const renderFixedToFallbackWarning = (): React.ReactElement | null => { + if (!fixedToFallback || !swapSendActive || showErrorCard) return null + return ( + + + + ) + } + const renderNymWarning = (): React.ReactElement | null => { if (!isNymActive || !processingAmountChanged) return null @@ -1319,10 +2710,11 @@ const SendComponent: React.FC = props => { // Every exit below has to re-arm the slider. SafeSlider holds its spinner // and stays disabled until it is reset, so returning without one strands // the user on a spinner that never clears. - if (edgeTransaction == null) { - resetSlider() - return - } + // + // The PIN spending limit gates BOTH submit paths, so it is checked + // before either one: the swap-send branch below returns early, and a + // swap-send has no `edgeTransaction`, so its null check sits below that + // branch too. if (pinSpendingLimitsEnabled && spendingLimitExceeded) { let isAuthorized = false try { @@ -1340,6 +2732,103 @@ const SendComponent: React.FC = props => { } } + // A send-to-address swap submits by approving the live quote. The + // slider is intentionally not reset on success, so a second slide + // cannot fire while the scene transitions to the success scene. + if (swapSendActive) { + // A quote can be retired mid-slide, since the PIN check above awaits + // and every term the quote was priced against retires it. The slider + // latches its spinner until something resets it, so a slide that + // arrives with no quote has to hand it back rather than just return. + // An approval already in flight owns the slider and resets it itself. + // The ref is what says whether the quote is still live: the closed-over + // value is the one this slide started with, so approving it would sign + // an order the scene retired while the PIN check was in flight. + const liveQuote = swapQuoteRef.current + if (liveQuote == null) { + resetSlider() + return + } + if (approvingSwapQuoteRef.current != null) return + approvingSwapQuoteRef.current = liveQuote + isSendingRef.current = true + // `approve` signs and broadcasts inside the plugin, so the scene + // cannot see the boundary the plain-send path locks at. Treat the + // whole call as at or past it: a failure may still have put the + // deposit on the network, and re-arming the slider would let a second + // slide pay the order again. + broadcastAttemptedRef.current = true + try { + const result = await liveQuote.approve() + playSendSound().catch((error: unknown) => { + console.log(error) // Fail quietly + }) + // Delay navigation until gesture interactions finish to prevent + // possible crashes, the same as the plain-send path below. The + // slider stays latched either way, so the extra frame cannot let a + // second slide through. + InteractionManager.runAfterInteractions(() => { + navigation.replace('swapSuccess', { + edgeTransaction: result.transaction, + walletId: coreWallet.id + }) + }) + } catch (err: unknown) { + console.log(err) + const errorCasted = + err instanceof Error ? err : new Error(String(err)) + if (asMaybePendingFundsError(err) != null) { + // The wallet refused the deposit before submitting it, the same + // refusal the plain-send path re-arms on, so nothing was sent: + broadcastAttemptedRef.current = false + setSwapError( + new I18nError( + lstrings.transaction_failure, + errorCasted.message !== '' + ? errorCasted.message + : lstrings.send_funds_not_spendable_error_message + ) + ) + } else { + logActivity( + `Error during swap-send approve (${liveQuote.pluginId}): ${String( + err + )}` + ) + // A rejection the engine names outright keeps its specific copy. + // Anything else gets the same "status unknown" card as a failed + // plain-send broadcast. + setSwapError( + DETERMINISTIC_REJECTIONS.has(errorCasted.name) + ? describeSwapError(err) + : new I18nError( + lstrings.send_broadcast_failure_title, + sprintf( + lstrings.send_broadcast_failure_message_s, + errorCasted.message + ) + ) + ) + needsScrollToEnd.current = true + } + } finally { + approvingSwapQuoteRef.current = undefined + isSendingRef.current = false + // Only the pre-submit refusal above re-arms the slider. Success + // leaves it latched while the scene navigates away. + if (broadcastAttemptedRef.current) { + setBroadcastAttempted(true) + } else { + resetSlider() + } + if (closeAfterApprovalRef.current) { + closeAfterApprovalRef.current = false + closeSwapQuote(liveQuote) + } + } + return + } + // EVM chains broadcast zero-amount transactions, which still spend gas. // Other chains disable the slider for zero amounts (unless allowZeroTx), // so this confirmation only surfaces for EVM coin and token sends. @@ -1366,6 +2855,11 @@ const SendComponent: React.FC = props => { } } + if (edgeTransaction == null) { + resetSlider() + return + } + try { if (beforeTransaction != null) await beforeTransaction() } catch (e: unknown) { @@ -1459,6 +2953,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 === '') ) { @@ -1723,6 +3222,9 @@ const SendComponent: React.FC = props => { } }) useUnmount(() => { + // Retire any quote request still in flight, so its answer is closed + // rather than stored on a scene that is gone: + swapQuoteGeneration.current++ if (onBack != null) onBack() }) @@ -1739,12 +3241,28 @@ const SendComponent: React.FC = props => { setProcessingAmountChanged(false) return } + // 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 @@ -1760,30 +3278,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) { @@ -1830,7 +3324,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) @@ -1918,15 +3414,360 @@ 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 + + // Frozen after an approve attempt, as the plain-send calculation is: a + // re-quote would either arm a fresh order or retract the "status + // unknown" card that explains the locked slider. + if (broadcastAttemptedRef.current) { + setFetchingSwapQuote(false) + return + } + + 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 + } + + const disabledAsset = disableAssetsCover( + swapDisableAssets.source, + pluginId, + tokenId + ) + ? { currencyCode, displayName: coreWallet.currencyInfo.displayName } + : disableAssetsCover(swapDisableAssets.destination, destPluginId, null) + ? destCurrencyInfo + : undefined + if (disabledAsset != null) { + setSwapQuote(undefined) + setFetchingSwapQuote(false) + setSwapError( + new I18nError( + lstrings.exchange_generic_error_title, + sprintf( + lstrings.swap_token_no_enabled_exchanges_2s, + disabledAsset.currencyCode, + disabledAsset.displayName + ) + ) + ) + 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, + toPluginId: destPluginId, + toAddresses: [ + { addressType: 'publicAddress', publicAddress: toAddress } + ], + toMemos, + nativeAmount: quoteNativeAmount, + quoteFor: guaranteedSide === 'send' ? 'from' : 'to', + privacy: stealth ? 'required' : undefined + }, + makeStealthSwapRequestOptions( + account, + { disabled: swapDisablePlugins }, + { ignoreProviderSetting: true } + ) + ) + const [quote, ...unusedQuotes] = quotes + + // The core keeps each swap-to-address quote's destination wallet + // alive until the quote is closed, so every quote the scene will not + // hold is closed here: the runners-up, and all of them when a newer + // request has superseded this one. + if (generation !== swapQuoteGeneration.current) { + for (const unused of quotes) closeSwapQuote(unused) + return + } + for (const unused of unusedQuotes) closeSwapQuote(unused) + 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, + swapDisablePlugins, + swapDisableAssets, + swapQuoteNonce + ], + 'SendComponent:swapQuote' + ) + + // Mirror the quote into its ref, so a read that resumes after an await sees + // the retirement rather than the value its render closed over. A quote the + // scene retires, or still holds when it unmounts, is closed here, which is + // what releases its destination wallet in the core. + React.useEffect(() => { + swapQuoteRef.current = swapQuote + if (swapQuote == null) return + return () => { + if (approvingSwapQuoteRef.current === swapQuote) { + closeAfterApprovalRef.current = true + return + } + closeSwapQuote(swapQuote) + } + }, [swapQuote]) + + // Retry ONCE when the rate the fixed-to fallback was missing finally loads. + // The fallback runs inside the quote effect, which cannot depend on + // `exchangeRates` without re-quoting on every rate tick, so a failure that + // happened before rates loaded would otherwise sit on a hard error until the + // user edited a field. Keyed on the rates becoming usable rather than on the + // rates object changing, so a later tick cannot trigger a second request. + React.useEffect(() => { + if (!rateStarvedFallback) return + const destRate = getExchangeRate( + exchangeRates, + destPluginId, + null, + defaultIsoFiat + ) + const srcRate = getExchangeRate( + exchangeRates, + pluginId, + tokenId, + defaultIsoFiat + ) + if (destRate <= 0 || srcRate <= 0) return + setRateStarvedFallback(false) + setSwapQuoteNonce(nonce => nonce + 1) + }, [ + defaultIsoFiat, + destPluginId, + exchangeRates, + pluginId, + rateStarvedFallback, + tokenId + ]) + const showSlider = spendInfo.spendTargets[0].publicAddress != null let disableSlider = false let disabledText: string | undefined - if ( + if (swapSendActive) { + // A send-to-address swap submits its live quote: + disableSlider = swapQuote == null || fetchingSwapQuote + // Same PIN gate the plain-send branch below applies: without this the + // swap-send slider stayed live and never prompted for the PIN. + if ( + !disableSlider && + pinSpendingLimitsEnabled && + spendingLimitExceeded && + (pinValue?.length ?? 0) < PIN_MAX_LENGTH + ) { + disableSlider = true + disabledText = lstrings.spending_limits_enter_pin + } + } else if ( edgeTransaction == null || processingAmountChanged || (zeroString(spendInfo.spendTargets[0].nativeAmount) && @@ -1946,6 +3787,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' @@ -2014,19 +3861,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()} @@ -2038,6 +3899,8 @@ const SendComponent: React.FC = props => { {renderScamWarning()} {renderPendingTransactionWarning()} + {renderSwapSendWarning()} + {renderFixedToFallbackWarning()} {renderNymWarning()} {renderError()} {sliderTopNode} @@ -2052,6 +3915,11 @@ const SendComponent: React.FC = props => { ? lstrings.send_confirmation_slider_locked : undefined } + confirmText={ + swapSendActive && stealth + ? lstrings.stealth_slide_send + : undefined + } onSlidingComplete={handleSliderComplete} disabled={disableSlider} /> @@ -2081,6 +3949,23 @@ const getStyles = cacheStyles((theme: Theme) => ({ calcFeeView: { flexDirection: 'row' }, + swapAmountRow: { + alignItems: 'flex-start' + }, + swapAmountText: { + fontSize: theme.rem(1) + }, + swapAssetRow: { + flexDirection: 'row', + alignItems: 'center', + // Match the visual title-to-body gap of a text row: an icon fills its + // box, so it lacks the font line-box whitespace a text body carries. + marginTop: theme.rem(0.375) + }, + providerHint: { + fontSize: theme.rem(0.75), + color: theme.secondaryText + }, calcFeeSpinner: { marginLeft: theme.rem(1) }, @@ -2097,3 +3982,33 @@ const getStyles = cacheStyles((theme: Theme) => ({ height: 0 } })) + +/** + * The display name and currency code for an asset, or `undefined` when the + * account has no plugin or token for it. + */ +/** + * Closes a swap quote the scene no longer holds. A failure only means the + * quote was already gone, which leaves the user nothing to act on. + */ +function closeSwapQuote(quote: EdgeSwapQuote): void { + quote.close().catch((error: unknown) => { + console.log(error) + }) +} + +function describeAsset( + account: EdgeAccount, + asset: EdgeAsset +): { currencyCode: string; displayName: string } | undefined { + const currencyConfig = account.currencyConfig[asset.pluginId] + if (currencyConfig == null) return undefined + if (asset.tokenId == null) { + const { currencyCode, displayName } = currencyConfig.currencyInfo + return { currencyCode, displayName } + } + const token = currencyConfig.allTokens[asset.tokenId] + if (token == null) return undefined + const { currencyCode, displayName } = token + return { currencyCode, displayName } +} diff --git a/src/components/scenes/SwapConfirmationScene.tsx b/src/components/scenes/SwapConfirmationScene.tsx index aa92c69df09..24139f86642 100644 --- a/src/components/scenes/SwapConfirmationScene.tsx +++ b/src/components/scenes/SwapConfirmationScene.tsx @@ -1,7 +1,8 @@ import { useIsFocused } from '@react-navigation/native' -import { add, div, gt, gte, lte, sub, toFixed } from 'biggystring' +import { add, div, gt, gte, toFixed } from 'biggystring' import type { EdgeSwapQuote, + EdgeSwapRequest, EdgeSwapRequestOptions, EdgeSwapResult } from 'edge-core-js' @@ -10,7 +11,6 @@ import { SectionList, type ViewStyle } from 'react-native' import { sprintf } from 'sprintf-js' import { updateSwapCount } from '../../actions/RequestReviewActions' -import { useSwapRequestOptions } from '../../hooks/swap/useSwapRequestOptions' import { useHandler } from '../../hooks/useHandler' import { useMount } from '../../hooks/useMount' import { useRowLayout } from '../../hooks/useRowLayout' @@ -56,15 +56,27 @@ 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 + + /** + * The request and options the quotes were fetched with. An expiry re-quote + * reuses them verbatim, so a Stealth Swap keeps its privacy demand and its + * provider restriction. `quote.request` cannot stand in: it is the plugin's + * copy, with `quoteFor: 'max'` already resolved to a fixed amount, and the + * options never ride on a quote at all. + */ + swapRequest: EdgeSwapRequest + swapRequestOptions: EdgeSwapRequestOptions } interface Props extends SwapTabSceneProps<'swapConfirmation'> {} @@ -76,7 +88,9 @@ interface Section { export const SwapConfirmationScene: React.FC = (props: Props) => { const { route, navigation } = props - const { quotes, onApprove } = route.params + const { quotes, onApprove, swapRequest, swapRequestOptions } = route.params + // A Stealth Swap's provider is fixed, so its powered-by card is not tappable: + const isStealth = swapRequest.privacy === 'required' const dispatch = useDispatch() const theme = useTheme() @@ -98,8 +112,8 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { ) const [pending, setPending] = useState(false) - - const swapRequestOptions = useSwapRequestOptions() + /** The quote's timer ran out; nothing on screen may be approved any more. */ + const [expired, setExpired] = useState(false) const isFocused = useIsFocused() @@ -116,44 +130,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 @@ -199,7 +180,7 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { const requote = useHandler(() => { navigation.replace('swapProcessing', { - swapRequest: selectedQuote.request, + swapRequest, swapRequestOptions, onCancel: () => { navigation.navigate('swapTab', { screen: 'swapCreate' }) @@ -208,7 +189,9 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { navigation.replace('swapConfirmation', { selectedQuote: quotes[0], quotes, - onApprove + onApprove, + swapRequest, + swapRequestOptions }) } }) @@ -217,6 +200,11 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { const handleExchangeTimerExpired = useHandler(() => { // An approval in flight owns the quote until it finishes: if (!isFocused || pending) 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 @@ -521,7 +509,7 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { {selectedQuote.isEstimate && !showPriceImpact ? ( @@ -551,7 +539,7 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { {renderTimer()} diff --git a/src/components/scenes/SwapCreateScene.tsx b/src/components/scenes/SwapCreateScene.tsx index 22372de2aed..13eef928462 100644 --- a/src/components/scenes/SwapCreateScene.tsx +++ b/src/components/scenes/SwapCreateScene.tsx @@ -26,11 +26,14 @@ 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 { 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, @@ -49,8 +52,10 @@ import { } from '../modals/WalletListModal' import { Airship, showToast, showWarning } from '../services/AirshipInstance' import { useTheme } from '../services/ThemeContext' +import { SettingsSwitchRow } from '../settings/SettingsSwitchRow' import { UnscaledText } from '../text/UnscaledText' import { LineTextDivider } from '../themed/LineTextDivider' +import { StealthInfoText } from '../themed/StealthInfoText' import { SwapInput, type SwapInputCardAmounts, @@ -69,12 +74,6 @@ export interface SwapCreateParams { errorDisplayInfo?: SwapErrorDisplayInfo } -export interface SwapErrorDisplayInfo { - message: string - title: string - error: unknown -} - interface Props extends SwapTabSceneProps<'swapCreate'> {} export const SwapCreateScene: React.FC = props => { @@ -100,6 +99,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) @@ -271,10 +274,19 @@ 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. + const quoteRequest: EdgeSwapRequest = stealth + ? { ...swapRequest, privacy: 'required' } + : swapRequest + const quoteRequestOptions = stealth + ? makeStealthSwapRequestOptions(account, swapRequestOptions) + : swapRequestOptions navigation.navigate('swapProcessing', { - swapRequest, - swapRequestOptions, + swapRequest: quoteRequest, + swapRequestOptions: quoteRequestOptions, onCancel: () => { navigation.goBack() }, @@ -282,7 +294,9 @@ export const SwapCreateScene: React.FC = props => { navigation.replace('swapConfirmation', { selectedQuote: quotes[0], quotes, - onApprove: resetState + onApprove: resetState, + swapRequest: quoteRequest, + swapRequestOptions: quoteRequestOptions }) } }) @@ -446,6 +460,10 @@ export const SwapCreateScene: React.FC = props => { Keyboard.dismiss() }) + const handleToggleStealth = useHandler(() => { + setStealth(value => !value) + }) + const handleFromAmountChange = useHandler((amounts: SwapInputCardAmounts) => { navigation.setParams({ // Update the error state: @@ -614,6 +632,23 @@ export const SwapCreateScene: React.FC = props => { /> )} + {fromWallet != null && toWallet != null ? ( + + + + {stealth ? ( + + ) : null} + + + ) : null} {renderAlert()} {isNextHidden || isKeyboardOpen ? null : ( diff --git a/src/components/scenes/SwapProcessingScene.tsx b/src/components/scenes/SwapProcessingScene.tsx index 3305f8a2458..7262c4ef016 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,13 +14,12 @@ 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 @@ -59,6 +52,13 @@ export const SwapProcessingScene: React.FC = (props: Props) => { 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) } @@ -219,163 +219,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..5beca1a344d 100644 --- a/src/components/scenes/TransactionDetailsScene.tsx +++ b/src/components/scenes/TransactionDetailsScene.tsx @@ -15,8 +15,8 @@ import { sprintf } from 'sprintf-js' import { formatCategory, + getPluginIdIcon, getTxActionDisplayInfo, - pluginIdIcons, splitCategory } from '../../actions/CategoriesActions' import { playSendSound } from '../../actions/SoundActions' @@ -101,8 +101,21 @@ export const TransactionDetailsComponent: React.FC = props => { const swapData = convertActionToSwapData(account, transaction) ?? transaction.swapData - const thumbnailPath = - useContactThumbnail(mergedData.name) ?? pluginIdIcons[iconPluginId ?? ''] + // A private send must not reveal its recipient anywhere in the UI. The + // payout address stays on the action for support to trace the order. The + // plugin writes this action with the transaction, so a token send's + // parent network-fee row carries the same answer. + const isPrivateSend = action?.actionType === 'swapSend' && action.privacy + + // A send spends to the provider's deposit address; the pasted recipient + // never reaches `spendTargets` at all. Titling that row "Recipient + // Addresses" therefore names the wrong party. + const isSwapSend = action?.actionType === 'swapSend' + + const contactThumbnail = useContactThumbnail(mergedData.name) + const pluginIdIcon = getPluginIdIcon(iconPluginId, theme) + const thumbnailPath = contactThumbnail ?? pluginIdIcon?.uri + const fitThumbnail = contactThumbnail == null && pluginIdIcon?.fit === true // Check if this is a gift card transaction const giftCardAction = @@ -519,7 +532,13 @@ export const TransactionDetailsComponent: React.FC = props => { rightButtonType="editable" icon={ hasThumbnail ? ( - + ) : ( = props => { swapData={swapData} transaction={transaction} wallet={wallet} + hidePayoutAddress={isPrivateSend} /> )} @@ -662,7 +682,11 @@ export const TransactionDetailsComponent: React.FC = props => { )} @@ -712,6 +736,11 @@ const getStyles = cacheStyles((theme: Theme) => ({ borderRadius: theme.rem(1), marginRight: theme.rem(0.5) }, + tileLogo: { + width: theme.rem(2), + height: theme.rem(2), + marginRight: theme.rem(0.5) + }, tileTextPriceChangeUp: { color: theme.positiveText }, @@ -740,7 +769,7 @@ const convertActionToSwapData = ( return } - if (action.actionType !== 'swap') { + if (action.actionType !== 'swap' && action.actionType !== 'swapSend') { return } @@ -751,9 +780,11 @@ const convertActionToSwapData = ( isEstimate, toAsset, payoutAddress, - payoutWalletId, refundAddress } = action + // A send pays out to an address, not a wallet, so its id matches none: + const payoutWalletId = + action.actionType === 'swap' ? action.payoutWalletId : '' const payoutCurrencyCode = getCurrencyCodeWithAccount( account, 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..9fcb9fee1e4 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' @@ -188,17 +188,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 +231,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..696074f4a14 --- /dev/null +++ b/src/components/themed/PriceImpactText.tsx @@ -0,0 +1,110 @@ +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 + + 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/StealthInfoText.tsx b/src/components/themed/StealthInfoText.tsx new file mode 100644 index 00000000000..a2b8fdb6806 --- /dev/null +++ b/src/components/themed/StealthInfoText.tsx @@ -0,0 +1,69 @@ +import * as React from 'react' +import { View } from 'react-native' + +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import { STEALTH_LEARN_MORE_URI } from '../../util/stealthSwap' +import { openBrowserUri } from '../../util/WebUtils' +import { showError } from '../services/AirshipInstance' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { EdgeText } from './EdgeText' + +interface Props { + /** What the line says: the explanation, or why the toggle cannot be armed. */ + message: string + /** + * Whether to offer the "Learn more" link. A blocked-reason line does not: + * the article explains the feature, not why this particular send is + * ineligible. + */ + showLearnMore?: boolean +} + +/** + * The explanatory line under a Stealth toggle. + * + * Both toggles render it, the send scene's and the swap amount-entry scene's, + * with the same styles and the same "Learn more" target, so it lives in one + * place rather than twice. Only the message differs. + */ +export const StealthInfoText: React.FC = props => { + const { message, showLearnMore = false } = props + const theme = useTheme() + const styles = getStyles(theme) + + const handleLearnMore = useHandler((): void => { + openBrowserUri(STEALTH_LEARN_MORE_URI).catch((error: unknown) => { + showError(error) + }) + }) + + return ( + + + {message} + {showLearnMore ? ' ' : null} + {showLearnMore ? ( + + {lstrings.stealth_learn_more} + + ) : null} + + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + container: { + paddingHorizontal: theme.rem(1), + paddingBottom: theme.rem(0.75) + }, + text: { + color: theme.secondaryText, + fontSize: theme.rem(0.75) + }, + link: { + color: theme.textLink, + fontSize: theme.rem(0.75) + } +})) diff --git a/src/components/themed/TransactionListRow.tsx b/src/components/themed/TransactionListRow.tsx index 9b4a9ca7678..d85a9a5812f 100644 --- a/src/components/themed/TransactionListRow.tsx +++ b/src/components/themed/TransactionListRow.tsx @@ -14,8 +14,8 @@ import { sprintf } from 'sprintf-js' import { formatCategory, + getPluginIdIcon, getTxActionDisplayInfo, - pluginIdIcons, splitCategory } from '../../actions/CategoriesActions' import { getFiatSymbol } from '../../constants/WalletAndCurrencyConstants' @@ -180,8 +180,10 @@ const TransactionViewInner: React.FC = props => { } // Icon & Thumbnail - const thumbnailPath = - useContactThumbnail(name) ?? pluginIdIcons[iconPluginId ?? ''] + const contactThumbnail = useContactThumbnail(name) + const pluginIdIcon = getPluginIdIcon(iconPluginId, theme) + const thumbnailPath = contactThumbnail ?? pluginIdIcon?.uri + const fitThumbnail = contactThumbnail == null && pluginIdIcon?.fit === true if (thumbnailPath != null) { arrowIconSize = theme.rem(1) arrowContainerStyle.push(styles.arrowIconOverlayContainer) @@ -209,7 +211,11 @@ const TransactionViewInner: React.FC = props => { const icon = thumbnailPath != null ? ( - + {arrowIcon} ) : ( @@ -262,7 +268,12 @@ const TransactionViewInner: React.FC = props => { // HACK: Handle 100% of the margins because of SceneHeader usage on this scene return isCard === true ? ( - + <> @@ -297,6 +308,7 @@ const TransactionViewInner: React.FC = props => { ) : ( ({ height: '100%', borderRadius: theme.rem(1) }, + logoImage: { + position: 'absolute', + width: '100%', + height: '100%' + }, arrowIconOverlayContainer: { position: 'absolute', justifyContent: 'center', diff --git a/src/components/themed/WalletList.tsx b/src/components/themed/WalletList.tsx index 37185d120ee..35b9658c9bc 100644 --- a/src/components/themed/WalletList.tsx +++ b/src/components/themed/WalletList.tsx @@ -33,6 +33,15 @@ interface Props { excludeWalletIds?: string[] filterActivation?: boolean + /** + * Opt-in grouping: assets matching this filter render first, under + * `pinnedTitle`, and everything else follows under `otherTitle`. Callers that + * omit it keep the default recent/all ordering untouched. + */ + pinnedAssets?: EdgeAsset[] + pinnedTitle?: string + otherTitle?: string + // Visuals: searchText: string showCreateWallet?: boolean @@ -58,6 +67,9 @@ export const WalletList: React.FC = (props: Props) => { excludeAssets, excludeWalletIds, filterActivation, + pinnedAssets, + pinnedTitle, + otherTitle, // Visuals: searchText, @@ -208,6 +220,35 @@ export const WalletList: React.FC = (props: Props) => { // Show the create-wallet list, filtered by the search term: walletItems.push(...createWalletList) + // Opt-in pinned grouping wins over the recent/all split: a caller that + // asks for it wants its own assets first, not the most-recently-used ones. + // Searching stays flat, as it does for every other caller. + if (pinnedAssets != null && searchText.length === 0) { + const pinned: Array = [] + const rest: Array = [] + for (const item of walletItems) { + const isPinned = + item.type === 'asset' && + checkAssetFilter( + { + pluginId: item.wallet.currencyInfo.pluginId, + tokenId: item.tokenId + }, + pinnedAssets, + undefined + ) + if (isPinned) pinned.push(item) + else rest.push(item) + } + if (pinned.length === 0 || rest.length === 0) return walletItems + return [ + ...(pinnedTitle == null ? [] : [pinnedTitle]), + ...pinned, + ...(otherTitle == null ? [] : [otherTitle]), + ...rest + ] + } + // Show a flat list if we are searching, or have no recent wallets: if (searchText.length > 0 || recentWalletList.length === 0) { return walletItems @@ -238,7 +279,10 @@ export const WalletList: React.FC = (props: Props) => { }, [ createWalletList, filteredWalletList, + otherTitle, parentWalletSection, + pinnedAssets, + pinnedTitle, recentWalletList, searchText ]) @@ -258,6 +302,9 @@ export const WalletList: React.FC = (props: Props) => { token={token} tokenId={tokenId} wallet={wallet} + // This list only ever renders inside the picker modal, which + // floats over a scene whose rows carry the same names. + testIdPrefix="walletPickerRow" onPress={handlePress} /> ) diff --git a/src/components/themed/WalletListCurrencyRow.tsx b/src/components/themed/WalletListCurrencyRow.tsx index 938396143f2..83f534e0394 100644 --- a/src/components/themed/WalletListCurrencyRow.tsx +++ b/src/components/themed/WalletListCurrencyRow.tsx @@ -31,6 +31,15 @@ interface Props { tokenId: EdgeTokenId wallet: EdgeCurrencyWallet + /** + * Namespace for the row's `testID`, so a walk can name the row it means on + * the surface it means. This component renders BOTH the wallet-list scene + * and the wallet-picker modal, and the modal sits over the scene, so a + * shared namespace would make a picker row indistinguishable from the row + * behind it. + */ + testIdPrefix?: string + // Callbacks: onLongPress?: () => void onPress?: ( @@ -46,6 +55,7 @@ const WalletListCurrencyRowComponent: React.FC = props => { token, tokenId, wallet, + testIdPrefix = 'walletListRow', // Callbacks: onLongPress, @@ -187,7 +197,11 @@ const WalletListCurrencyRowComponent: React.FC = props => { Promise + /** + * 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 } @@ -99,8 +155,11 @@ export const AddressTile2 = React.forwardRef( lockInputs, navigation, onChangeAddress, + onUnparsedAddress, + selfTransfer, recipientAddress, resetSendTransaction, + parseCrossChainAddress, title } = props @@ -156,9 +215,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 +243,33 @@ 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; the consumer parses it on the + // destination chain instead. + if (parseCrossChainAddress != null) { + const payment = await parseCrossChainAddress(address) + if (payment == 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: payment.publicAddress }, + addressEntryMethod, + crossChainNativeAmount: payment.nativeAmount, + crossChainMemo: payment.memo + }) + return + } + setLoading(true) const enteredInput = address.trim() address = enteredInput @@ -382,6 +482,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}` ) @@ -462,29 +571,50 @@ export const AddressTile2 = React.forwardRef( const handleSelfTransfer = useHandler(() => { const { currencyWallets } = account const { pluginId } = coreWallet.currencyInfo + const sourceAsset = { pluginId, tokenId } Airship.show(bridge => ( )) .then(async result => { if (result?.type !== 'wallet') return - const { walletId } = result + const { walletId, tokenId: pickedTokenId } = result const wallet = currencyWallets[walletId] // Prefer segwit address if the selected wallet has one 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. + // So is the source chain's own coin picked for a token source: the + // address is the same chain's, but the payout is a different asset. + const destPluginId = wallet.currencyInfo.pluginId + if ( + selfTransfer != null && + (destPluginId !== pluginId || pickedTokenId !== tokenId) + ) { + await selfTransfer.onPickCrossAsset(destPluginId, address) + return + } await changeAddress(address, 'other') }) .catch((err: unknown) => { diff --git a/src/constants/constantSettings.ts b/src/constants/constantSettings.ts index c999c72dbbe..ff0c4d5853b 100644 --- a/src/constants/constantSettings.ts +++ b/src/constants/constantSettings.ts @@ -2,6 +2,7 @@ export const AAVE_WELCOME = 'aaveWelcome.json' export const FIRST_OPEN = 'firstOpen3.json' export const SCAM_WARNING = 'scamWarning.json' // For warnings on sends export const SCAM_WARNING_2 = 'scamWarning2.json' // For other general dangerous actions +export const SWAP_SEND_WARNING = 'swapSendWarning.json' // For sends routed through a swap export const SETTINGS_PERMISSION_LIMITS = 'SETTINGS_PERMISSION_LIMIT' export const SETTINGS_PERMISSION_QUANTITY = 3 export const LOCAL_EXPERIMENT_CONFIG = 'remoteConfigSticky.json' diff --git a/src/docs/stealth-send-swap.md b/src/docs/stealth-send-swap.md new file mode 100644 index 00000000000..e3e78ad59c0 --- /dev/null +++ b/src/docs/stealth-send-swap.md @@ -0,0 +1,1410 @@ +# 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-10-05 | +| Repos | [edge-react-gui](https://github.com/EdgeApp/edge-react-gui), [edge-core-js](https://github.com/EdgeApp/edge-core-js), [edge-exchange-plugins](https://github.com/EdgeApp/edge-exchange-plugins) | +| Implementation | [edge-react-gui#6066](https://github.com/EdgeApp/edge-react-gui/pull/6066), [edge-core-js#730](https://github.com/EdgeApp/edge-core-js/pull/730), [edge-exchange-plugins#469](https://github.com/EdgeApp/edge-exchange-plugins/pull/469) | +| Supersedes | prototype PRs [#6054](https://github.com/EdgeApp/edge-react-gui/pull/6054), [#6031](https://github.com/EdgeApp/edge-react-gui/pull/6031) (kept open as reference) | +| Related | [Asana task](https://app.asana.com/0/1215088146871429/1216251688512498) | + + + +This document describes what is built on branch `jon/stealth-send-swap` across the three repos above. Direction came from the Asana task and its UI proposal A, plus follow-up operator comments on the task. The code is the source of truth: every code block is quoted from the branch and captioned with a link pinned to the commit it was quoted from. + +## Contents + +1. [Problem](#1-problem) +2. [Prior art](#2-prior-art) +3. [Goals and non-goals](#3-goals-and-non-goals) +4. [Design overview](#4-design-overview) +5. [Detailed design: edge-core-js](#5-detailed-design-edge-core-js) +6. [Detailed design: edge-exchange-plugins](#6-detailed-design-edge-exchange-plugins) +7. [Detailed design: edge-react-gui](#7-detailed-design-edge-react-gui) +8. [The send scene UX, end to end](#8-the-send-scene-ux-end-to-end) +9. [Testing](#9-testing) +10. [Phase history](#10-phase-history) +11. [Decisions](#11-decisions) +12. [Glossary](#12-glossary) +13. [References](#13-references) +14. [Post-implementation retrospective](#14-post-implementation-retrospective) + +## 1. Problem + +Edge can send an asset to an address on its own chain, and it can swap between two wallets the user holds. It cannot do the thing users actually ask for: pay someone whose address is on a different chain, or send without the recipient being able to link the payment back to the sender's wallet. + +Both limits show up in ordinary payment flows: + +- **Cross-asset send.** A user holding ETH who owes someone 0.25 LTC has to swap ETH to LTC into their own wallet, then send. Two operations, two fees, and the swap leg needs a Litecoin wallet they may not want. +- **Privacy.** Every ordinary send writes a direct sender-to-recipient edge on chain. The recipient, and anyone reading the chain, can walk back to the sender's wallet and its balance history. + +A swap provider that pays out to an arbitrary address solves both, because the provider address sits between sender and recipient. Edge's swap stack could not express that: `EdgeSwapRequest` required a `toWallet`, an `EdgeCurrencyWallet` the user owns. + +## 2. Prior art + +**Prototype PRs [#6054](https://github.com/EdgeApp/edge-react-gui/pull/6054) and [#6031](https://github.com/EdgeApp/edge-react-gui/pull/6031)** proved the flow but are not shippable. They add a parallel `HoudiniSendScene` reached by rerouting the wallet Send button from `TransactionListTop`, hardcode four destination chains with hand-written `memoNeeded` flags, recreate the price-delta UI that `SwapConfirmationScene` already has, and fake the destination wallet in GUI code. The fork means every other send entry point in the app keeps the old behavior. + +**A GUI-built fake destination wallet** was tried first and does not work. The object has to cross the [yaob](#yaob) bridge into the core to reach the swap plugin, and a plain JavaScript object's function properties do not survive that wire format, so any plugin call on it fails. This is the finding that moved the synthetic wallet into the core, recorded in [Decision: build the synthetic destination wallet in the core](#build-the-synthetic-destination-wallet-in-the-core). + +**Existing swap-provider address entry** (the "send to address" some providers expose) is not reusable either: it lives inside the swap scenes and assumes the user is trading between their own assets, so it does not give the send scene a destination. + +## 3. Goals and non-goals + +Goals: + +- Send from any wallet to an address on any chain the provider serves, from the ordinary send scene. +- Offer a privacy-routed send (Stealth Send) and a privacy-routed swap (Stealth Swap) that pin the request to the privacy provider. +- Accept the recipient address through every entry path a user might reach for: paste, typed entry, and scanned QR. +- Leave every constrained send flow exactly as it is: payment protocol, [FIO](#fio) requests, deep links, and any caller that pre-locks tiles or takes over broadcast. + +Non-goals: + +- **Token destinations.** Only native chain assets are offered as a destination. `getHoudiniChain` returns `undefined` for a non-null `tokenId`, so the recipient picker lists chains only. Token sources are supported and tested. +- **Max spend in swap-send mode.** The plain-mode max flow is untouched; a swap-send max through the plugins' `getMaxSwappable` is deferred, see [Phase history](#10-phase-history). +- **Multiple recipients with swap-send.** Gated off in both directions, see [Multi-recipient gating](#multi-recipient-gating). +- **Telling two [EVM](#evm) chains apart from a bare address.** Physically impossible from the address alone; see [Decision: ask the user when the address format is ambiguous](#ask-the-user-when-the-address-format-is-ambiguous). + +## 4. Design overview + +| Repo | Deliverable | Scope | +|---|---|---| +| [edge-core-js#730](https://github.com/EdgeApp/edge-core-js/pull/730) | `EdgeSwapSendRequest` beside `EdgeSwapRequest`, core-built [synthetic destination wallet](#synthetic-destination-wallet), `EdgeTxActionSwapSend`, `EdgeCurrencyConfig.parseUri` | [Section 5](#5-detailed-design-edge-core-js) | +| [edge-exchange-plugins#469](https://github.com/EdgeApp/edge-exchange-plugins/pull/469) | HoudiniSwap plugin, chain mapping, destination-[memo](#memo) threading, the `swapSend` action | [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 request type. The GUI describes the destination as data (an `EdgeSwapSendRequest` naming a chain and its addresses); 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 to quote one. The plugin that wins does name the result, by writing a `swapSend` action instead of a `swap` action, because it is the one party that knows the destination was an address and whether the route was private. + +```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({ toPluginId, toAddresses, toMemos, privacy }, stealthOptions) + API->>API: resolveSwapRequest: send request becomes toWallet + API->>Synth: makeSyntheticDestinationWallet(currencyConfig, toAddresses, 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 (pays the deposit address, saves a swapSend action) +``` + +## 5. Detailed design: edge-core-js + +### The request contract + +A send to an address is its own request type rather than an optional field on the wallet-to-wallet one. `EdgeSwapRequest` keeps its required `toWallet`, and `EdgeSwapSendRequest` names the destination chain and addresses in its place. Both share `EdgeSwapRequestBase`, which carries the source, the amount, and the route-privacy requirement. + +[`src/types/types.ts`](https://github.com/EdgeApp/edge-core-js/blob/f283aaf6470f3e1fb1e6b7428c918c16177f25a3/src/types/types.ts) +```ts +/** + * A swap between two wallets. This is also the resolved shape swap plugins + * receive: for an `EdgeSwapSendRequest`, `toWallet` is a synthetic destination + * wallet the core builds from the destination address. + */ +export interface EdgeSwapRequest extends EdgeSwapRequestBase { + toWallet: EdgeCurrencyWallet +} + +/** + * A swap that pays out to an address rather than one of the user's wallets. + */ +export interface EdgeSwapSendRequest extends EdgeSwapRequestBase { + toPluginId: string + + /** The destination addresses. The first entry is the payout address. */ + toAddresses: EdgeAddress[] + + /** + * Destination memos (e.g. an XRP destination tag) for memo-required payout + * chains. Swap plugins read them off the synthetic destination wallet's + * `getMemos` (see `EdgeSyntheticDestinationWallet`). + */ + toMemos?: EdgeMemo[] +} +``` + +Two types instead of one type with two optional fields means the compiler, not a runtime check, rules out a request with no destination, and every existing caller of `EdgeSwapRequest` keeps a `toWallet` it can read without a null guard. `toAddresses` is the same `EdgeAddress` list `getAddresses` returns, so a chain with several address formats can pass them all. + +The `getMemos` split matters: a request 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` passes a wallet request through, refuses one that carries both shapes, validates the destination plugin and token, and builds the synthetic wallet from the rest: + +[`src/core/swap/swap-api.ts`](https://github.com/EdgeApp/edge-core-js/blob/f283aaf6470f3e1fb1e6b7428c918c16177f25a3/src/core/swap/swap-api.ts) +```ts + const { toPluginId, toAddresses, toMemos, ...base } = request +``` + +Destructuring the send fields off is what keeps the resolved request re-submittable: `quote.request` rides back to the GUI carrying only `toWallet`, so resubmitting it cannot trip the both-shapes check. + +### 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 `getAddresses` returns the caller's addresses and `getReceiveAddress` returns the first: + +[`src/core/swap/synthetic-wallet.ts`](https://github.com/EdgeApp/edge-core-js/blob/f283aaf6470f3e1fb1e6b7428c918c16177f25a3/src/core/swap/synthetic-wallet.ts) +```ts +export function makeSyntheticDestinationWallet( + currencyConfig: EdgeCurrencyConfig, + toAddresses: EdgeAddress[], + toMemos: EdgeMemo[] = [] +): EdgeCurrencyWallet { +``` + +The id prefix (`synthetic://`) is a public contract: plugins branch on it to skip address-type lookups that make no sense for a pasted address, and to write a `swapSend` action rather than a `swap` action (see [Section 6](#6-detailed-design-edge-exchange-plugins)). + +The wallet is bridgified so plugin calls work unchanged across the core's WebView boundary, and anything bridgified stays in [yaob](#yaob)'s object table until something closes it. One synthetic wallet is built per `fetchSwapQuotes` call and shared by every quote that call returns, and the caller reaches it through `quote.request.toWallet`, so it is released by reference count: closed when the last quote carrying it is closed, and immediately when no quote survives to carry it. Without that, every quote refresh on a swap-to-address screen would leave another wallet in the table for the life of the account. The same reasoning is why `resolveSwapRequest` reuses the account's long-lived `currencyConfig` instead of building one per request. + +### The send action + +A swap that paid out to an address is saved as its own action type, written by the plugin that ran it: + +[`src/types/types.ts`](https://github.com/EdgeApp/edge-core-js/blob/f283aaf6470f3e1fb1e6b7428c918c16177f25a3/src/types/types.ts) +```ts +/** + * A send that settled through a swap provider: the payout went to an address + * the user entered, not to one of their own wallets. + */ +export interface EdgeTxActionSwapSend { + actionType: 'swapSend' + swapInfo: EdgeSwapInfo + orderId?: string + orderUri?: string + isEstimate: boolean + fromAsset: EdgeAssetAmount + toAsset: EdgeAssetAmount + + /** The recipient. */ + payoutAddress: string + refundAddress?: string + + /** Routed privately (a Stealth send). */ + privacy: boolean +} +``` + +It has no `payoutWalletId`, because there is no payout wallet. `EdgeTxActionSwap` keeps its required `payoutWalletId`, so no consumer of an ordinary swap learns to handle a missing one. `EdgeTxActionSwapSend` joins the `EdgeTxAction` union, so a plugin writes it through the same `spendInfo.savedAction` a swap action uses. + +### Chain URI parsing without a wallet + +A scanned code for a chain the user holds no wallet on still needs that chain's own URI parser. `EdgeCurrencyConfig.parseUri` takes the same `(uri, currencyCode?)` arguments as `EdgeCurrencyWallet.parseUri` and returns the same result, because both delegate to one helper: + +[`src/core/account/custom-tokens.ts`](https://github.com/EdgeApp/edge-core-js/blob/f283aaf6470f3e1fb1e6b7428c918c16177f25a3/src/core/account/custom-tokens.ts) +```ts +export async function parseCurrencyUri( + ai: ApiInput, + accountId: string, + pluginId: string, + uri: string, + currencyCode?: string +): Promise { + const tools = await getCurrencyTools(ai, pluginId) + const { state } = ai.props + const { allTokens, customTokens } = state.accounts[accountId] + + const parsedUri = await tools.parseUri( + uri, + currencyCode, + makeMetaTokens(customTokens[pluginId]) + ) + + if (parsedUri.tokenId === undefined) { + const { tokenId = null } = upgradeCurrencyCode({ + allTokens: allTokens[pluginId], + currencyInfo: state.plugins.currency[pluginId].currencyInfo, + currencyCode: parsedUri.currencyCode ?? currencyCode + }) + parsedUri.tokenId = tokenId + } + return parsedUri +} +``` + +The parser receives the account's custom tokens, and a result that names its asset only by the deprecated `currencyCode` gains a `tokenId`. A URI therefore parses the same whichever entry point ran: `tokenId` is `null` for the chain's own coin and a resolved id for a token, custom ones included. The gui's cross-chain reader relies on that when it refuses a URI that names a token ([Payment URI amounts](#payment-uri-amounts)). + +`EdgeParsedUri` also gains an optional `addressTypes: string[]`, so a chain with several address formats can say which `getAddresses` labels a parsed address matches. Nothing in this branch writes it yet; it is the slot a multi-format chain fills when a send request has to name its address type. + +### Error reporting + +`SwapCurrencyError` takes the resolved request, which always carries `toWallet`, so its destination pluginId reads straight off the wallet whether that wallet is real or synthetic. + +## 6. Detailed design: edge-exchange-plugins + +### Plugin identity and transport + +[`src/swap/central/houdini.ts`](https://github.com/EdgeApp/edge-exchange-plugins/blob/b7984a63e85f1cb7ea64e833ac9bba06e2698a0b/src/swap/central/houdini.ts) +```ts +export const swapInfo: EdgeSwapInfo = { + pluginId, + isDex: false, + displayName: 'HoudiniSwap', + supportEmail: 'support@houdiniswap.com' +} +``` + +Two transport facts decide whether any call works at all, and neither is obvious: + +- Auth is `Authorization: :` with no `Bearer` prefix. Every endpoint returns 402 without it. +- The partner API is server-to-server and answers browser-origin requests with 403. The core runs plugins inside a WebView, so `io.fetch` carries `Origin` / `Sec-Fetch-*` headers. Every call therefore passes `corsBypass: 'always'`, which routes through the native fetch host-side and matches the contract the API expects. + +### Destination handling + +The plugin reads the destination through the wallet surface, with two branches for the synthetic case: + +[`src/swap/central/houdini.ts`](https://github.com/EdgeApp/edge-exchange-plugins/blob/b7984a63e85f1cb7ea64e833ac9bba06e2698a0b/src/swap/central/houdini.ts) +```ts +async function getDestinationMemos( + toWallet: EdgeCurrencyWallet +): Promise { + const { getMemos } = toWallet as EdgeCurrencyWallet & + SyntheticDestinationMethods + if (getMemos == null) return [] + return await getMemos() +} +``` + +and, in `fetchSwapQuoteInner`: + +[`src/swap/central/houdini.ts`](https://github.com/EdgeApp/edge-exchange-plugins/blob/b7984a63e85f1cb7ea64e833ac9bba06e2698a0b/src/swap/central/houdini.ts) +```ts + // A synthetic (swap-to-address) destination holds exactly one pasted, + // caller-validated address, so a typed-address lookup does not apply. + const isSyntheticDestination = toWallet.id.startsWith( + SYNTHETIC_WALLET_ID_PREFIX + ) +``` + +Memos become `destinationTag` on order creation, which is what [memo](#memo)-required chains (XRP, XLM, Cosmos Hub, Hedera, Thorchain) need to credit the payment. + +### Naming the send + +The plugin writes the transaction's saved action, and it is the one party that holds both facts a send needs recorded: the destination was a synthetic wallet, and the request asked for privacy. So it writes the `swapSend` shape from [The send action](#the-send-action) for a synthetic destination and the ordinary `swap` shape for a real one: + +[`src/swap/central/houdini.ts`](https://github.com/EdgeApp/edge-exchange-plugins/blob/b7984a63e85f1cb7ea64e833ac9bba06e2698a0b/src/swap/central/houdini.ts) +```ts + const swapAction: EdgeTxActionSwapPlugin = isSyntheticDestination + ? { + ...action, + actionType: 'swapSend', + privacy: request.privacy === 'required' + } + : { ...action, actionType: 'swap', payoutWalletId: toWallet.id } +``` + +A token send's parent network-fee row carries the identical `swapSend` shape and `privacy` flag, because `makeSwapPluginQuote` files that row by copying the signed transaction's own saved action under `tokenId: null`. The GUI writes nothing after approval, so there is no second write that could fail and leave the two rows disagreeing. + +### Chain mapping + +`src/mappings/houdini.ts` maps every Edge `EdgeCurrencyPluginId` to a Houdini chain `shortName`, or `null` where Houdini has no compatible chain. [IBC](#ibc)-family chains (coreum, osmosis, axelar) are deliberately `null`: Houdini reports no `memoNeeded` flag and a permissive `^.*$` address validation for them, so their payout semantics are not trustworthy enough to offer. + +The table answers what Houdini calls a chain, not whether Houdini serves it. Those are different questions with different lifetimes: the name is stable, while what is served changes whenever the provider adds or drops a native coin. Whether a chain has a tradable native is discovered at runtime by `resolveTokenId`, which declines with the same `SwapCurrencyError` `checkWhitelistedMainnetCodes` raises, so a mapped-but-unserved name costs one memoized lookup rather than a wrong answer. `celo`, `fantom` and `polkadot` are absent from `GET /tokens?mainnet=true` entirely, and all three 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 lookup takes the query's answer as given. The native query asks for `mainnet=true`, the catalogue's own definition of a chain's coin, and every mapped chain answers it with at most one row, so that row is the native. Testing the row again only adds ways to miss. A coin's address field is `null` on most chains, an empty string on six and a contract-style address on TON, and a row's `chain` is not always the name the query used: a `bitcoincash` query answers with rows on `bch`. The contract path matches on the address, which is what identifies a token, and on nothing else. [Retrospective item 8](#where-this-document-was-wrong-or-silent) records the two chains the earlier checks lost. + +The memoization is what makes this affordable. `resolveTokenId` caches misses as well as hits, so an unserved chain is asked about once per ten-minute window instead of once per quote. The window matters in both directions: without it, a chain Houdini lists later in the session stays refused until the app restarts, which is the failure `.cursor/BUGBOT.md`'s `catalog-cache-expiry` rule exists to prevent for exactly this kind of provider catalog. A lookup the provider FAILED to answer is deliberately not cached at all: a rate limit or a server error says nothing about whether the chain is served, and caching it would turn one bad minute into a chain that stays dead for the rest of the window. + +### Route selection + +Quotes are filtered by route type, and the caller decides which types are acceptable: + +[`src/swap/central/houdini.ts`](https://github.com/EdgeApp/edge-exchange-plugins/blob/b7984a63e85f1cb7ea64e833ac9bba06e2698a0b/src/swap/central/houdini.ts) +```ts + const privateOnly = request.privacy === 'required' + const candidateQuotes = quotes + .filter( + (quote): quote is HoudiniQuote => + quote != null && + (quote.type === 'private' || + (!privateOnly && quote.type === 'standard')) + ) +``` + +A request carrying `privacy: 'required'` takes `private` (multi-exchange) routes only, which is what makes Stealth private. Without it, `standard` routes are acceptable too, ranked below private. The distinction decides what a user can send: Houdini serves no private route under 25 USD but serves standard routes down to 10, so a plain Swap & Send between those two figures is only possible on a standard route, while a Stealth Send at the same amount has nothing to route through. See [Minimum order sizes](#minimum-order-sizes). + +A `standard` route still settles through Houdini, so the recipient never sees the sender's address, but it uses a single exchange leg that can relink the two sides. That is why a privacy request must decline rather than accept one: the caller has no way to inspect which route it got, so a silent downgrade would be undetectable. + +Houdini prices exact-out on fixed-rate quotes alone, which its private routing does not serve, so a privacy request priced by the receive side finds nothing and declines. The send scene answers that by re-pricing from the send side and keeping its privacy, which is the [fixed-to fallback](#availability-fallbacks) it already had. + +This filter is the reason a live availability change on the provider's side can disable forward swap-to-address sends without any code change here; see [Retrospective item 2](#where-this-document-was-wrong-or-silent). + +### Rate limits + +Houdini is an aggregator behind Cloudflare, and tight request loops get blocked in a way that poisons the answers: a 429 arriving where a quote was expected reads exactly like an unavailable pair, and caching that verdict would teach the UI something false. Every call therefore goes through one wrapper that retries a 429 behind the `retryAfter` the API reports, doubling on top of it so a burst does not re-collide the moment the window reopens, and gives up after three attempts with an error that says rate limit rather than unavailable. Since only a plain `Error` comes back, the send scene's `SwapCurrencyError` branch never fires, so no `routeCaps` entry is written and no toggle turns itself off on the strength of a throttled request. + +Two bounds sit on that retry, and they pull in opposite directions. The doubling is capped, but the cap bounds OUR OWN growth only: the window the API asked for always survives it, because retrying inside a window the provider named just draws another 429 and spends the budget for nothing. Against that, a retry is only worth waiting for if what it resends is still alive when the wait ends. Houdini quote ids live about a minute and the exchange budget reports `retryAfter` near sixty seconds, so honoring the window and re-POSTing the same quote id hangs the user for a minute and then fails as an expired quote, blaming the wrong thing. The create-exchange call therefore passes the candidate quote's own expiry into the wrapper, and a wait that would land past it fails immediately as a rate limit. `validUntil` arrives as Unix seconds inside a string, which `new Date` reads as an invalid date, so the parse reads the number first. + +The `max` path is the other place the exchange budget bites. `getMaxSwappable` runs the quote function once to size the spend and the real quote runs it again, so creating an order on the sizing pass spends one of the one-per-minute slots and guarantees the real create is throttled. The sizing pass builds its spend shape from the quote alone, standing in the user's own refund address for the deposit address it does not have. + +Standing in the user's own address is what forces the other two properties of that probe. An [EVM](#evm) engine compares a spend target against its own public key, which IS the address, and rejects the match with `SpendToSelfError`; that error escapes `getMaxSwappable` and fails every max swap from an EVM wallet unless the probe's `spendInfo` sets `skipChecks: true`. And the probe deliberately quotes the full PRE-FEE balance to find the ceiling, so an above-limit balance has to clamp through `getMaxSpendable` rather than throw `SwapAboveLimitError` and abort a max swap that fits once the network fee comes off: the route maximum is enforced on the real quote only. Both are pre-PR checklist items in `docs/CREATING_AN_EXCHANGE_PLUGIN.md` and both are modelled in `src/swap/central/template.ts`. + +### Amount safety + +Three rules govern every amount that crosses the provider boundary, and all three come from the repo's own checklist rather than from anything Houdini-specific. + +Provider amounts arrive as JSON floats, so they reach `biggystring` through `floatToDecimalString`, which expands scientific notation at both ends of the range. Comparison and sorting go through `biggystring` too, not through the floats: `String(smallFloat)` can produce notation that a string comparison misreads, and the rule covers ranking as much as arithmetic. + +Rounding to whole atomic units has a DIRECTION, and it is not cosmetic. A minimum rounds UP, so the floor Edge enforces never lands below the provider's own and a deposit is not rejected on arrival. A maximum, the receive amount, and the deposit amount round DOWN, so none of them is ever larger than what the provider will honor. + +The deposit amount is also a trust boundary. `order.inAmount` comes back from Houdini and becomes a signed spend, so a `from` quote refuses an order asking for more of the source asset than the user requested. On a reverse (`to`) quote the user pinned the receive amount and the send side is the provider's to price. The bound there comes from the route the order was created on: the smaller of the send amount that route quoted (plus 1% for the provider's rounding) and its published from-side ceiling. The quoted amount does the real work, since routes commonly publish a ceiling of `9007199254740991`; a live fixed-rate order restated its quote at +0.012%. + +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. Houdini passes it only for a request carrying `privacy: 'required'`. A same-asset request that did not ask for privacy is the no-op every other plugin rejects, and quoting it would charge exchange fees to return what the user already had. The blocked-token half of the helper applies either way. + +## 7. Detailed design: edge-react-gui + +### Where the feature is allowed to appear + +`SendScene2` gains the feature in place rather than in a parallel scene. The gate is a single predicate: + +[`src/components/scenes/SendScene2.tsx`](https://github.com/EdgeApp/edge-react-gui/blob/6f1714cd2fb4f2def44da1a92e7de545b19e896f/src/components/scenes/SendScene2.tsx) +```ts + const swapSendAllowed = + lockTilesMap.address !== true && + lockTilesMap.amount !== true && + lockTilesMap.wallet !== true && + hiddenFeaturesMap.address !== true && + hiddenFeaturesMap.amount !== true && + fioPendingRequest == null && + onDone == null && + alternateBroadcast == null && + beforeTransaction == null && + initSpendInfo?.spendTargets[0]?.publicAddress == null +``` + +Every constrained caller fails at least one clause, so payment protocol, [FIO](#fio) requests, deep links, and any caller taking over broadcast keep their existing behavior exactly. The last clause is also why deep links do not enter this flow: they pre-fill an address. + +Activation is then: + +[`src/components/scenes/SendScene2.tsx`](https://github.com/EdgeApp/edge-react-gui/blob/6f1714cd2fb4f2def44da1a92e7de545b19e896f/src/components/scenes/SendScene2.tsx) +```ts + const destPluginId = recipientPluginId ?? pluginId + const sameAsset = destPluginId === pluginId && tokenId == null + const crossAssetPicked = recipientPluginId != null && !sameAsset + const crossAsset = !sameAsset + const swapSendActive = swapSendAllowed && (stealth || crossAssetPicked) +``` + +The two cross-asset booleans answer different questions and a token source is where they part. `crossAssetPicked` is what turns a plain send into a swap-send on its own, and it is also the test for whether switching Stealth off would help: without an adopted recipient asset, the toggle is the only thing making this a swap. `crossAsset` labels the flow, and a token send to its own chain pays out that chain's native asset, so it crosses assets even though nobody picked a recipient. Reading one where the other belongs titled such a send "Stealth Send". + +`recipientPluginId` names the source chain only for a token source without Stealth. There the chain's native coin is a payout the first row does not offer (that row keeps the token), so the chain gets its own row, and adopting it is what makes the send a swap. Everywhere else the first row and a source-chain row would name the same payout, and the picker lists it once: two rows that produce one order and recover from a missing route two different ways are not a choice a user can make deliberately. + +### Quote request + +When active, the scene requests a quote instead of building a spend. `makeSpend` is skipped entirely (`if (swapSendActive) { setEdgeTransaction(null); … return }`) because the transaction comes from the quote. + +Stealth restricts the request to the privacy provider through a shared helper, `src/util/stealthSwap.ts`, used by both the send scene and the swap scene: + +[`src/util/stealthSwap.ts`](https://github.com/EdgeApp/edge-react-gui/blob/6f1714cd2fb4f2def44da1a92e7de545b19e896f/src/util/stealthSwap.ts) +```ts +export function makeStealthSwapRequestOptions( + account: EdgeAccount, + opts: EdgeSwapRequestOptions = {}, + flags: StealthSwapFlags = {} +): EdgeSwapRequestOptions { + const disabled: EdgePluginMap = { ...opts.disabled } + for (const swapPluginId of Object.keys(account.swapConfig)) { + if (swapPluginId !== 'houdini') disabled[swapPluginId] = true + } + return { + ...opts, + disabled, + forceEnabled: + flags.ignoreProviderSetting === true + ? { ...opts.forceEnabled, houdini: true } + : opts.forceEnabled, + preferPluginId: undefined, + preferType: undefined + } +} +``` + +Clearing `preferPluginId`/`preferType` matters: a user's saved provider preference would otherwise fight the restriction. `ignoreProviderSetting` is how the send path opts out of the account's exchange settings, per [Provider availability versus exchange settings](#provider-availability-versus-exchange-settings); the Exchange scene leaves it unset. + +Every send-to-address quote goes through these options, stealth toggle on or off: send-to-any is a privacy feature and is Houdini-exclusive by operator direction. Making the restriction conditional on the toggle (`stealth ? ... : undefined`) breaks that guarantee, whatever a stale description elsewhere may say; the reasoning is in [Decision: send-to-any is Houdini-exclusive, toggle or no toggle](#send-to-any-is-houdini-exclusive-toggle-or-no-toggle). + +### Linked amounts + +"You send" and "Recipient gets" are linked through one piece of state, `guaranteedSide`. The edited side is guaranteed and the other tracks the live quote as an estimate. Each row says which it is in its own title rather than under the amount: `You send (Guaranteed)` with the state word in green, `Recipient gets (Estimated)` with it in warning orange, so the amount is the last thing on the row and the pair reads as one line each. The estimated side keeps the `~` prefix on its number. `EdgeRow` renders the word from a `titleState` node, which is how a row tints part of its header without rebuilding the shared header style. Editing "Recipient gets" issues `quoteFor: 'to'`, a reverse quote. + +Both amounts are entered through the standard crypto/fiat flip input (`FlipInputModal2`), committed on close so quotes still fire per commit rather than per keystroke; a zero or untouched amount is a dismissal. Max is hidden because max spend is not offered in swap-send mode. The destination side has no wallet, so its modal borrows the user's own wallet on the destination chain for denominations and rates (with a plain text modal as the fallback when no such wallet exists); the borrowed wallet's balance row reads as that wallet's balance, which is cosmetic noise accepted for reusing the standard modal. + +Both sides open on **fiat**. That is what the Exchange scene's two inputs and the plain send both do, so opening on crypto made this scene the only amount entry in the app that did not; it is also the denomination the decision is actually made in here, since the provider states its floors in USD and a cross-asset pair has no common crypto unit to compare its two sides in. The one entry still denominated in crypto is the no-destination-wallet fallback: a plain text modal has no rate to price against, and the account holding no wallet on the destination chain is exactly the case where no rate is guaranteed to be loaded. `FlipInputModalResult` does not report which side the user finished on, so nothing remembers a per-session preference; the Exchange scene does not either, and one fixed opening denomination beats two divergent memories. + +### Cross-chain address entry + +A destination on another chain cannot go through the source wallet's `parseUri`, so `AddressTile2` takes two hooks. The first, `parseCrossChainAddress`, reads input once the destination chain is known, using that chain's own parser through `EdgeCurrencyConfig.parseUri`: + +[`src/components/tiles/AddressTile2.tsx`](https://github.com/EdgeApp/edge-react-gui/blob/6f1714cd2fb4f2def44da1a92e7de545b19e896f/src/components/tiles/AddressTile2.tsx) +```ts + parseCrossChainAddress?: ( + text: string + ) => Promise +``` + +The second, `onUnparsedAddress`, is the one that makes the feature discoverable: + +[`src/components/tiles/AddressTile2.tsx`](https://github.com/EdgeApp/edge-react-gui/blob/6f1714cd2fb4f2def44da1a92e7de545b19e896f/src/components/tiles/AddressTile2.tsx) +```ts + onUnparsedAddress?: ( + address: string, + addressEntryMethod: AddressEntryMethod + ) => Promise +``` + +It fires when this wallet's chain cannot read the input, immediately before the invalid-address toast. Because it hangs off `changeAddress`, which every entry affordance funnels through, one hook covers Paste, Enter address, and Scan at once. + +`SendScene2`'s handler detects the chain, adopts it as the recipient asset, and applies the address. Chain detection lives in `src/util/houdiniChains.ts`: + +[`src/util/houdiniChains.ts`](https://github.com/EdgeApp/edge-react-gui/blob/6f1714cd2fb4f2def44da1a92e7de545b19e896f/src/util/houdiniChains.ts) +```ts +export function detectHoudiniChains( + text: string, + opts: { + /** The sending wallet's chain. */ + sourcePluginId: string + /** The sending wallet's token, or `null` for the chain's own coin. */ + sourceTokenId: string | null + /** Whether the account has a currency plugin for this chain. */ + isSupported: (pluginId: string) => boolean + } +): HoudiniChain[] +``` + +A URI scheme names its chain outright and wins. A bare address is matched against each served chain's regex, as a prefilter only: whichever chain is picked, its own `parseUri` reads the input before anything is adopted; several chains share a format, so every match is returned and the caller disambiguates. The source chain is dropped from the candidates only when the source IS that chain's coin: from a TOKEN it is a real destination, since USDC on Ethereum paying out native ETH is a cross-asset route no plain send can make, and dropping it left a pasted `0x` address offering every other [EVM](#evm) network but not the one the recipient holds. The chain table entry is: + +[`src/util/houdiniChains.ts`](https://github.com/EdgeApp/edge-react-gui/blob/6f1714cd2fb4f2def44da1a92e7de545b19e896f/src/util/houdiniChains.ts) +```ts +export interface HoudiniChain { + pluginId: string + houdiniShortName: string + memoNeeded: boolean + hasSelfPrivate: boolean + addressValidation: RegExp +} +``` + +`HOUDINI_CHAINS` is a snapshot of Houdini's mainnet native tokens (v2 partner API, re-fetched 2026-09-10) intersected with Edge pluginIds. The table holds 36 chains, 6 of them [memo](#memo)-required (`cosmoshub`, `hedera`, `ripple`, `stellar`, `thorchainrune`, `ton`). Four of the provider's published regexes are corrected in the table; see [Decision: correct the provider's address regexes rather than route around them](#correct-the-providers-address-regexes-rather-than-route-around-them). `hasSelfPrivate` is covered in [Same-asset private capability](#same-asset-private-capability). + +The intersection is by pluginId, and a matching pluginId is not a matching network. Houdini's `telos` is the Telos EVM network (`0x` addresses, chain id 40), while Edge's `telos` plugin is the EOSIO-based native chain, whose accounts are names. No Edge Telos wallet can pay a `0x` deposit address or be paid at one, so the row is left out rather than offering a destination the user's own Telos wallet cannot use. + +Because `setRecipientPluginId` has not re-rendered when the address is applied, the detected chain is threaded through the result object rather than read back from state: + +[`src/components/scenes/SendScene2.tsx`](https://github.com/EdgeApp/edge-react-gui/blob/6f1714cd2fb4f2def44da1a92e7de545b19e896f/src/components/scenes/SendScene2.tsx) +```ts + // A destination detected from the address itself makes this a cross-asset + // send. `setRecipientPluginId` has not re-rendered yet, so the routing + // below reads the detected chain rather than the stale render-time state. + const uriGuaranteesReceiveSide = + detectedDestPluginId != null || (swapSendActive && !sameAsset) +``` + +### Payment URI amounts + +A scanned QR carries a payment URI, not a bare address. Reading one is split in two, in `src/util/paymentUri.ts`. `peekPaymentUri` reads only what the code says about its own chain, the scheme and the [EIP-681](#eip-681) `@chainId`, plus bare-address candidates for detection, and loads no chain's parser: + +[`src/util/paymentUri.ts`](https://github.com/EdgeApp/edge-react-gui/blob/6f1714cd2fb4f2def44da1a92e7de545b19e896f/src/util/paymentUri.ts) +```ts +export interface PaymentUriPeek { + addressCandidates: string[] + scheme?: string + evmChainId?: number +} +``` + +Candidates are returned in priority order (raw trimmed text, scheme-prefixed path, naked path) so [cashaddr](#cashaddr)-style addresses that keep their `prefix:` on chain still match. The chain table's address patterns are a prefilter over those candidates: they pick which chains to offer, and never decide on their own that an address is valid. + +Once a chain is picked, `parseCrossChainPayment` hands the text to that chain's own parser, asking for the chain's own coin: + +[`src/util/paymentUri.ts`](https://github.com/EdgeApp/edge-react-gui/blob/6f1714cd2fb4f2def44da1a92e7de545b19e896f/src/util/paymentUri.ts) +```ts + const parsed = await currencyConfig.parseUri(text.trim(), currencyCode) + const { publicAddress, nativeAmount, tokenId, uniqueIdentifier } = parsed + if (publicAddress == null || publicAddress === '') return + if (tokenId != null) return + return { publicAddress, nativeAmount, memo: uniqueIdentifier } +``` + +The chain's parser covers what a generic reader cannot: its URI dialect, its checksums, its amount units and its memo parameter names. The amount comes back in the chain's native units, so the scene no longer converts a display amount. A code naming one of the chain's tokens is refused, because this flow pays out the chain's own coin. + +A URI amount is what the recipient should **receive**, so for a cross-asset destination it sets the receive side as guaranteed and lets the quote price the send side. Same-asset (stealth) sends keep it on the send side, because the provider offers no receive-priced route when source and destination assets match; guaranteeing the receive side there would make every same-asset payment URI unquotable. + +### Availability fallbacks + +Whether the provider offers a private route, or a receive-priced (fixed) route, is a live property of each pair. The scene learns it from real quote failures. A `SwapCurrencyError` while the receive side is guaranteed flips the guarantee to the send side, seeds the send amount from display exchange rates so the send stays actionable, and raises a warning card that clears on the next amount edit. One while stealth is on for a **same-asset** pair turns the toggle off (toast, persistent info line) and degrades the swap into the plain direct send the toggle had upgraded. A **cross-asset** pair with no route keeps the plain error card: the request is Houdini-only with or without the toggle, so flipping it changes nothing and there is no other provider to degrade into. Learned capabilities are cached per pair in session state (`routeCaps`), so a later attempt to re-arm the toggle or re-fix the receive amount answers with a pre-emptive toast instead of another doomed quote. The full branch structure is the flowchart in [Section 8](#8-the-send-scene-ux-end-to-end). + +Two effects write the scene's single `error` state, the quote effect and the plain-send `makeSpend` effect, and each retracts only its own message when the other takes over. Entering swap-send mode clears a plain-send failure so an insufficient-funds message cannot sit over a valid quote; leaving it clears the swap's failure so a minimum-amount message cannot sit over the plain send the user switched to. Provenance is tracked rather than guessed, because clearing unconditionally in either direction wipes the other effect's answer. + +The toggle is a dependency of the quote effect, so flipping it always invalidates the held quote and re-fetches. It has to be: the request now carries `privacy: 'required'` only when stealth is on, and the floor that applies changes with it, so the two states genuinely ask the provider different questions. An earlier revision left `stealth` out on the grounds that the request did not vary with it, which was true then and is not now; a cross-asset pair would have shown standard-route pricing under a Stealth label. + +### Minimum order sizes + +Houdini enforces its own minimum per route type, in USD. The figures live as named constants (`HOUDINI_MIN_USD` in `src/util/houdiniChains.ts`) with the provider's guidance quoted beside them, rather than as literals at the call sites: + +| Route | Minimum | Used by | +|---|---|---| +| private | 25 USD | every Stealth flow, including same-asset | +| standard | 10 USD | plain Swap & Send | +| [dex](#dex) | 5 USD | assets carrying `hasDex` | + +These were confirmed against the live v2 API before being written down, not taken on the provider's word. Cross-asset TRX to LTC answered `422 Amount is too low, minimum is 10 USD` at 8 USD, returned standard routes only from 12 through 24 USD, and added private routes from 25 up. Same-asset TRX to TRX answered `422 Amount is too low, minimum is 25 USD` below 25 and returned private routes only above it. Both match the stated floors. + +The scene enforces them before any request goes out. Under the private floor the Stealth toggle refuses to arm and explains why; under the applicable floor the quote effect returns an under-minimum error instead of calling the API. Pre-empting matters for more than tidiness: a user thumbing through small amounts would otherwise spend the provider's rate limit on requests whose refusal is already known, and those 429s come back looking like unavailable routes. + +Floors are not the whole story. Individual tokens carry higher server-side minimums that cannot be known upfront (Polygon private is effectively 60 USD against a 25 USD floor). Those arrive as quote errors carrying the real figure, and the error card shows the provider's own message, per [Phase 9](#phase-9-real-failures-in-the-error-ui). Clearing the floor is necessary, never sufficient. + +### Destination assets are route-derived + +Every surface that decides whether an asset can be a send-to-address destination reads the route metadata (`HOUDINI_CHAINS` through `getHoudiniChain`), never a hardcoded asset shape. That covers address detection, the recipient-asset picker, quote gating, and the "Myself" picker. Tokens are absent from all of them for one reason: `getHoudiniChain` returns undefined for a non-null `tokenId`. When the provider serves token destinations, relaxing that one function surfaces them everywhere at once. + +### What the recipient receives + +The "Recipient receives" row and the picker that edits it name one asset, computed once: + +[`src/util/houdiniChains.ts`](https://github.com/EdgeApp/edge-react-gui/blob/6f1714cd2fb4f2def44da1a92e7de545b19e896f/src/util/houdiniChains.ts) +```ts + return swapSendActive + ? { pluginId: destPluginId, tokenId: null } + : { pluginId: sourcePluginId, tokenId: sourceTokenId } +``` + +A swap-send pays out the destination chain's native asset, because the quote asks for `toTokenId: null` and token destinations are not offered; a plain send delivers the source asset verbatim, token included. Deciding that separately in the row and in the picker is what let them disagree: the picker offered a USDT wallet "Tether (USDT)" and the row underneath then read "Ethereum (ETH)". + +Each picker row names what the recipient gets once THAT row is picked, not what the current state pays. The first row clears `recipientPluginId`, which leaves only Stealth to make the send a swap, so it is labelled from Stealth alone. Labelling it from the current `swapSendActive` named ETH on a USDT sender's first row while a chain was adopted, and tapping it reverted to a plain USDT send. Rows are then deduplicated by key, first row winning, which is how a native source keeps one source-chain row and a token source without Stealth gains the native one. Re-picking the asset already shown is a no-op even when its row maps to a different `recipientPluginId`, since a chain adopted before Stealth was armed names the same payout as the first row. + +The picker's rows are keyed on the asset rather than on its label. A display name is not an identity: several chains share a currency code (ETH on Ethereum, Base and Arbitrum), and the POL ERC-20 on Ethereum shares its display name AND its currency code with the Polygon chain, so a name-keyed list marked both rows selected and resolved either tap to the same destination, leaving Polygon unreachable from a POL wallet. `RadioListModal` rows therefore carry an optional `value`, defaulting to the name so existing callers are unchanged, which is also what the row's `testID` follows. The same opt-in shape adds the search box the 36-chain list wants, reusing the filtering `ListModal` already provides. + +The "Myself" picker follows the same rule. It offers the source asset plus every chain the provider pays out to, including the source chain's own coin when the source is a token (a USDT on Tron sender is offered TRX wallets, a different payout on the same chain), 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` and `polkadot` have no mainnet native, so listing them would offer destinations whose every quote throws, and they are not in the table. Verifying that also turned up the empty-string defect described in [Retrospective item 6](#where-this-document-was-wrong-or-silent). The table matches the plugin's mapping: TON is a memo row with the provider's `EQ`/`UQ` pattern, and Monad (chain id 143) and Robinhood Chain (chain id 4663) are EVM rows named by their [EIP-681](#eip-681) ids. + +### 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 35 chains in the table, one (`rsk`, RBTC) is false; the rest are true. + +This is Houdini's dominant flow, around 60% of their traffic, which is why it gets a table lookup instead of a learned failure. Cross-asset private capability is the opposite case and stays quote-reactive: it fluctuates per pair, so it is learned from a real attempt and never cached beyond the session, per [Availability fallbacks](#availability-fallbacks). + +### Provider availability versus exchange settings + +The send-scene stealth and swap-send path **ignores** the global exchange-settings enable flag for HoudiniSwap. The Exchange scene keeps honoring it. That setting governs which providers the swap aggregator is allowed to use, so it is the user's answer about swapping, not about sending: a private send is a send feature that happens to be powered by Houdini, and switching off a swap provider should not silently remove it. + +Mechanically the core skips any plugin whose `swapSettings[pluginId].enabled` is false, so the scene opts out of that check for one request through `EdgeSwapRequestOptions.forceEnabled`, set by `makeStealthSwapRequestOptions` only when the caller passes `ignoreProviderSetting`. An explicit `disabled` entry still wins, so the same helper's provider restriction cannot be defeated by it. + +Ignoring the enable flag does not extend to the info server's swap kill switch. The Exchange scene refuses a pair whose source or destination asset is listed in `disableAssets`, and the send scene applies the same check before it requests a swap-send quote: `disableAssetsCover` matches an entry naming the chain's own coin, one token, `allTokens`, or `allCoins`, and a covered pair shows the Exchange scene's own "no enabled exchanges support" error instead of a quote. The enable flag is the user's preference about swapping; the kill switch is Edge withdrawing an asset from every swap, which binds a send powered by a swap too. + +### 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. A send is a `swapSend` action (see [The send action](#the-send-action)) written by the plugin, and `getTxActionDisplayInfo` titles it from two facts the action carries: `privacy`, and whether `fromAsset` and `toAsset` name the same asset. A transparent send is a Swap & Send whatever its assets, a private same-asset send is a Stealth Send, and a private cross-asset send is a Stealth Swap & Send. + +Hiding the recipient is a display rule, not a storage rule. The action 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, a private send's title outranks any stored metadata name, and the details scene hides the payout address when `privacy` is set. The transaction list's own fallback needs no change, because a swap-send's spend target is the provider's deposit address, never the recipient's. + +That last fact is worth naming on screen rather than leaving implied. The details scene's spend-target row is titled "Recipient Addresses", which on a send-shaped swap names the wrong party: the row holds the provider's deposit address, and the pasted recipient never reaches `spendTargets` at all. On a private send it reads as precisely the disclosure the flow exists to prevent, which is how it was reported. The row is therefore titled from the action: a `swapSend` action means the title is "Exchange Deposit Address", and every other transaction keeps the original wording. The row stays, because the deposit address is the one address that makes a stuck order traceable from the app. Ordinary Exchange-scene swaps carry the same mislabel and are deliberately left alone here, since they are not this branch's flows. + +The privacy rules bind on **every** row a flow produced, not just the one the send scene holds. A token send pays its fee in the chain's own coin, and the plugin files that fee row with the same `swapSend` action (see [Naming the send](#naming-the-send)), so every rule keyed on `privacy` reads the same on both rows. The fee row is the fee and not the send, so it keeps the network-fee title while still obeying the name-suppression rule: `getTxActionDisplayInfo` applies the send titles only when the asset action is not a `*NetworkFee`, and the stored-name override stays bound to `privacy` regardless of row. + +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 has no payout wallet to resolve. 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 that adds `privacy: 'required'` to the quote request and `makeStealthSwapRequestOptions` to its options. The confirmation scene receives that request and those options as route params and re-quotes an expired quote with them verbatim, so the restriction survives re-quotes without the scene rebuilding it from a flag. `quote.request` is not reused for this, because it is the plugin's copy, with a `'max'` quote already resolved to a fixed amount. `PoweredByCard.onPress` became optional so the provider renders as fixed (no chevron, no "tap to change provider") whenever the request carries `privacy: 'required'`. + +A Stealth Swap whose pair has no private route shows the error and leaves the toggle on. The request asked for privacy, and quietly re-quoting a transparent swap is the substitution the plugin is told never to make; the user turns the toggle off if a transparent swap is acceptable. + +### Saying that a swap is running + +A send routed through Houdini looks like a send and behaves like a swap: the wallet pays the provider's deposit address, and the recipient is paid later by a second transaction the provider broadcasts. The table below lists what tells the user so, and each row answers a different question. + +| Surface | Where | Fires when | Persistence | +|---|---|---|---| +| Terms modal | `SwapConfirmationScene`, through `swapVerifyTerms` | the dedicated swap scene confirms a quote Houdini won | the provider's own `agreedToTerms` user setting | +| Swap-send modal | `SendScene2`, through `showSwapSendWarningModal` | the send scene first becomes a swap | `swapSendWarning.json` in the account disklet | +| Warning card | `SendScene2`, in the warning cluster | `swapSendActive`, while no error card or fixed-to fallback card is shown | none, it is scene state | + +The terms modal is the pre-existing centralized-provider acknowledgement, keyed by pluginId in `SwapVerifyTermsModal`'s `pluginData` table. Houdini's entry gives it the same three links every other centralized provider gets. Declining calls `changeEnabled(false)` on the provider, which stops Houdini quoting on the swap scene, Stealth Swap included. The send scene is unaffected: it passes `ignoreProviderSetting`, and the core's `forceEnabled` reaches a provider the user switched off. + +The swap-send modal is the send scene's own, because the send scene never reaches `SwapConfirmationScene` and so never runs `swapVerifyTerms`. It follows the send scam warning beside it: a disklet key, `runOnce` against a double-fire within one app run, and a `ConfirmContinueModal`. The provider names itself off `account.swapConfig[STEALTH_SWAP_PLUGIN_ID].swapInfo.displayName`, so the copy survives a provider change. Its four bullets cover the routing, the wait, a rare AML/KYC hold, and that a held or failed swap is resolved with the provider. It informs and never gates the quote; the card below is the persistent notice and carries no KYC wording. + +[`src/actions/SwapSendWarningActions.tsx`](https://github.com/EdgeApp/edge-react-gui/blob/6f1714cd2fb4f2def44da1a92e7de545b19e896f/src/actions/SwapSendWarningActions.tsx) +```ts +export const showSwapSendWarningModal = async ( + disklet: Disklet, + providerName: string +): Promise => { +``` + +The card is the recurring half. A modal shown once cannot warn the user on their fortieth stealth send, and the wait is a property of every one of them, so `renderSwapSendWarning` sits with the fixed-to fallback and Nym cards and reads off `swapSendActive`. The cluster shows one swap card at a time: an error card (`showErrorCard`, the same test `renderError` uses) hides both swap warnings, and the fixed-to fallback card hides the generic one, since it is the more specific notice and clears on the next amount edit. The user can still send while the fallback card shows, so its body ends with the swap warning's own text: hiding the generic card must not drop the swap-and-delay notice. A user who hits the kill switch's "no enabled exchanges" error sees that error alone, not a warning about a swap that cannot run. Private routing gets its own copy, since the sentence a user needs is about a private swap when Stealth is on. + +### Shared price impact + +The prototype recreated the price-delta UI. It is instead extracted from the swap confirmation scene into `src/components/themed/PriceImpactText.tsx` and reused by both: + +[`src/components/themed/PriceImpactText.tsx`](https://github.com/EdgeApp/edge-react-gui/blob/6f1714cd2fb4f2def44da1a92e7de545b19e896f/src/components/themed/PriceImpactText.tsx) +```ts +export const PRICE_IMPACT_WARNING_THRESHOLD = 0.05 +export function calculateQuotePriceImpact(…) +export const PriceImpactText: React.FC = props => { +``` + +## 8. The send scene UX, end to end + +Every branch a send-to-address user can hit, from address entry to an armed slider. Three rules organize it: every quote is Houdini-only (no other provider is ever consulted), the UI reflects what the provider actually offers (a capability the pair lacks turns its control off, with a toast saying why), and a degraded state is always recoverable where a degradation exists (the fixed-to fallback re-quotes the send side, the same-asset stealth fallback is the plain send, and pre-emptive refusals explain themselves on tap). + +Nodes tagged `[API]` are the only ones that reach the network. Everything else is decided from local state or the chain table, which is the point: the provider rate-limits tight traffic, so a branch that can be settled without asking is settled without asking. + +```mermaid +flowchart TD + A[Address entered by\npaste, type, or scan] --> B{Source wallet\nparses it?} + B -- yes --> C[Same-chain send,\nunchanged behavior] + B -- no --> D{Matches a served\ndestination chain?\nHOUDINI_CHAINS, no request} + D -- none --> E[Invalid address toast] + D -- exactly one --> F[Chain adopted as\nRecipient receives] + D -- several --> G[Network picker modal] + G -- picks --> F + G -- cancels --> H[Entry cancelled] + F --> I{URI carries\nan amount?} + I -- no --> J[User enters amount,\nsend side guaranteed] + I -- yes --> K[Receive side guaranteed,\nfixed to] + K --> L{Houdini offers a\nreceive-priced route?\n[API] GET /tokens, GET /quotes} + L -- yes --> M[Houdini quote arms,\nreceive amount locked\n[API] POST /exchanges] + L -- no --> N[Falls back to fixed from:\ntoast, warning card, send\namount seeded from rates] + N --> O[Card clears when the\nuser edits an amount] + O --> J + J --> T{Clears the floor\nfor this route?\n25 USD private, 10 standard\nlocal, no request} + T -- no --> T2[Under-minimum error card,\nno request sent] + T2 -. user raises amount .-> J + T -- yes --> P{Cross-asset\ndestination?} + P -- yes --> P3{Stealth on?} + P3 -- yes --> U[Houdini-only forward quote,\nprivacy required\n[API] GET /quotes] + P3 -- no --> U2[Houdini-only forward quote,\nstandard routes allowed\n[API] GET /quotes] + U -- route exists --> W[Quote arms:\nSlide to Confirm\n[API] POST /exchanges] + U2 -- route exists --> W + U -- no route --> Z[Error card:\nprovider's own message] + U2 -- no route --> Z + U -- rate limited --> RL[Backoff behind retryAfter,\nthen rate-limit error.\nNo routeCaps entry written] + P -- no --> P2{Stealth on?} + P2 -- no --> V[Plain single-asset send] + P2 -- yes --> Q0{hasSelfPrivate\nfor this asset?\ntable lookup, no request} + Q0 -- no --> S2[Toggle refuses to arm,\ntoast names the asset] + Q0 -- yes --> Q{Private route\nfor this pair?\n[API] GET /quotes} + Q -- yes --> R[Stealth quote arms:\nSlide to send stealthily\n[API] POST /exchanges] + Q -- no --> S[Stealth turns itself off:\ntoast, info line under the\ntoggle, pair remembered] + S --> V + S -. later toggle taps .-> X[Refuses to arm,\npre-emptive toast] + N -. later Recipient gets taps .-> Y[Editor refuses to open,\npre-emptive toast] + J -. toggle flipped either way .-> RQ[Held quote invalidated,\nre-quote with the new\nprivacy and floor] + RQ --> T +``` + +Which requests each action produces: + +| User action | Requests | +|---|---| +| Address entered or chain picked | none (chain table) | +| Amount committed, under the floor | none (pre-empted) | +| Amount committed, clears the floor | `GET /tokens` per asset (memoized per chain), then `GET /quotes` | +| Stealth toggled either way | the same pair, re-requested with the new privacy and floor | +| Slider confirmed | `POST /exchanges`, then the normal send broadcast | +| Any of the above, rate limited | the same call retried behind `retryAfter`, up to three times | +| Order status after broadcast | none in-app; the details scene links out to Houdini's order page | + +None of the branches above tell the user that the send is a swap, which is the one thing the scene's own layout hides: the amounts, the address and the slider all read like a send. The modal and the card in [Saying that a swap is running](#saying-that-a-swap-is-running) sit across this flow rather than inside it. The modal fires once per account, at whichever node first sets `swapSendActive` (adopting a cross-asset recipient at `F`, or arming Stealth at `P2`), and the card holds from that node until the flow leaves swap mode. Neither issues a request, and neither gates the slider. + +One error slot is shared by two owners, and every bug in this area came from ignoring that. The plain-send `makeSpend` effect owns its failures, the quote effect owns the swap's, and expiry belongs to neither: it is a property of the REQUEST, so it survives whichever mode the scene is in and is retracted only by replacing the address. Each owner clears through a helper that refuses to touch what it does not own, which is what keeps an insufficient-funds message from vanishing under a live quote, keeps a minimum-amount message from sitting over a plain same-asset send, and keeps an expired request from leaving a disabled slider with nothing on screen explaining it. + +The same discipline governs what may still be approved. A quote is retired the instant anything it was priced against moves: the amount, the address, the source wallet, the privacy toggle, or its own expiry timer. Retiring means dropping the quote, not merely asking for a new one, because the slider gates on a quote being present and a re-quote takes a render to start. A source-wallet or asset switch resets the whole spend rather than the first recipient alone, and retires the toggle, the learned route capabilities, and any error on screen, all of which describe the wallet the user just left. Retirement is read through a ref at the one place that resumes after an await, the PIN spending-limit check on the way to approving, because a render's closed-over quote cannot see a retirement that happened while it was suspended. + +Approval is a one-way boundary, as a plain send's broadcast is. The plain path locks its slider once `signTx` returns, because past that point a failure no longer proves the transaction is absent from the network. A swap-send cannot see that point: `approve` signs and broadcasts inside the plugin. So the scene treats the whole call as at or past it. The slider locks as "Send Attempted" for the life of the scene, a failure shows the same "Transaction Status Unknown" card a failed plain broadcast does (or the engine's own text for a rejection it names outright), and the quote effect freezes, so no edit can arm a fresh order or retract the card. Address resets and wallet switches leave the card in place. The one failure that re-arms is the wallet refusing the deposit as not yet spendable, which happens before anything is submitted. + +A retired quote is also closed. The core keeps a swap-to-address request's [synthetic destination wallet](#the-synthetic-wallet) alive until every quote carrying it is closed, so the scene closes each quote it will not hold: the runners-up of every request, all quotes of a request a newer one superseded (including an answer that lands after the scene unmounts), and the held quote when it is replaced, cleared, or the scene unmounts. The one exception is a quote retired while its approval is still signing, which the approval closes once it finishes rather than having it closed underneath it. + +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 + +Eleven files across three repos hold 165 tests, all passing. + +In the gui, 104 across five files: + +1. `src/__tests__/util/paymentUri.test.ts` (15): the peek passing a bare address through, trimming whitespace, reading a [BIP-21](#bip-21) scheme, keeping the [cashaddr](#cashaddr) `prefix:` candidate, stripping the [EIP-681](#eip-681) `pay-` prefix, and keeping the case of a `scheme://` address that url-parse would lowercase; the EIP-681 `@chainId` read in decimal and hex, left unset when absent, and a token-transfer code offering no address; and `parseCrossChainPayment` returning the chain parser's address, native amount and [memo](#memo), asking for the chain's own coin, and refusing a parser rejection, a token code, and an empty address. +2. `src/__tests__/util/houdiniChains.test.ts` (51): the address-detection cases (EIP-681 chain ids naming Robinhood Chain and Monad, a TON address matching TON alone, single-chain detection, all-[EVM](#evm) fan-out for a bare `0x`, scheme resolution including a scheme differing from the pluginId, source chain never offered from that chain's own coin but offered from a token on it, unsupported chains skipped, Solana and Dogecoin and legacy Bitcoin formats, non-address text rejected, unknown scheme falling back to format matching, a mislabeled scheme not trusted, the Cardano catch-all regression), plus lookup and table invariants: `getHoudiniChain` resolving a served chain, refusing an unserved one, refusing the three chains with no mainnet native, and refusing a token id on a served chain; no duplicate plugin ids or provider chain names; every entry carrying a boolean `hasSelfPrivate`; every address regex rejecting the empty string and free text, which is the shape the Cardano catch-all had; the [memo](#memo)-required set; and the floor constants ordered [dex](#dex) < standard < private, shaped as biggystring-comparable strings, and equal to the values the provider published. The picker cases pin that the first row names the payout its pick gives under each Stealth and token combination, that a token source without Stealth is offered its own chain's native coin, that no payout is listed twice, and that a native source lists every served chain but its own. +3. `src/__tests__/util/stealthSwap.test.ts` (13): 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; and the kill-switch predicate matching an entry without a token to the chain's coin alone, a named token alone, `allTokens` to every token but the coin, `allCoins` to both, and nothing on another chain. +4. `src/__tests__/util/swapErrorDisplay.test.ts` (17): a missing error, minimums and maximums rendered in the units of whichever side was fixed, the limit-free fallback when the bound is zero, both assets named on an unroutable pair including the swap-to-address case where the payout code has to be supplied, insufficient funds from both the typed error and the stringified shape some plugins throw, pending transactions, a geographic restriction, an unrecognized error surfacing the provider's own text, a rate limit never rewritten into a pair error, a thrown non-error stringified, and the original error preserved for the caller to log. + +5. `src/__tests__/actions/CategoriesActions.test.ts` (8): the three send titles read off the `swapSend` action's `privacy` flag and its asset pair, a private send's title outranking a stored metadata name shaped like a recipient address, a plain swap-send leaving a stored name alone, and the parent network-fee row keeping both its own title and its own category while still refusing a stored recipient-style name. The fee-row title case fails on the pre-fix code, which is what makes it worth having. + +In edge-core-js, 19: `test/core/synthetic-wallet.test.ts` (5) for the synthetic wallet's shape and bridge survival and its address list (every address returned verbatim, the first paid out to); `test/core/account/account.test.ts` for `parseUri` on a config with no wallet (the chain's coin, a builtin token by currency code, and a custom token the parser has to be told about) and a send request quoted end to end; `test/core/currency/wallet/currency-wallet-cleaners.test.ts` for a `swapSend` action round-tripping through the disk cleaners and one without `privacy` rejected; the plugin-selection truth table in `test/core/swap.test.ts` (8), which pins that a caller can reach a provider the user switched off and can never reach one it disabled itself in the same call, and `test/core/swap-quote-close.test.ts` (2) for the synthetic wallet's reference-counted release, including a double-close that must not free it twice. + +In edge-exchange-plugins, 42 in `test/houdini.test.ts`: 4 acceptance tests replaying recorded fixtures (quote retrieval both directions, order creation, destination-tag threading), and 38 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 each shape the catalogue gives a coin's row (a `null`, empty or contract-style address, and a `chain` that differs from the queried name), 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, a dex route taken for a plain send only in the shape the plugin can execute, a private same-asset request allowed and any other same-asset request declined, a chain with no native declined before any quote goes out, the fixed-versus-floating label on forward and reverse quotes, a rate-limited call retried behind the window the API reports, the retry budget running out with a message that names the rate limit rather than the route, an unserved chain asked about once and then remembered, a lookup the provider failed to answer deliberately left uncached and reported as a provider failure rather than an unsupported pair, both legs of a same-asset quote sharing one lookup, a reported retry window longer than our own cap honored while the cap still bounds growth when none is reported, a backoff that would outlive its quote failing fast instead, the Unix-seconds `validUntil` the API actually sends parsed correctly, a max quote creating one exchange rather than two, a `VALIDATION_ERROR` surfacing its field message instead of the generic "Validation Failed", the max probe setting `skipChecks` and clamping an above-limit balance rather than throwing, the trust boundary refusing an inflated deposit amount on a forward quote and on a reverse quote (against the route ceiling and against the route's own quoted send amount, while accepting an order that restates its quote with rounding), minimums rounding up and maximums down, a numeric and a blank deposit tag both surviving the cleaner, the 409 deposit-address-in-use fallthrough to the next route, and the saved action: a `swapSend` with the recipient and the request's `privacy` for a pasted-address destination under both privacy settings, and a `swap` naming the payout wallet for one of the user's own wallets. + +Full-repo verification: `verify-repo.sh` PASSED on all three repos, covering install, prepare, lint, and the full jest and mocha suites. + +### Maestro suite + +[`maestro](#maestro)/14-stealth/` holds a flow per user-visible branch, built from reusable +subflows in `maestro/common/stealth-*.yaml`. The point of the split is that a +later session can drive one specific state (a live private quote on some other +pair, say) in a handful of `runFlow` steps rather than walking the simulator by +hand. `maestro/14-stealth/README.md` documents how to run the suite whole or one +flow at a time, what each env var needs from the signed-in account, and the two +gotchas that cost the most time to find: the confirm slider is a pan gesture +that ignores coordinate swipes entirely, and notification cards float over the +bottom of every scene including the slider. The launch subflow also closes the +"Verify your password" prompt, which covers the scene on some launches and hides +the tab bar every walk waits for. + +The flows drive rather than assert. Each asserts only enough to gate its next +step, because behavioral claims belong in the unit tests and in this document. +The two flows that move funds carry the `stealth-spend` tag and nothing else, so +a run of the suite by its ordinary tag can never spend. + +On-device, iOS simulator, account `edge-funds`, against the live provider: + +5. **Executed cross-chain send.** ETH wallet, Litecoin address pasted, destination auto-adopted, 0.25 LTC guaranteed on the receive side, live quote 1 ETH = 39.59988278 LTC, executed to the success scene. Broadcast `0xa87fd77e1a64310d565e462cbc91e5f3e0e748ff1bbb62857455aef37e4044e7`, 0.00631315 ETH ($11.93), category `Exchange:To LTC`. Source wallet moved 0.0175191 to 0.0111629 ETH. +6. **Executed cross-chain send from a scanned URI**, plain and with Stealth on: `176c833c6f0ef09ea9c2ba5eb6a39e079a13262aadd362d87d195350114a54dc` and `53f03d92da63c9b20dc29a0c296b4043cefb71e334066bf78529d6cec2b11cb6`. +7. **Executed plain cross-asset send**, on the phase 2 pre-followup code state where a stealth-off send fanned out to every provider (ChangeNOW won): `0xfd51a5c5d4ba44267d257506c977a8e88952f56987e653d2b812502fa739cf8b`. It covers the send-to-address broadcast path, not the provider restriction. +8. **A 10-case entry-path and chain matrix**: typed Ethereum address into a Bitcoin wallet (picker to Ethereum, and separately to Polygon); typed Solana address into an Ethereum wallet; scanned `bitcoin:` URI from an Ethereum wallet; scanned `ethereum:` URI from a Bitcoin wallet; pasted Bitcoin address into a Litecoin wallet; typed Ethereum address from a USDC (Algorand) and a USDT (Tron) token wallet; deep link confirming unchanged same-chain behavior. Chains exercised: BTC, LTC, ETH, POL, SOL. +9. **Regression:** plain same-asset sends, multi-recipient [UTXO](#utxo) sends, and the multi-recipient gating, all verified on device. +10. **Stealth auto-disable mechanics, live**, on the phase 5 code state: ETH wallet, pasted LTC address, stealth armed, amount entered. The quote failed on the missing private route, the toggle turned itself off with the toast, the info line pinned, and the fallback re-quote of that code state executed through ChangeNOW (`0x2f281f4ea143a775355da7876290aaa2a9a7d44160a68eeac89b1ebe92ac284c`). It covers the toast, info line and `routeCaps` mechanics, which are the same lines the same-asset branch runs; the cross-asset fan-out it re-quoted through belongs to that code state alone. +11. **Fixed-to fallback, live.** PIVX wallet ($89), stealth on, own PIVX address pasted, "Recipient gets" set to 1000 PIVX: the reverse quote failed (no receive-priced same-asset route), the toast fired, the send side became guaranteed at the rate-seeded 1000 PIVX, and the warning card appeared. Tapping "Recipient gets" afterwards refused with the pre-emptive toast; editing "You send" cleared the card. The post-fallback forward quote then surfaced the provider's PIVX deposit-address defect (retrospective item 4), which is unrelated to the fallback mechanism. +12. **Pre-emptive stealth refusal, live.** Re-tapping the stealth toggle on the known-unavailable ETH to LTC pair refused to arm and toasted, without issuing another quote. +13. **Houdini exclusivity, live (phase 6).** ETH wallet, pasted LTC address, 0.006 ETH, Stealth OFF: the quote went Houdini-only and surfaced `SwapCurrencyError: HoudiniSwap does not support ethereum:null to litecoin:null` in the error card, where the phase 5 code had produced an armed ChangeNOW quote on the same pair and amount. Repeated with Stealth ON: the toggle stayed on and the same error card appeared, with no auto-disable toast and no re-quote. Both amount entries went through the new flip-input modals (You send in ETH/USD, Recipient gets in LTC/USD via a borrowed Litecoin wallet). Amount errors were also confirmed unchanged: 0.002 ETH (below the provider minimum) produced the plain error card with the toggle still armed. The same-asset auto-disable degrade was NOT drivable this phase: the provider's route availability flapped during testing (private routes present on one probe, absent minutes later) and no funded same-asset pair lacked a private route at a fundable amount; the toast, `routeCaps`, and degrade mechanics are the same lines phase 5 drove to execution. + +14. **Amount-row title states, live (phase 13).** XLM wallet, "My Sonic" adopted as the destination, 158 XLM entered on the send side: the send row read `You send (Guaranteed)` in green with a bare amount and the receive row `Recipient gets (Estimated)` in orange with the `~` prefix, on a live quote of 1 XLM = 7.67561925 S. Editing "Recipient gets" to 1200 S swapped both, `Recipient gets (Guaranteed)` green and bare against `You send (Estimated)` orange with the tilde, and the reverse quote resolved at 1 XLM = 7.56043868 S with the slider armed. Nothing was sent. + +15. **Executed a private Stealth Send priced in fiat (phase 16).** Litecoin wallet, own Sonic wallet adopted through the Myself picker, `28` typed into the "You send" flip input with USD as its open field: the row committed `0.61556 LTC`, the private quote came back at `1 LTC = 1948.10181555 S` (1.63%) for `~1199.17355358 S ($27.54)` with a `0.00052969 LTC` network fee, and the slide reached the success scene. The transaction's details read `Amount in USD $28.04` against `Ł 0.61556 (+0.000529 fee)`, category `Exchange:To S`, deposit address `MG4xVKRzGkYcyHuFBdxNzvBwg2gG1GAdWy`. Both rows were also opened on a second pair (XLM to Sonic) purely to see which field they present: each opened on USD with the crypto amount above it, and `30` on the send side resolved to `185.176 XLM ($30.00)`. + +16. **Pasted destinations, live (phase 22).** From a Litecoin wallet holding 0.68125 LTC: a pasted Bitcoin Cash address with Stealth on reached a private quote of `0.127768 BCH ($29.24)` at `1 LTC = 0.2281164 BCH` with the slider armed, and was not sent. A pasted TON address with the destination tag `edge-stealth-test` and Stealth on quoted `21.45672 TON` at `1 LTC = 38.28822269 TON` (2.12%) and slid to the success scene; the transaction reads "Stealth Swap & Send", `Ł 0.5604 (+0.00306 fee)`, `$30.15`, category `Exchange:To TON`, order `7UFJMkByut8apEpPGHPiiD`. A pasted Monad address from an ETH wallet, with Monad picked first, returned "No enabled exchanges support ETH to MON" at 12 USD with Stealth off and at 26 USD with Stealth on, which is the state before the plugin took dex routes (item 18). + +17. **Launch prompt, forced and organic.** With the "Verify your password" prompt forced open once per bundle load by an uncommitted edit, `stealth-launch.yaml` closed it through `modal-close-button` and reached the tab bar. The same subflow closed it again on its own trigger later in the run, when a tap landing on the password card raised it over a success scene. + +18. **Monad over a dex route, live (phase 22).** Funded by swapping `12.001 USDC` into `123.42 POL` inside My Polygon 2 on the Exchange scene (0x Gasless Swap, 1.16%). A pasted Monad address from that wallet, Monad picked first and Stealth off, quoted `~521.038595453371 MON ($12.04)` at `1 POL = 4.17231418 MON` and slid to the success scene. The transaction reads "Swap & Send", `POL 124.88 (+2.26194 fee)`, category `Exchange:To MON`, order `2JrCoeLG36zo7pdRRWcwYn`, whose Houdini record carries `isDex: true` and the source transaction hash the plugin reported through `dex/confirmTx`. + +## 10. Phase history + +### Phase 1: prototype and the bridge verdict + +- **Sketched:** prove a swap-to-address flow end to end. +- **Shipped:** prototype PRs [#6054](https://github.com/EdgeApp/edge-react-gui/pull/6054) and [#6031](https://github.com/EdgeApp/edge-react-gui/pull/6031), with a parallel scene, a four-chain hardcode, and a GUI-built fake destination wallet. +- **Diverged:** the fake wallet did not survive the [yaob](#yaob) bridge, which moved the synthetic wallet into the core and set the shape of the whole design. + +### Phase 2: production implementation + +- **Sketched:** replace every prototype hack with real wiring. +- **Shipped:** the core `toAddressInfo` seam, the HoudiniSwap plugin, and `SendScene2` integrated in place with a 38-chain metadata table, linked amounts, expiry re-quoting, destination tags, and both Stealth toggles. +- **Diverged:** the prototype's reroute of the wallet Send button and its `HoudiniSendScene` re-skin were deleted rather than adapted, and the price-delta UI was extracted for reuse instead of recreated. The provider restriction also changed inside the phase. The first cut restricted stealth sends alone and fanned plain cross-asset sends out to every provider; send-to-any became Houdini-exclusive instead. That fix was autosquashed into the feature commit, and neither the PR body nor this document recorded it, which set up the phase 5 regression. + +### Phase 3: scanned payment URIs + +- **Sketched:** a scanned QR carries a URI, not a bare address, so the cross-chain branch has to read one. +- **Shipped:** `paymentUri.ts` plus URI handling in the cross-chain branch, driven to execution on funded wallets in both plain and stealth modes. +- **Diverged:** the first cut routed a URI amount to the guaranteed receive side unconditionally. Provider probing showed no receive-priced route exists for same-asset pairs at any amount, which would have made every same-asset payment URI unquotable, so the routing was gated on the destination being cross-asset. + +### Phase 4: cross-chain address entry + +- **Sketched:** an Ethereum address could not be pasted or typed when sending from a Bitcoin wallet, and the full URL failed too. Cover the top chains, USDC and USDT, and every entry path. +- **Shipped:** `detectHoudiniChains` and the `onUnparsedAddress` hook, the disambiguation modal, the two regex corrections, and the 10-case matrix in [Section 9](#9-testing). +- **Diverged:** the bug was assumed to be a parsing gap and turned out to be an ordering gap. The cross-chain override worked correctly but only engaged once "Recipient receives" had been changed, which nobody does before entering an address. + +### Phase 5: route availability in the UI + +- **Sketched:** the UI reflects what the provider actually offers. The stealth toggle turns itself off with a toast on a pair with no private route, a fixed receive amount falls back to a guaranteed send amount (toast plus a warning card that clears on edit) when no receive-priced route exists, and disabled controls explain themselves on tap. +- **Shipped:** the `routeCaps` mechanism, both fallbacks, the pre-emptive refusals, and the warning card, driven live on ETH to LTC (stealth auto-disable through to an executed swap) and PIVX to PIVX (fixed-to fallback on a funded wallet). +- **Diverged** twice, and the second divergence was a regression. The unconditional Houdini restriction had been intended behavior since phase 2, but the PR body still described the original fan-out and the autosquash had made the restriction look original, so it read as drift. This phase made the restriction conditional on the toggle and re-routed the stealth fallback through a provider fan-out. The executed swap in [Section 9](#9-testing) item 10 ran on that code state. Phase 6 reverted it. + +### Phase 6: Houdini exclusivity restored + +- **Sketched:** all send and swap functionality is Houdini-exclusive, the PR body is out of date, and the documentation has to make the confusion unrepeatable. The swap-send amount modals become flip inputs. +- **Shipped:** the unconditional `makeStealthSwapRequestOptions` restored at the quote call, the stealth auto-disable narrowed to same-asset pairs (cross-asset, the toggle does not change a Houdini-only request, so a missing route keeps the error card), the `stealth` quote-effect dependency dropped again, every fan-out claim purged from this document and the PR body, and both amount rows moved from plain text modals to `FlipInputModal2` ([Linked amounts](#linked-amounts)). +- **Diverged:** nothing. The retrospective gained the doc-drift item this regression earned. + +### Phase 7: routable Myself picker and transaction identity + +- **Sketched:** two features and a standing rule. The rule is that supported-destination logic is route-derived on every surface, with the branch audited for hardcoded assumptions. Feature A: the "Myself" picker lists every routable destination asset, same-asset pinned to the top through an opt-in grouping prop, with private availability handled by the toggle rather than by filtering the list. Feature B: swap-sends, stealth sends and stealth swap-sends are distinguishable in the transaction list and details, through a first-class action field rather than a metadata convention, with the recipient suppressed in the UI but preserved in storage. +- **Shipped:** all of it, plus the [Destination assets are route-derived](#destination-assets-are-route-derived) and [Transaction identity](#transaction-identity) sections and three decisions recording the rationale in the same turn the code landed. +- **Diverged:** `swapType` extends the existing swap action instead of becoming its own `actionType`, because these flows are swaps to every existing consumer and a new action type would drop them out of all of it. + +### Phase 8: PIN gate and quote-state hardening + +- **Sketched:** nothing new. A finalize-gate re-confirmation found the PR sitting in draft, which had suppressed the reviewer bots entirely; this repo runs no typecheck on pull requests, so the draft bought nothing and hid everything. Marking it ready produced seven findings across two review rounds. +- **Shipped:** the stale-quote clear on every amount commit plus a generation guard on the quote effect, the fixed-to fallback no longer stranding the scene when rates are missing, the [PIN spending limit](#pin-spending-limit) gating swap-send at all three points it was bypassed, a null check on an empty quote list, a full swap-send reset when the address is cleared, and the stealth degrade wired into the confirmation scene's re-quote. +- **Diverged:** the deferred-work table said PIN limits on stealth sends were "not doing". That decision is reversed here, and the reasoning behind it is recorded as a mistake worth keeping. + +### Phase 9: real failures in the error UI + +- **Sketched:** swap and send failures must show the real cause (provider message, limit floor, network error) instead of a catch-all alert. +- **Shipped:** the swap flow's error mapping moved out of `SwapProcessingScene` into `src/util/swapErrorDisplay.ts` and the send scene now uses it, so a failed send-to-address quote renders the limit that was crossed, the pair that cannot route, or the provider's own message rather than "Unexpected Error". The review rounds that followed produced six more fixes. Three are behavioral: the PIN spending-limit flag became a derived value instead of effect-written state (it could lag a render behind a live quote and leave the slider armed), a source-wallet change now clears the Stealth toggle and learned route capabilities, and a private send's title now outranks any stored metadata name. The rest are corrections: `upgradeSwapData` receives the payout wallet rather than the source wallet, so a payout currency code resolves against its own chain; `trackSwapError` reports the destination wallet type instead of repeating the source; and the Hedera `addressValidation` pattern escapes its dots, which previously made them wildcards that accepted `0X0Y12345`. +- **Diverged:** one finding was rejected rather than fixed, that the limit ignores the network fee, because `origin/develop` computes it the same way for every plain send and changing only this path would make the two disagree. + +### Phase 10: the provider feedback round + +- **Sketched:** a feedback round from the Houdini team plus two internal reviewers, carrying a standing rule (every supported-destination decision is route-derived) and two hard constraints (no availability probing or per-pair caching of any kind, and graceful rate-limit handling). +- **Shipped:** the Myself picker's same-asset capability reads Houdini's own `hasSelfPrivate` flag instead of assuming it, and the Stealth toggle refuses to arm for an asset that lacks it; the toggle re-quotes on every flip, which it did not before, because the request now genuinely differs by privacy and floor; the three minimums were confirmed against the live API and became named constants enforced before any request leaves the app; the plugin honors `privacy: 'required'`, so a plain Swap & Send can use the standard routes that are the only ones on offer between 10 and 25 USD, while a Stealth send declines rather than silently taking one; every call backs off behind the API's own `retryAfter` and reports a rate limit as a rate limit; the exchange order details render on a stealth transaction's detail scene, which they never did; the send scene ignores the global exchange-provider setting while the Exchange scene keeps honoring it; and the Houdini amount rows show their fiat value inline in parentheses through the shared `FiatText` component. +- **Diverged:** nothing was dropped, but the standing-rule audit found more than the asks did. Four chains were offered as destinations that Houdini serves no native for at all, and six more could never resolve a token id because the API spells "no contract address" as an empty string on those chains while the plugin tested only for null. Both are fixed; see [Retrospective item 6](#where-this-document-was-wrong-or-silent). + +The review round on that work produced nine findings across five passes, and two were privacy holes of the same shape as the one the round set out to close. The Exchange scene's Stealth Swap restricted the provider but never demanded a private route, so a wallet-to-wallet stealth swap could be served a transparent standard route under a private label; it now sets `privacy: 'required'` on the initial quote and on the expiry re-quote. A held quote survived a switch to another wallet on the same asset, leaving an order created against the previous wallet's refund address armed and approvable. The rest were state hygiene: the floor guard raced an in-flight quote until every run of the effect began retiring its predecessor, the two effects were clearing each other's errors, a token send to its own chain was titled "Stealth Send" when it pays out native and so crosses assets, the fixed-to fallback read a stale rate snapshot, and forward quotes claimed to be fixed. One finding was rejected in part: adopting `checkInvalidTokenIds` wholesale would have rejected same-asset swaps outright, which is the feature. + +### Phase 11: the follow-up sweep + +- **Sketched:** work the previous round's own follow-up list rather than carrying it, with three of its items answered directly. Funding is not a precondition to wait on: a wallet a test needs is funded by swapping into it, and only the spread and fees count against the budget, since the principal stays in the account. The dependency-publish item was wrong to write down at all, because this feature is pinned to our own core and exchange-plugin changes. And a product question left open for two phases was to be decided, not re-deferred. +- **Shipped:** the six chains whose native coin the API reports with an empty contract address were driven for real, on a wallet funded by swapping into it, ending in an executed private Stealth Send between two of them; the spending-limit question decided against the pre-Houdini arithmetic; unit coverage roughly tripled, with the plugin's route filter, native-token matching and rate-limit backoff moved onto deterministic local responses; a composable [maestro](#maestro) suite covering every user-visible branch; and the token lookup taught to remember a miss, so a chain the provider serves no native for declines on one call per session instead of one per quote. +- **Diverged** twice. The funding swap chose its source badly first: the provider handed back a PIVX deposit address the PIVX plugin cannot spend to, which cost an attempt and is recorded as a provider defect rather than ours. And the audit's own finding was mis-fixed. Four chains the provider serves no native for were hardcoded to `null`, on the reasoning that a mapped name surfaced an error where a `null` would decline; both paths raise the same `SwapCurrencyError`, so the reasoning was wrong and the fix was a snapshot of a live fact. A follow-up round reverted it and cached the token-lookup miss instead, which costs the same single call without asserting anything the provider might change. + +### Phase 12: the suite green, and a reviewer that came back + +- **Sketched:** replace the hardcoded chain nulls with the dynamic decline the previous round should have written, correct the overstated claim recorded alongside that fix, and get every flow in `maestro/14-stealth/` passing end to end while swapping only for assets a flow actually needs. Mid-phase, the send scene's provider-floor card turned out to be hardcoded to dollars, and the reviewer bot had run out of quota, which the harness should handle without treating a silent reviewer as a clean one. +- **Shipped:** all ten flows pass, verified one at a time with the driver killed between, because a failing flow takes the driver down and every later flow then reports a connection error that reads as a suite-wide break. Getting there fixed seven defects in the suite rather than in the app: a search-field focus race that dropped the typed filter, rows never satisfying the default full-visibility requirement, a transaction row whose accessible label is the whole row joined, so a text match resolved to a container and tapped nothing, a PIN loop paying for six passes after the gate cleared, a picker missing the wait its shared subflows already had, a [memo](#memo)-chain default naming a display name the picker does not use, and two funded flows running the same direction so they could not run back to back. The floor message now converts through the rates the scene already holds and formats with the shared helper, verified on device by switching the account to EUR and reading back "Private routing needs at least EUR 21.80". +- **Diverged** once, and it was the harness. The reviewer-availability classifier written this phase read the check-run bucket, and Bugbot reports `skipping` on a HEAD it has in fact just reviewed. Trusting the bucket would have filed a real finding as "reviewer unavailable" and walked past it. The classifier now asks whether a review exists pinned to the head commit, which is the only proof of coverage, and the bucket merely raises the question. + +That correction mattered immediately: the reviewer's quota returned mid-phase and it filed seven findings across three pushes, six of which were real provider-interaction bugs (a failed token lookup indistinguishable from an unsupported pair, both legs of a same-asset quote spending two calls on one answer, a provider retry window truncated by our own cap, a retry that then outlived the quote it would resend, a `validUntil` the parse could not read, a max quote spending two of the one-per-minute exchange slots, and a validation failure reporting "Validation Failed" instead of its real reason). One was rejected with reasoning. Each plugin fix carries a test. + +A second review round followed on the gui, four more findings, all real: a quote surviving the toggle that repriced it, a quote surviving its own expiry while the terms modal deferred the navigation away, an asset change whose reset was overwritten by a stale spread of the pre-reset value, a superseded `makeSpend` landing after the scene had moved to quoting and clearing an error it did not own, and the expiry message being retracted as though the plain send owned it. They are one theme, and [Section 8](#8-the-send-scene-ux-end-to-end) now states it: one error slot has two owners plus a request-scoped case, and a quote is retired the instant anything it was priced against moves. + +### Phase 13: the state word moves into the row title + +- **Sketched:** the state word sat on its own line under the amount, so each of the two linked rows read as three stacked lines and the number was not the last thing on it. Put the word in the row's title instead, parenthesised, green for the guaranteed side and warning orange for the estimated one, on both rows. +- **Shipped:** `EdgeRow` grew a `titleState` node it renders after the title, and `EdgeText` grew `PositiveText` to sit beside the existing `WarningText`. Both colour components set colour only, so a span nested in a 0.75rem header keeps the header's size rather than jumping to the 1rem body size an `EdgeText` would have forced. The scene passes the word and drops the third line; the estimated side keeps its `~` prefix, which marks the approximation next to the number rather than away from it. +- **Diverged** once, and the linter found it. The first cut widened `EdgeRow.title` to a node so the scene could compose the whole header itself. That put raw text in a fragment outside any ``, which `react-native/no-raw-text` rejects, and it would have made every caller wanting a tinted word rebuild the shared header style. Moving the parentheses into `EdgeRow` fixed both, and phase 15 then moved them back out to the caller for a different reason. + +The suite needed two changes for the new titles, both of which say something about the old ones. The shared amount-row subflow matched the row by its exact title, which the state word now breaks, so it matches a prefix; and it now erases the flip input before typing, because the input opens pre-filled and `inputText` appends, which silently commits a concatenated amount on any re-edit. The payment-URI walk asserted a bare `Guaranteed` that no longer stands alone as its own element, and now asserts the whole title, which also binds the guarantee to the row that should hold it. + +### Phase 14: the row that named the wrong party + +- **Sketched:** a tester reported the recipient visible in a private send, with a screenshot of the transaction details scene and no further detail. +- **Shipped:** the row is titled from the action, "Exchange Deposit Address" whenever the saved action carries a `swapType`. The audit that went with it found a real leak the report had not: a token send's parent network-fee row carries the plugin's unstamped action, so `swapType` is absent there and its Exchange Details renders the payout address the token row hides. Both are covered in [Transaction identity](#transaction-identity). +- **Diverged** twice, both inside the fix. Stamping the fee row makes it a send-shaped swap to every consumer, and `getTxActionDisplayInfo` titles those from `SWAP_SEND_LABEL_MAP` before it looks at the asset action, so the first cut retitled the fee row "Private Send" and put what looked like a second private send in the list. The title map now applies only when the asset action is not a network fee, while name suppression stays bound to the flow. The second divergence came from review: the fee-row stamp is a second `saveTxAction` inside a `try` the send deliberately does not fail on, so a stamp that fails leaves the row unstamped and the send still reports success, which is a privacy rule failing OPEN on a best-effort write. Rather than harden the write, the rule moved off it. The payout address is now suppressed on any network-fee row whether or not it carries a `swapType`, which costs nothing (the fee row is not the payment) and holds for pre-stamp transactions too. + +The report was half right, and the half that was wrong is the interesting one. The screenshot pointed at the "Recipient Addresses" row, which holds `spendTargets[0].publicAddress`. On a send-shaped swap that is the provider's deposit address; the payee rides on `savedAction.payoutAddress` alone and was already suppressed everywhere it renders. The transaction settled it without argument: the row's address received 38,693 sats, the gross send side the scene showed as "Exchange 0.00038693", while the payee was owed the 37,580 net shown as "To 0.0003758 BTC". The address has two transactions and forwarded the full amount, which is what a single-use deposit address looks like. So the data was right and the label was wrong, and a label that says "recipient" over an address the user did not choose is not a cosmetic complaint on a flow whose whole promise is that no such address exists. + +### Phase 15: the credentials came back, and the label stopped needing a hack + +- **Sketched:** redo phase 14's testing against working provider credentials, plus anything redoing it turns up. +- **Shipped:** the first genuinely stamped transaction on the test host, made deliberately from a **token** source since that is the only shape that files a second action under `tokenId: null`: 30 USDT on Ethereum to the account's own Litecoin wallet, private route, through to the success scene. It confirms three things the previous phase could only argue: the stamp lands, the details title reads "Stealth Swap & Send", and the spend-target row reads "Exchange Deposit Address" over Houdini's real deposit address. The parentheses around the amount-row state words also moved to the caller, having taken the title's colour rather than the word's, so `Recipient gets (Estimated)` had rendered in three colours. `EdgeRow` had been writing the brackets itself around whatever node it was handed, which necessarily put them outside the caller's colour component; the row now contributes only the separating space. +- **Diverged:** phase 14 shipped its label fix with a hack-forced screenshot, on the reading that no transaction in the test account carried a `swapType` because the provider was unavailable. That reading was wrong. `HOUDINI_INIT` was present in `env.json` and set to `false`, so `corePlugins` never registered the plugin and `fetchSwapQuotes` queried zero of them, which surfaced as `reduce of empty array with no initial value` from `pickBestError`. A missing credential and a disabled plugin are the same symptom through that code path. What is still not covered on device is the parent fee row's suppression, whose unit tests and unconditional code path are the standing evidence. + +### Phase 16: the amount entry opens on fiat + +- **Sketched:** the amount entry defaults to fiat instead of crypto. +- **Shipped:** `forceField="fiat"` on both rows, and the maestro suite's defaults restated in fiat. +- **Diverged:** nothing. The one-word change in two places is the whole of it, and the interesting part is that the scene had been the app's only dissenter. The Exchange scene forces fiat on both of its inputs and the plain send seeds its remembered side to fiat, so opening on crypto was this branch's own invention rather than a house convention it had inherited. The reason to prefer fiat here is stronger than consistency: the provider's floors are quoted in USD, and the two sides of a cross-asset send have no crypto unit in common, so fiat is the only denomination in which both rows and both floors can be read against each other. The suite's stealth flows had used source-asset default amounts picked to straddle the 10 and 25 USD floors, with a README caveat warning that a price move would quietly push them onto the wrong side; the defaults are now 30 and 15 USD, which sit where they are meant to sit at any price. + +### Phase 17: the branch made reviewable + +- **Sketched:** the commit history reads as a straight-line progression rather than the path development took, and this document matches the current standards and the current code. +- **Shipped:** 63 gui commits rebuilt as 15, 7 exchange-plugin commits as 2, and the core's CHANGELOG moved out of all three of its commits into the last. Each commit is independently lint-clean, each string lands in the commit that uses it, and each file leaves the legacy lint exclusions in the commit that brings it onto the strict ruleset. The tree matches the pre-rewrite branch exactly apart from this document, the CHANGELOG, and three blank lines in the lint config. This document lost the commissioning narration from its phase entries, gained [Section 12](#12-glossary), and had every count and code block re-checked against the branch. +- **Also shipped:** a deep review pass over all three PRs plus the reviewer bots that ran on each push, whose confirmed findings are fixed here. Grouped by what they were: + + | Defect | Fix | + |---|---| + | Three published address patterns matched addresses of other chains: eCash spelled its prefix-less form as `[0-9A-Za-z]{42}`, exactly an `0x` [EVM](#evm) address, and Solana's floor of 32 base58 characters reached into the 33-34 band the Bitcoin-family legacy forms occupy | Both narrowed to the encodings those chains actually use, joining the Cardano and PIVX corrections | + | A scanned code's chain was read from its scheme alone, so `ethereum:…@137` paid Ethereum: every EVM network writes `ethereum:` and only the [EIP-681](#eip-681) chain id names the network | The parser reports the chain id, the detector reads it before the scheme, and a chain id nothing serves resolves to nothing rather than falling back | + | An EIP-681 token-transfer code (`ethereum:@1/transfer?address=`) put the token CONTRACT where the payout address is read | Function-call codes yield no address candidate, so they are refused rather than paying a contract | + | A scanned destination memo was dropped on both entry paths, so a tag-required exchange deposit was paid with nothing to credit it by | The parser reads `dt`/`memo`/`tag`/`message`, both paths carry it to the Destination Tag row, and only on chains that need one | + | The quote outlived terms it was priced against: a replaced recipient address, and an edited destination tag | Both retire it, through one `changeDestinationTag` writer for the tag | + | A slide arriving after the quote was retired mid-check left the slider latched on its spinner | That path hands the slider back | + | "Recipient receives" named the source token while the quote asked for `toTokenId: null`, so a token sender was told the recipient receives the token while the order paid out native | The row names the chain's native asset whenever no recipient asset is picked | + | Switching the source wallet cleared a still-valid same-asset recipient | The reset is scoped to what the new wallet cannot pay | + | In the core, a fresh `CurrencyConfig` was bridged into every swap-to-address quote and never closed | The account's own long-lived config is used | + | In the plugin, the token query sent Edge's checksummed contract while matching lowercased, and `floatToDecimalString` still returned exponential notation at or above 1e21 | The query sends the form it matches on, and the expansion is done by hand above that threshold | + +- **Diverged:** the counts were the divergence. The chain table had shrunk from 38 chains to 34 and from 6 memo-required chains to 5 when the four unserved chains were dropped, the test suite had grown past what the testing section claimed, and two code blocks (the activation predicate and `makeStealthSwapRequestOptions`) were quoting superseded code that the surrounding prose already described correctly. The rebase onto the current core master also carried the old branch's CHANGELOG wholesale, deleting two already-released sections; the tree comparison that checked the rewrite could not see it, because it compared against the pre-rewrite branch rather than against master. + +### Upstream, on Houdini's side + +Not our work, tracked so it is not rediscovered: + +- [ ] **Exact-out fixed-rate min-max bug.** Houdini acknowledged it and has developers on it. Re-verify exact-out min-max behavior after they ship. Do **not** build a workaround in this scope: the [fixed-to fallback](#availability-fallbacks) already degrades gracefully, and a workaround would have to be unpicked. +- [ ] **Private routes on fixed-rate quotes.** Until these exist, a privacy request priced by the receive side cannot be served, which is why the fallback re-prices from the send side. Worth re-checking whenever their routing changes. +- [ ] **Token destinations.** Blocked on token payout metadata through the swap plugin surface, not on us; `getHoudiniChain` returning undefined for a non-null `tokenId` is the single line that gates every surface. +- [ ] **Rate-limit headers, and which tier these credentials are on.** Free tier is ruled out behaviorally (six quote calls in 27 seconds drew no 429 against a 5/min free limit), but the API returns no `x-ratelimit-*` or `retry-after` header on success, so "pro" cannot be read off a response. At 500 quote requests a minute the app's per-user traffic is irrelevant; at 5 it is not. Worth both confirming the tier and asking them to expose the headers so a client can self-pace instead of guessing. +- [ ] **Deposit addresses that the source chain rejects.** A PIVX order returns a deposit address starting `EXMD…`, which Edge's PIVX plugin refuses with "unable to convert address to script pubkey", so the send fails at spend time with an opaque wallet error. Reproduced twice, on separate rounds. Until they fix it, the plugin could validate `order.depositAddress` against the source chain's own rules and throw a provider-named error instead; see the deferred-work table. + +### Deferred work + +| Item | Disposition | Reason | +|---|---|---| +| Token destinations | Deferred | Provider metadata for token payouts is not exposed through the swap plugin; native destinations cover the reported use cases. | +| Max spend in swap-send mode | Deferred | Needs the plugins' `getMaxSwappable`; plain-mode max is unaffected. | +| Dynamic chain metadata from the API | Deferred | Requires chain metadata through the swap plugin surface; the snapshot is dated in the module. Constrained further by the no-probing rule: a refresh would have to be a single cold fetch, never a loop. | +| `SwapDetailsCard` on a stealth send's tx detail | Reversed, now shipped | The card did not need a payout wallet, only the payout asset's currency config, which the saved action already carries. See [Transaction identity](#transaction-identity). | +| PIN spending limits on stealth sends | Reversed, now shipped | The original reasoning compared this to a swap. It is a send. See [Gate swap-send behind the PIN spending limit](#gate-swap-send-behind-the-pin-spending-limit). | +| [EIP-681](#eip-681) `value=` (wei) amounts | Deferred | Address is accepted, amount ignored; no reported user impact yet. | +| Validate provider deposit addresses against the source chain | Deferred | A PIVX order returns an address the PIVX plugin cannot spend to, and it surfaces as an opaque wallet error rather than a provider problem. A check after `asHoudiniOrder` would name the real cause; any chain the provider gets wrong fails the same way. | +| First-party Stealth Send article behind "Learn more" | Deferred | The feature spec has both toggles open a gist as a placeholder until the article exists. `STEALTH_LEARN_MORE_URI` is the one line to edit when it does. A gist stays editable by its owner, so the swap should not wait long. | +| Price impact when the destination asset has no fiat rate | Deferred | `calculateQuotePriceImpact` treats a to-side fiat value of zero as a total loss, so a send to an asset the account holds no rate for would show "(100.00%)" beside the exchange rate. Found by reading the code; two live quotes to unheld chains (Avalanche, HyperEVM) both had rates and showed about 2%. To fix separately if it turns up. | +| Whether the PIN spending limit should count fees | Decided: it does not | Settled against the pre-Houdini behavior rather than carried as a question. See [Match the pre-existing spending-limit arithmetic](#match-the-pre-existing-spending-limit-arithmetic). | + +### Phase 18: one answer to what the recipient receives + +- **Sketched:** the "Recipient receives" row and its picker disagree for a token source, and the picker has no search across 34 chains. +- **Shipped:** both surfaces read one `getRecipientAsset`, so the row can no longer name an asset the picker does not offer, and it follows the Stealth toggle rather than only the picked chain, so a plain USDT send stops claiming the recipient receives ETH. The source chain is listed once instead of twice. `RadioListModal` rows carry an optional value, which fixes selection for labels that are not unique and gives the row a stable `testID`; the picker's maestro subflow now names its chain by pluginId. The search box reuses the filtering `ListModal` already had. +- **Also shipped:** the slider reads the live quote through a ref rather than the value its render closed over. The PIN spending-limit check awaits, and a quote retired during that await left the closed-over binding pointing at an order the scene had already dropped, so the hand-back added in [Phase 17](#phase-17-the-branch-made-reviewable) could not see it. +- **Diverged:** the row fix from [Phase 17](#phase-17-the-branch-made-reviewable) was half a fix. Branching on whether a chain had been picked was right while a swap was active and wrong otherwise, so correcting the stealth case moved the same mislabel onto the plain send. The predicate was never "did the user pick something", it was "is this a swap". +- **Also diverged:** two rows for the source chain looked like a filter bug and were a modelling one. They encoded a real distinction, an implicit destination versus an adopted one, that no user could see and that only changed how a missing private route recovered. Removing the row removed the distinction rather than hiding it. + +### Phase 19: the branch rebased, and read against the repo's own checklist + +- **Sketched:** rebase all three branches onto their bases and review them, checking the Houdini plugin against `AGENTS.md`, `.cursor/BUGBOT.md`, `docs/API_REQUIREMENTS.md` and `docs/CREATING_AN_EXCHANGE_PLUGIN.md`, which upstream had filled out in the meantime. +- **Shipped:** six checklist items the plugin was missing. The max probe sets `skipChecks: true`, without which an EVM engine rejects a probe aimed at the user's own address and fails every max swap from an EVM wallet. The probe clamps instead of throwing `SwapAboveLimitError`, so an above-limit balance still makes a max swap once the fee is out. A `from` quote refuses a deposit amount above what the user requested. Rounding acquired a direction: minimums up, maximums and receive and deposit amounts down. The deposit memo is cleaned with `asOptionalBlank(asNumberString)`, so a numeric tag no longer takes the order down and a blank one no longer becomes an empty memo. The token-id cache expires after ten minutes. Alongside them: sorting and limit selection moved off JS floats onto `biggystring`, a cleaner failure logs the payload, and the 409 deposit-address-in-use fallthrough matches the envelope's `code` rather than searching the response text. +- **Also shipped:** the [synthetic destination wallet](#synthetic-destination-wallet) is released. It is bridgified per `fetchSwapQuotes` call and reached through `quote.request.toWallet`, so it now closes by reference count once the last quote carrying it is closed; before, every quote refresh left one in yaob's object table for the life of the account. The same reasoning was already written down one line away, for `currencyConfig`. +- **Also shipped:** the swap-send path delays its success navigation through `InteractionManager.runAfterInteractions`, matching the plain-send path beside it and the repo rule about navigating out of a completed gesture. +- **Diverged:** the rebase auto-merged a duplicate `testID` onto `SafeSlider`'s thumb. This branch had turned the hardcoded id into a prop that defaults to the same value, and upstream had independently added the hardcoded one; git merged both attributes onto the element and only `tsc` caught it. Rebasing 18 commits over 155 is where a semantic conflict hides behind a textual non-conflict. +- **Also shipped:** the maestro suite, which the rebase had broken. `walletListRow` ids were identical across a wallet's seven token rows, so a walk asking for "My Sonic" could land on any of them; token rows now carry their currency code. The picker modal reused those same ids over a scene whose rows repeat the names, so a tap resolved to the COVERED row and dismissed the sheet; picker rows are now `walletPickerRow.`. And the Send scene is reached through Home -> Send -> "To Another Wallet/Exchange" rather than the Assets tab, whose wallet row no longer lands on the wallet's transaction list. +- **Held:** three findings from the core review were rejected with evidence rather than fixed. `payoutWalletId` becoming optional in the disk cleaners is correct, because the public type is genuinely optional now. `forceEnabled` reaching a provider the user switched off is the documented intent, and is set on the send scene alone. The synthetic wallet's id is deliberately stable rather than unique per request: nothing keys on it, and the GUI already treats a `synthetic://` id as naming no wallet. + +### Phase 20: the swap under the send is stated out loud + +- **Sketched:** a one-time warning modal on the send scene when Houdini is detected, a one-time modal on the dedicated swap scene in the pattern the other centralized providers already use, and a warning card at the bottom of the send scene for stealth, swap-and-send, or both. +- **Shipped:** all three, per [Saying that a swap is running](#saying-that-a-swap-is-running). The swap scene needed one table entry, since `SwapConfirmationScene` was already calling `swapVerifyTerms` and Houdini was the only routed provider with no `pluginData` row. The send scene got its own modal and its own disklet key. The card reads off `swapSendActive`, the same predicate the rest of the swap-send UI keys on. +- **Diverged:** the send scene cannot reuse the terms modal. `swapVerifyTerms` runs on `SwapConfirmationScene` alone, and a send-to-address quote never visits that scene, so a single acknowledgement would have covered the dedicated swap and silently skipped every stealth send. That is why the operator's two asks stayed two implementations rather than collapsing into one. +- **Also shipped:** the three PRs went back to draft. gui CI cannot pass against published `edge-core-js` 2.48.0, whose `SwapCurrencyError` dereferences `request.toWallet` unconditionally, which is the change core#730 carries. + +### Phase 21: the lookup takes its query's answer + +- **Sketched:** four plugin defects from a live probe of Houdini's catalogue: the native lookup failing on 15 of 38 mapped chains, seven mapped chains with no native, seven served chains missing from the table, and no private route for 11 chains when the receive side is priced. +- **Shipped:** the native lookup takes the row its `mainnet=true` query returns and contract tokens match on the address alone, so Bitcoin Cash and TON resolve. `monad` (`MON`) and `robinhood` (`Robinhood`) joined the chain table; the latter's plugin id needed the plugins branch rebased onto current master. Two offline tests pin the new row shapes, and the scripted catalogue answers `mainnet=true` the way the live one does. Robinhood Chain, Monad and TON also joined the gui's destination table (37 chains): the two EVM rows keyed by chain ids 4663 and 143, TON as a memo row, each with a detection test. Both Stealth toggles' "Learn more" opens the gist the task names as the placeholder, replacing Edge's support home. All three branches were rebased onto their bases' current heads; the gui rebase took develop's wallet-row test ids (`..`), so every stealth maestro flow now names a wallet together with its currency code. +- **Diverged:** the probe measured the June prototype branch, which predates this one's fixes for the empty-string address and the symbol filter. Replayed against this branch's own predicate, 33 of 38 mapped chains resolved and two of the five failures were defects. `celo`, `fantom` and `polkadot` stay mapped per [Let the token lookup decide what is served](#let-the-token-lookup-decide-what-is-served-and-cache-its-misses); `axelar`, `bobevm`, `coreum` and `osmosis` were already `null`, and five of the seven missing chains were already mapped. The receive-side question was already answered by the [fixed-to fallback](#availability-fallbacks). The gui rebase merged develop's `testID` and this branch's onto one `SearchFooter` input with no textual conflict; `tsc` caught the duplicate, and develop's id stands, with the maestro walk selecting the input through it. + +### Phase 22: pasted destinations driven on device + +- **Sketched:** drive pasted Bitcoin Cash, TON and Monad destinations in the send scene (a private BCH quote, a tagged TON send to a broadcast, a Monad send), capture the recipient picker listing TON and Monad, and make the stealth launch walk survive the "Verify your password" prompt. +- **Shipped:** `AddressModal`'s input carries `testID="addressModal"`. The Enter button on `AddressTile2` opens that modal, not `TextInputModal`, and its input rendered as `undefined.textInput`, so `stealth-enter-address.yaml` could not paste an address; the walk now targets `addressModal.textInput` and taps "Next". `stealth-launch.yaml` closes the password prompt through a new `stealth-dismiss-password-prompt.yaml`, once after the mount and again after the password card is swiped, because a swipe the card reads as a press opens the same prompt. The mount wait accepts either the tab bar or the prompt. +- **Also shipped:** the plugin takes a [dex](#dex) route for a plain send when no exchange route serves the pair, which is what makes a Monad destination reachable ([Let a plain send take a dex route](#let-a-plain-send-take-a-dex-route)). A dex order names `addressFrom` and answers with the call to sign rather than a deposit address, so the spend sends its value to the router with the calldata as a hex memo, and `dex/confirmTx` reports the txid after broadcast. +- **Diverged:** Monad is served by no exchange route at all. Every Houdini route into `MON` from the eleven sources checked is a dex route, and BCH answers `NO_DEX_ROUTES`, so the first drive stopped at "No enabled exchanges support ETH to MON" with Stealth on and off. Reaching the destination meant reversing this branch's own "dex routes never taken" rule rather than fixing a defect. With Monad adopted as the recipient, turning Stealth on also leaves it on beside a route error rather than degrading: that degrade applies only when the toggle is the only thing making the send a swap ([Availability fallbacks](#availability-fallbacks)). + +### Phase 23: the review pass on the gui and plugin branches + +- **Sketched:** run the full review path on the gui and exchange-plugin PRs (the core PR excluded) and fix what survives. +- **Shipped:** a reverse quote's deposit is bounded by the send amount its route quoted as well as by the route ceiling. A same-asset route is quoted only for a private request. The plugin reads a failed response body once for both its code and its message. In the gui, the Dash and Monero address patterns reject a literal pipe, the detected-network picker resolves rows by pluginId, the swap confirmation renders one `PoweredByCard`, and every commit subject fits in 50 characters. +- **Rejected:** seven findings, each with evidence. Two said the privacy demand goes unenforced, because installed core predates the field and other plugins ignore it; the field arrives with this core branch, and the only caller disables every other provider for the request. One said Stealth quotes never show price impact, but core puts the synthetic destination wallet on `quote.request`. The other four: a dex order failure aborting the quote (dex routes rank last, so no exchange route follows one), the export keeping the private title (the name-suppression rule in [Transaction identity](#transaction-identity)), a render-time guard that no caller can trip, and a shared show-once warning helper that would rewrite code this branch does not touch. + +### Phase 24: the picker row names the payout its pick gives + +- **Sketched:** a reviewer bot finding that the picker's first row mislabels a token payout, and a medium self-review of the gui branch. +- **Shipped:** the first row is labelled from Stealth alone, since picking it clears the adopted chain. A token source without Stealth is offered its own chain's native coin as a separate row, rows are deduplicated by key so a native source still lists its chain once, and re-picking the asset already shown changes nothing. From the self-review, the doc comment for `handleUnparsedAddress` moved onto that function from `adoptCrossChainDestination`, where it described the wrong contract. +- **Diverged:** [Phase 18](#phase-18-one-answer-to-what-the-recipient-receives) settled the row by asking "is this a swap" of the current state. That is the right question for the row under the picker and the wrong one for a picker row, which must answer it for the state its own pick produces. Removing the source-chain row there also removed a real choice for token sources, whose native payout on the same chain was reachable only by pasting an address. +- **Rejected:** one self-review finding, with evidence. It said a new address on a memo-required chain keeps a tag adopted for the previous one. The address tile offers its entry buttons only while it is empty, and emptying it runs the reset that clears the tag, so no path replaces an address with its tag still set. + +### Phase 25: the kill switch reaches the send scene, and quotes are closed + +- **Sketched:** five reviewer threads and two more from the bot's next pass, on the gui branch. +- **Shipped:** the send scene honors the info server's swap kill switch, so an asset withdrawn from swapping cannot be reached through a swap-send ([Provider availability versus exchange settings](#provider-availability-versus-exchange-settings)); proven on device with a local `disableAssets` entry for Tron USDT, which showed the Exchange scene's error in place of a quote, and again unmodified against the live info server, whose list names Zano as a swap source, so a Zano swap-send stops at the same error. Every swap quote the scene will not hold is now closed, so the core releases the synthetic destination wallet ([Section 8](#8-the-send-scene-ux-end-to-end)); [Phase 19](#phase-19-the-branch-rebased-and-read-against-the-repos-own-checklist) made that release reference-counted in the core, but nothing on the gui side ever closed a quote, so the count never reached zero. On device, re-quoting a MON to ETH swap-send from 12 to 11 USD replaced the held quote with a fresh expiry, and sliding the replacement reached the broadcast, so the held quote was not closed under the approval; the broadcast itself failed at Monad's broadcast endpoints, which fail a plain same-chain MON send the same way. +- **Rejected:** five findings, each with evidence. Two targeted code that arrived from develop after this branch's base. One said an unrecognized quote error loses the provider's message, but the fallback renders it verbatim and the slider re-arms. Two named the Learn-more link, which is the placeholder the task specifies and already a merge blocker until a first-party support article replaces it. + + +### Phase 26: a tag on a same-chain address, and the Myself coin + +- **Sketched:** nine reviewer-bot threads on the gui branch. +- **Shipped:** a destination tag written into a same-chain URI reaches the Destination Tag row. The source wallet parses such an address itself, so the tag arrives as the parsed URI's `uniqueIdentifier` rather than through the cross-chain detector, and the quote builds its memo from the Destination Tag row alone; before, `ripple:
?dt=` pasted with Stealth on quoted a swap-send with no tag. Arming Stealth after a plain send already carries a memo on a tag-required chain seeds the row from that memo too. On device, both orders (Stealth on then the URI, and the URI then Stealth on) filled the Destination Tag row with `12345` from an XRP wallet. The "Myself" picker offers a token source its own chain's coin, and picking it adopts that payout, matching the recipient-asset picker from [Phase 24](#phase-24-the-picker-row-names-the-payout-its-pick-gives). On device, Myself from a Tron USDT wallet listed the account's TRX wallets, and picking one set the recipient row to Tron (TRX). `telos` left the chain table, per [Cross-chain address entry](#cross-chain-address-entry). +- **Diverged:** the Telos row was added by pluginId intersection, which assumed one pluginId names one network. Edge's `telos` is the EOSIO chain and the provider's is its EVM sibling; the live chain list gives chain id 40 and a `0x` address pattern for it. +- **Rejected:** six findings, each with evidence. Two targeted MAX and multi-recipient handling that arrived from develop after this branch's base. A swap-send cannot gain a second recipient: Add Another Address is hidden while `swapSendActive`, and the Stealth toggle, the recipient-asset picker and Myself all refuse while there are several targets. The PIN limit does see a receive-priced swap-send, because the quote writes its `fromNativeAmount` onto the spend target in the same update that arms the slider. The new modal `testID`s break no maestro flow; every `undefined.clearIcon` selector targets an input outside those three modals. The Learn-more gist is the placeholder the task names and already a merge blocker. + +### Phase 27: the review that moved the seams + +- **Sketched:** a human review across all three PRs. The send-scene UX and the plugin stayed; the requests moved three seams. The destination becomes a request type of its own, the transaction identity becomes a plugin-written action, and the chain URI parser becomes reachable without a wallet. It also found a correctness bug: a plain Swap & Send hid its recipient everywhere, against the identity table in [Transaction identity](#transaction-identity), because the details scene hid the payout address for every Houdini swap. +- **Shipped:** in the core, `EdgeSwapSendRequest` beside a wallet request whose `toWallet` is required again ([The request contract](#the-request-contract)); the synthetic wallet taking an `EdgeAddress` list; `EdgeTxActionSwapSend` with a `privacy` flag and no payout wallet ([The send action](#the-send-action)); and `EdgeCurrencyConfig.parseUri` plus `EdgeParsedUri.addressTypes` ([Chain URI parsing without a wallet](#chain-uri-parsing-without-a-wallet)). In the plugin, the `swapSend` action written for a synthetic destination, which `makeSwapPluginQuote` now accepts ([Naming the send](#naming-the-send)). In the gui, the flat send request; the post-approval stamp removed along with its fee-row stamp, its fee-row predicate, and the fail-closed fee-row rule, since the plugin's action is on both rows from the start; the transaction title read off `privacy` and the asset pair; the payout address hidden only when `privacy` is set, which fixes the plain Swap & Send finding; `requireDestinationWallet` removed with its call sites; `SwapConfirmationScene` carrying the request and options the quotes were fetched with, so the expiry re-quote reuses them verbatim; a Stealth Swap with no private route showing its error with the toggle left on, where it had turned the toggle off and re-quoted a transparent swap; the Learn-more link moved beside the plugin id; and the hand-written URI splitter reduced to a scheme and chain-id peek, with the picked chain's own `parseUri` supplying the address, the native amount and the memo ([Payment URI amounts](#payment-uri-amounts)). +- **Diverged:** the chain table's address patterns stopped deciding validity. They pick which chains to offer and nothing else, because the chain's own parser now checks the address, including checksums the patterns never could. The URI amount now arrives in the destination's native units, so the display-amount conversion the scene did is gone rather than moved. +- **Held:** two requests were kept as they were, each with a reason. The plugin keeps its synthetic-destination branch for the typed address lookup until a pasted address can carry a real label, which needs `addressTypes` written by a chain plugin first; the review asked for exactly that sequencing. The design doc stays in the branch's first commit rather than a commit of its own, because every revision of it folds into that commit by the repo convention this branch follows, so separating it once would not keep it separate. + +### Phase 28: one parser result from both entry points + +- **Sketched:** a review note on the core PR: `EdgeCurrencyConfig.parseUri` returned the plugin's raw result, while `EdgeCurrencyWallet.parseUri` passed the account's custom tokens to the parser and resolved `tokenId` from the deprecated `currencyCode`. A caller comparing `tokenId` saw `undefined` from one and a resolved id from the other. +- **Shipped:** `parseCurrencyUri` in the core's `custom-tokens.ts`, called by both methods ([Chain URI parsing without a wallet](#chain-uri-parsing-without-a-wallet)). The config method lost its `customTokens` parameter, since the account supplies them. +- **Diverged:** nothing in the gui changed. Its cross-chain reader already treated a non-null `tokenId` as "this URI names a token, refuse it"; that check now also catches a URI naming a token only by currency code. +- **Held:** the wallet method's behavior, which the shared helper reproduces line for line. + +### Phase 29: the swap-send modal names the hold risk + +- **Sketched:** a one-time notice on the send side covering KYC holds and stuck funds, a persistent notice on the scene, and a swap-side message with an option to disable the provider. +- **Shipped:** two more bullets on the swap-send modal ([Saying that a swap is running](#saying-that-a-swap-is-running)): a rare AML/KYC hold, and that a held or failed swap is resolved with the provider. The modal stays informative and never gates the quote. +- **Diverged:** the swap side needed no change. Houdini already has its row in the shared terms modal, whose Reject disables the provider like every other centralized exchange. +- **Held:** the warning card's text, which carries the swap and the wait and no KYC wording, and the send scene's independence from the swap-side setting. + +### Phase 30: errors take the warning card's place + +- **Sketched:** on the kill-switch frame (Zano disabled, send to an ETH address), the "Swap before send" card stacked above the "Exchange Error" card. Review note: show one card at a time, errors superseding the warning. +- **Shipped:** `showErrorCard` gates both swap warnings, and the fixed-to fallback card gates the generic one ([Saying that a swap is running](#saying-that-a-swap-is-running)). +- **Review:** the low self-review found that hiding the generic card behind the fallback card dropped the swap-and-delay notice while the send stayed armed. The fallback card's body now carries that text, keeping one card on screen. +- **Diverged:** nothing. +- **Held:** the pre-existing pending-transaction, Nym and scam cards, which keep their own conditions; they are not swap-send cards and behave as on `develop`. + +### Phase 31: a failed approval locks the slider + +- **Sketched:** a rejected `liveQuote.approve()` showed a swap error and re-armed the slider. `approve` may have broadcast the deposit before it threw, so a second slide could pay the order twice. Review note: apply the same lock as a plain send. +- **Shipped:** the swap-send branch of `handleSliderComplete` sets the broadcast lock before calling `approve`; failures show the "status unknown" card and keep the slider locked, and the quote effect and the wallet-switch reset leave that state alone ([The send scene UX, end to end](#8-the-send-scene-ux-end-to-end)). +- **Diverged:** the boundary sits before `approve` rather than after signing, because the plugin hides its sign and broadcast steps from the scene. +- **Held:** a pending-funds refusal still re-arms, matching the plain path. + +### Phase 32: the provider icon follows the theme + +- **Sketched:** the HoudiniSwap mark is one light color on a transparent background, so it vanished on a light theme. Review note: use the light-theme icon now on the CDN. +- **Shipped:** `getSwapPluginIconUri` sends providers on a one-entry allowlist through `getThemedIconUri`, which asks the CDN for `icon-light.png` on a light theme. +- **Diverged:** nothing. +- **Held:** every other provider keeps the plain `icon.png` path, because the CDN has no light variant for them and the request would fail. Transaction rows were left without a logo until phase 34. + +### Phase 33: the Learn-more placeholder ships + +- **Sketched:** the spec has "Learn more" open a gist as a placeholder, final URL to be decided. Earlier phases called replacing it a merge blocker, and a security review of the rebased branch raised the gist again. +- **Shipped:** the comment on `STEALTH_LEARN_MORE_URI` now says what the spec says: a placeholder to replace once the first-party article is published. No behavior changed. +- **Diverged:** the merge-blocker wording from phase 25 is withdrawn. It was this branch's own caution, stricter than the spec, and the feature lands with the placeholder. +- **Held:** the link still opens in the system browser, outside the app, and the constant stays the single place to change it ([Deferred work](#deferred-work)). + +### Phase 34: the provider logo reaches transaction rows + +- **Sketched:** a HoudiniSwap swap or send showed no logo in the transaction list or on its details scene, where every other provider has one. Review note: this is not deferred work, fix it before the feature lands. +- **Shipped:** `getPluginIdIcon` answers both scenes with the image and a `fit` flag. A provider on the themed allowlist resolves through `getSwapPluginIconUri`, so it gets the same `icon.png` or `icon-light.png` the swap scenes load, with `fit` set: the row and the details tile draw it whole, with no circular mask. Every other provider keeps its one static image from `pluginIdIcons`, drawn for the circle and cropped to it as before. That map is now private to its module so no caller can read it without a theme. +- **Diverged:** the first cut returned only the image and let both scenes crop it to the thumbnail circle like every other provider. The HoudiniSwap logo is the mark above a wordmark that runs edge to edge, so the circle cut the wordmark down to a fragment. Seen on the simulator, then changed to fit the logo, which is how the swap scenes already draw it. +- **Held:** a contact or merchant-name match still outranks the provider logo, as it does for every provider. No `MERCHANT_CONTACTS` entry was added: that map holds one static image per name, which cannot follow the theme. On a transaction row the direction arrow still overlaps the logo's lower right corner, as it does on every thumbnail. A mark-only image drawn for the circle would remove both the flag and the overlap; it needs a new asset on the content server, which is outside this change. + +## 11. Decisions + +### Let a plain send take a dex route + +Houdini serves Monad through [dex](#dex) routes alone. A probe of every source the operator named (ETH, Base ETH, Sonic, TRX, Solana, Polygon as both its coin and its USDC, Avalanche, BSC, Arbitrum and Robinhood Chain, plus BCH, which answers `NO_DEX_ROUTES`) found no private or standard route at around 12 USD on 2026-09-11. The plugin dropped dex routes outright, so a Monad destination could be picked, addressed and priced, and then failed with the provider's own "no enabled exchanges" error. + +A request that did not ask for privacy now takes one when no exchange route fits, ranked below private and standard, and only in the shape Edge can execute: a native coin on an [EVM](#evm) chain, priced on the send side, with no token approval to sign, and an on-chain call rather than one Houdini broadcasts itself. A Stealth send never takes one, because a dex route is a contract call the user's own wallet signs, which links both sides in public. That is the same reason a privacy request declines a standard route. + +Rejected: leaving dex routes out. It keeps one rule for every route type, and it leaves Monad unreachable while the destination picker still offers it. + +Reopen if the provider starts serving Monad over an exchange route, or if a token source has to reach one of these destinations, which needs the approval and signature flow this scope leaves out. + +### Two acknowledgements rather than one + +The send scene and the dedicated swap scene each get their own one-time modal, with their own persistence. + +The alternative was to call `swapVerifyTerms` from the send scene too, so one `agreedToTerms` covered both. It loses on what the two modals are for. The terms modal is a provider consent gate: it names the provider, links its terms, privacy and know-your-customer pages, and disables the provider on a decline. The send-scene modal answers a different question, which is why this send now takes two transactions and longer than the user expects, and it must not disable anything, because a user who dismisses it still wants to send. Folding them would have meant one of the two texts always being wrong for the scene it appeared on. + +A second alternative was to show only the card and drop the send modal. The card is passive and lives beneath the amounts, so it can be scrolled past on the one send where the shape is genuinely new to the user. Reopen either if the two-modal sequence turns out to fire back to back for a user who reaches the swap scene and the send scene in the same session. + +### Build the synthetic destination wallet in the core + +Chosen: the core builds and bridgifies the destination wallet from an `EdgeSwapSendRequest`, a request type of its own beside `EdgeSwapRequest`. + +Evidence: a GUI-built fake was implemented first in the prototype. Its function properties do not survive the [yaob](#yaob) wire format, so plugin method calls on it fail once the object crosses into the core. + +Rejected: **GUI-built fake wallet** lost on the bridge finding above. **A new plugin-facing API** (`fetchSwapQuoteToAddress` or similar) lost because it forks every swap plugin's entry point to serve one provider; the synthetic wallet lets unmodified plugins participate. **Passing the address as a loose parameter alongside `toWallet`** lost because every plugin would need to know which of the two to trust. **An optional descriptor field on `EdgeSwapRequest`, with `toWallet` made optional**, shipped first and was replaced in [Phase 27](#phase-27-the-review-that-moved-the-seams): it made `toWallet` optional for every existing caller and left the exactly-one rule to a runtime check the type could state. + +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. Each row resolves to its chain's pluginId rather than its display name, so two chains sharing a name cannot collapse into one row. + +Evidence: a bare `0x` address matches roughly 14 [EVM](#evm) chains in the shipped table; a 42-character bech32 Bitcoin address also matches eCash. There is no information in the address itself that resolves this. + +Rejected: **pick the highest-priority match** lost because a wrong guess sends real funds to a chain the recipient does not control, which is unrecoverable. **Reject ambiguous addresses** lost because it would refuse the single most common case, an Ethereum address, which is the exact bug being fixed. **Infer from the source chain** lost because there is no correlation. + +Reopen if: the provider exposes a chain-resolution endpoint, or Edge gains an address-book that already knows the recipient's chain. + +### Correct the provider's address regexes rather than route around them + +Chosen: fix the Cardano, PIVX, Dash and Monero entries in `HOUDINI_CHAINS`. + +Evidence: the published Cardano pattern ends in `|^[a-zA-z0-9]*|[0-9A-Za-z]{45,65}$`. The first alternative is unanchored and zero-length, so it matches every string including empty. An audit script over every entry found this was the only catch-all, and that PIVX writes `A-z`, a character class that also spans the six punctuation characters between the alphabet halves. Dash writes `[X|7]` and Monero `[a-zA-Z|\d]`; inside a character class `|` is a literal, so both accepted a pipe as an address character. + +Rejected: **exclude Cardano from detection only** lost because it leaves the bug live on the shipped feature. Since [Phase 27](#phase-27-the-review-that-moved-the-seams) the pattern no longer decides validity (the picked chain's `parseUri` does), but a prefilter that matches every string still offers Cardano for every paste. **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 when written 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: `EdgeTxActionSwapSend`, a `swapSend` action type in the core carrying `privacy`, written by the swap plugin with the transaction. + +Evidence: the three flows are indistinguishable from the swap fields alone. They all carry `swapInfo`, `orderId`, `payoutAddress` and a from/to asset pair. Two facts separate them: the destination was a pasted address, and the route was private. The plugin holds both, since it sees the synthetic destination and the request's `privacy`, and it writes the action before broadcast, so the transaction and its parent fee row are saved with it. + +Rejected: **a metadata-name or category convention**, which is a magic string a user edit can destroy and no type can enforce. **An optional `swapType` on `EdgeTxActionSwap`, stamped by the send scene after approval**, which shipped first and was replaced in [Phase 27](#phase-27-the-review-that-moved-the-seams). The stamp was a second write after a send that had already succeeded, so it could fail silently, and the parent fee row needed its own stamp and its own fail-closed display rule to cover that. It also left `payoutWalletId` naming a synthetic wallet no consumer could resolve. The dedicated action type has no payout wallet field, and every existing swap consumer that should read a send (the details card, the exchange category, the transaction title) was taught the new type once. + +Reopen if: a second provider starts serving sends to addresses, which would test whether every plugin can be trusted to write the send shape. + +### Suppress the recipient in the UI, keep it in storage + +Chosen: a stealth send keeps `payoutAddress` on its saved action and hides it in every rendered surface. + +Evidence: support traces stuck orders by payout address, and losing it would make a failed private send unrecoverable. The privacy boundary this feature defends is the on-chain link between source and destination, which storing the address locally does not weaken: device-level access to the transaction file already implies access to the keys. + +Rejected: **not storing the address**, which buys no privacy against any attacker who is not already inside the device, and costs every future support ticket. + +Reopen if: the threat model grows to include an attacker with read access to wallet files but not keys. + +### Gate swap-send behind the PIN spending limit + +Chosen: the [PIN spending limit](#pin-spending-limit) applies to a swap-send exactly as it applies to a plain send. The limit is computed in one handler that both the makeSpend path and the swap-send path call, the check runs before either submit path, and the swap-send slider carries the same gate and the same prompt. + +Evidence: this reverses the earlier "not doing" entry, which reasoned that swap-send should match the existing swap flow because it is built on a swap quote. That comparison was wrong at the level that matters. A wallet-to-wallet swap moves funds between two wallets the user already controls, so a PIN prompt buys nothing; a swap-send moves funds to an arbitrary external address and is the exact operation the limit exists to gate. Reasoning from the implementation (it uses a swap quote) instead of from the user-visible operation (it is a send) is what produced the hole. Three separate gates were missing as a result: the submit-path check, the flag computation, and the slider's prompt, each bypassed by a different early return. + +Rejected: **matching the swap flow for consistency**, which is consistency with the wrong sibling. The plain-send path next to it in the same scene is the correct reference. + +Reopen if: the swap flow itself gains send-to-address destinations, at which point it needs this gate too rather than the reverse. + +### Share the swap error mapping instead of a catch-all card + +Chosen: the send scene maps a failed quote through the same `processSwapQuoteError` the wallet-to-wallet swap flow uses, extracted to `src/util/swapErrorDisplay.ts`, and wraps the result in an `I18nError` so `ErrorCard` renders the specific title and body. + +Evidence: `ErrorCard` renders anything that is not an `I18nError` as "Unexpected Error" with a canned "an unexpected error occurred, check your network connection" body and a Report Error button. Every swap-send failure took that path, so a user 0.1 TRX under the floor, a user on an unroutable pair, and a user with a genuine bug all saw the same card. The swap flow already had the mapping; the send scene simply was not using it. Verified on device: a 10 TRX send-to-address against a 10 USD provider floor now reads "Exchange Error / HoudiniSwap: Amount is too low, minimum is 10 USD". + +Rejected: **a bespoke error map for the send scene**, which would drift from the swap flow's within a release, and **passing the raw error through**, which loses the localized limit formatting the swap flow already does. + +Reopen if: the provider starts returning structured limit data on the error responses, which would let the plugin raise a typed `SwapBelowLimitError` for the 422 band instead of a plain message. + +### Ask the request for privacy, do not infer it from the route + +Chosen: `EdgeSwapRequest` carries an optional `privacy: 'required'`, and the Houdini plugin filters to private routes when it is set. A plain Swap & Send omits it and may take a standard route. + +Evidence: Houdini returns both route types above 25 USD and standard only between 10 and 25. With a single filter there is no correct setting: private-only breaks every plain swap-send in the 10-to-25 band, and accepting standard hands a Stealth send a single-leg route it cannot detect. A quote carries no route type back to the caller, so the downgrade would be invisible. Making the caller state its requirement puts the decision where the intent lives, and the plugin's obligation becomes explicit: decline rather than substitute. + +Rejected: **inferring privacy from whether the destination is same-asset**, which is wrong in both directions (a cross-asset stealth send needs privacy; a same-asset plain send does not exist). **A Houdini-specific `userSettings` flag**, which is account-wide where the requirement is per-request. **Reading the route type off the returned quote and re-requesting**, which spends two calls against a rate limit to answer what one flag settles. + +Reopen if: a second privacy provider appears, which would probably promote `'required'` into a small enum (`'required' | 'preferred'`) so an aggregator could rank rather than filter. + +### Validate the provider's stated minimums before hardcoding them + +Chosen: the three floors were probed against the live v2 API, then written down as named constants with the provider's own guidance quoted beside them, and enforced client-side before any request. + +Evidence: the numbers came from a feedback email, and an email is not a contract. The probe confirmed all three, which is the outcome that makes the constants trustworthy rather than the outcome that makes them interesting. Enforcing before the request also protects the rate limit, which the same feedback round identified as the thing that poisoned earlier availability readings. + +Rejected: **trusting the stated figures unverified**, which would have shipped an untested assumption into a gate that blocks sends. **Discovering the floor from quote errors alone**, which spends a request to learn a constant and gets throttled for it. **Scattering the numbers at the call sites**, which is how the next reader ends up with two different answers for the private floor. + +Reopen if: the floors move, or the provider exposes them per pair on the token or chain metadata, at which point they should be read rather than declared. + +### Show the order details on a stealth transaction, hide only the address + +Chosen: `SwapDetailsCard` renders for every swap-send, resolving the payout asset from the saved action when there is no payout wallet. `hidePayoutAddress` continues to mask the address alone. + +Evidence: the reason for keeping `payoutAddress` in storage is that support must be able to trace a stuck order. That argument requires the order id, provider, and amounts to be readable, so hiding them defeats the thing the storage rule was protecting. The card was in fact rendering nothing at all for every swap-send, private or not, because it bailed when the destination wallet lookup failed, and a synthetic payout wallet id never resolves. + +Rejected: **hiding the whole card for stealth transactions**, which is the outcome the bug produced by accident and which no one wanted. **Keeping the source wallet as the fallback config**, which resolves the payout currency code against the wrong chain and was the reason `payoutTokenId` stayed unset. + +Reopen if: order ids themselves become privacy-sensitive, which would argue for masking them in screenshots rather than removing them. + +### Scope the exchange-provider setting to the Exchange scene + +Chosen: the send-scene stealth and swap-send path ignores the global HoudiniSwap enable flag; the Exchange scene keeps honoring it. + +Evidence: the setting lives in Exchange settings and reads as a list of providers the swap aggregator may use. A private send is a send that happens to be powered by Houdini, so a user turning off a swap provider is not asking for private sends to disappear, and would have no way to connect the two if they did. + +Rejected: **honoring the flag on both paths**, which makes a send feature vanish with no explanation reachable from the send scene. **A second, send-specific toggle**, which is a settings row asking users to understand our provider topology. + +Reopen if: Houdini becomes one of several privacy providers, at which point the send path needs its own notion of which to use and the question changes shape. + +### Match the pre-existing spending-limit arithmetic + +Chosen: the PIN spending limit compares the sum of the spend targets against the limit and ignores the network fee, on the swap-send path exactly as on every plain send. + +Evidence: `origin/develop` computes it this way for every send in the app. The limit is a setting about how much a user may move without re-authenticating, and users read the amount they typed as the amount they are moving. Making one path add a fee the user never entered would give the same setting two meanings depending on which screen they were on, and the disagreement would surface as an unexplained PIN prompt just under a round number. + +Rejected: **adding the fee on the swap-send path only**, which is the version that creates the inconsistency. **Changing every path to include the fee**, which is a real product change to a security setting, affects flows this work does not touch, and would want its own task rather than riding in on a private-send feature. + +Reopen if: the limit is deliberately redefined as total wallet outflow, in which case both paths change together. + +### Let the token lookup decide what is served, and cache its misses + +Chosen: the chain table stays a pure name map, and whether a chain is actually served is answered at runtime by `resolveTokenId`, which memoizes misses as well as hits. + +Evidence: both routes already end in the same place. `checkWhitelistedMainnetCodes` throws `SwapCurrencyError` for an unmapped chain, and `resolveTokenId` finding no match throws the same error, so the provider declines correctly either way and the aggregator moves on. The only real difference was cost: the miss was never cached, so a chain the provider serves no native for spent a `GET /tokens` call on every quote against a rate-limited API. Caching the miss removes that, and it removes the reason to encode servedness in the table at all. + +Rejected: **hardcoding `null` for `celo`, `fantom`, `polkadot` and `ton`**, which is what shipped first. It reads as a fix but it is a snapshot of a live fact, so it rots the day Houdini adds a native and nothing tells us. The claim recorded for it was also wrong: it said a mapped-but-unserved chain surfaced an error where a `null` would have declined, and both paths raise the same error. **Caching failed lookups too**, which is cheaper still and wrong: a 429 would mark a perfectly good chain dead for the session. + +Reopen if: the provider exposes chain metadata through the swap plugin surface, which would let the name map itself stop being a snapshot. + +### Retry only as long as the thing you are retrying survives + +Chosen: a rate-limited call waits the window the provider asked for, unless the wait would land past the expiry of what it is sending, in which case it fails as a rate limit right away. + +Evidence: the two halves only look contradictory. Our own exponential cap must never truncate the provider's `retryAfter`, because retrying inside the window just draws another 429 and burns the budget, which is what a 30s ceiling did to Houdini's roughly 60s exchange window. But a quote id lives about 60 seconds, so honoring that same window and then re-POSTing the same quote id hangs the user for a minute and fails as an expired quote, which blames the wrong thing. Passing the quote's own expiry into the fetch resolves both: a short window with time left still retries, a long one fails immediately with the accurate reason. `validUntil` arrives as Unix seconds inside a string, which `new Date` reads as an invalid date, so the parse reads the number first or the guard can never fire. + +Rejected: **never retrying create-exchange**, which throws away the cases where the window is short and the quote has time left. **Capping the wait and retrying anyway**, which is the shipped-then-fixed version: it produces a wrong error message after a wasted wait. + +Reopen if: the provider issues quote ids that outlive their pricing, or exposes a re-quote endpoint cheap enough to re-price inside the retry. + +## 12. Glossary + +### BIP-21 + +Bitcoin Improvement Proposal 21, the `bitcoin:
?amount=` payment URI scheme that every Bitcoin-family QR encodes. Its `amount=` parameter is one of the two the URI splitter reads, and its `:` prefix is what lets a scanned code name its own chain instead of leaving the app to guess from the address format. [Specification](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki). + +### cashaddr + +Bitcoin Cash and eCash address encoding, whose canonical on-chain form keeps its `bitcoincash:` or `ecash:` prefix rather than dropping it the way a BIP-21 URI does. The prefix therefore cannot be stripped blindly, which is why the URI splitter returns the raw text as its first address candidate before it returns the stripped path. [Specification](https://reference.cash/protocol/blockchain/encoding/cashaddr). + +### CORS + +Cross-Origin Resource Sharing, the browser rule that attaches `Origin` and `Sec-Fetch-*` headers to a request and lets the server refuse it on that basis. Swap plugins run inside the core's WebView, so `io.fetch` looks like a browser to Houdini's server-to-server partner API, which answers 403; every call passes `corsBypass: 'always'` to route through the native fetch instead. [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS). + +### DEX + +Decentralized exchange, a swap venue that settles on chain through a contract rather than through a custodial order book. Houdini offers dex routes as a third route type below `private` and `standard`, with its own 5 USD floor; this feature never takes one, since a dex route cannot break the on-chain link the feature exists to break. See [Route selection](#route-selection) and [ethereum.org](https://ethereum.org/en/decentralized-exchanges/). + +### EIP-681 + +Ethereum Improvement Proposal 681, the `ethereum:
@?value=` payment URI scheme. Its `pay-` prefix and `@chainId` suffix are stripped to recover the bare address, and its `value=` parameter is deliberately ignored because it is denominated in wei rather than in display units. [Specification](https://eips.ethereum.org/EIPS/eip-681). + +### EVM + +Ethereum Virtual Machine, the execution environment Ethereum and the chains compatible with it share. Compatibility includes the 20-byte `0x` address format, so roughly fourteen chains in the chain table accept an identical-looking address and a bare `0x` string cannot name its own chain. That is the ambiguity the network picker exists for; see [Decision: ask the user when the address format is ambiguous](#ask-the-user-when-the-address-format-is-ambiguous). [Specification](https://ethereum.org/en/developers/docs/evm/). + +### FIO + +Foundation for Interwallet Operability, a protocol whose payment requests Edge can fulfil from the send scene. A FIO request pre-fills the send and takes over parts of its completion, so `swapSendAllowed` excludes it and those sends keep their existing behavior untouched. [Protocol documentation](https://dev.fio.net/). + +### IBC + +Inter-Blockchain Communication, the Cosmos-ecosystem transfer protocol. Houdini reports no `memoNeeded` flag and a permissive `^.*$` address validation for its IBC-family chains, so their payout semantics are not trustworthy enough to offer and the plugin maps them to `null`. See [Chain mapping](#chain-mapping) and the [protocol documentation](https://ibcprotocol.dev/). + +### Maestro + +The YAML-driven UI test runner the repo drives the simulator with. `maestro/14-stealth/` holds one flow per user-visible branch of this feature, composed from subflows in `maestro/common/`. [Documentation](https://docs.maestro.dev/). + +### Memo + +A short payload some chains require alongside a payment so the receiving exchange can credit the right account, called a destination tag on XRP. Five chains in the table need one, the send scene shows a tag row for them, and the value rides the send request's `toMemos` to the core and reaches the provider as `destinationTag` on order creation. See [The request contract](#the-request-contract) and the XRP Ledger's [destination tag documentation](https://xrpl.org/docs/concepts/transactions/source-and-destination-tags). + +### PIN spending limit + +An Edge account setting that re-prompts for the PIN once a single send exceeds a configured fiat amount. A swap-send moves funds to an arbitrary external address, so it is gated exactly like a plain send; see [Decision: gate swap-send behind the PIN spending limit](#gate-swap-send-behind-the-pin-spending-limit). The setting itself is [`src/reducers/SpendingLimitsReducer.ts`](https://github.com/EdgeApp/edge-react-gui/blob/develop/src/reducers/SpendingLimitsReducer.ts). + +### Synthetic destination wallet + +The core-built object that stands in for a destination the user does not own. It is backed by the real `EdgeCurrencyConfig`, so `currencyInfo` and `allTokens` are authentic, while its address accessors return the pasted addresses and its `getMemos` returns the request's memos. Swap plugins receive it as an ordinary `EdgeCurrencyWallet` and need no knowledge of addresses-instead-of-wallets. Defined in [The synthetic wallet](#the-synthetic-wallet), from [`src/core/swap/synthetic-wallet.ts`](https://github.com/EdgeApp/edge-core-js/blob/master/src/core/swap/synthetic-wallet.ts). + +### UTXO + +Unspent transaction output, the accounting model Bitcoin-family chains use, in which a transaction spends whole prior outputs and may pay several recipients at once. Multi-recipient sends therefore exist only on these chains, and they are gated against swap-send in both directions; see [Multi-recipient gating](#multi-recipient-gating) and the [Bitcoin developer guide](https://developer.bitcoin.org/devguide/transactions.html). + +### yaob + +Yet Another Object Bridge, the RPC layer that carries objects between the app's JavaScript context and the core's WebView. It transports data, not behavior: a plain object's function properties do not survive the wire format. That is why a GUI-built destination wallet fails once it crosses into the core, and why the synthetic wallet is built on the core side instead. [Package](https://github.com/swansontec/yaob). + +## 13. References + +- [Asana task 1216251688512498](https://app.asana.com/0/1215088146871429/1216251688512498) +- [edge-react-gui#6066](https://github.com/EdgeApp/edge-react-gui/pull/6066), [edge-core-js#730](https://github.com/EdgeApp/edge-core-js/pull/730), [edge-exchange-plugins#469](https://github.com/EdgeApp/edge-exchange-plugins/pull/469) +- Prototypes: [edge-react-gui#6054](https://github.com/EdgeApp/edge-react-gui/pull/6054), [edge-react-gui#6031](https://github.com/EdgeApp/edge-react-gui/pull/6031) +- HoudiniSwap v2 partner API, mainnet native-token snapshot dated 2026-07-30 in `src/util/houdiniChains.ts` + +## 14. Post-implementation retrospective + +### Estimate vs. actuals + +| Phase | Sketched as | Actual | +|---|---|---| +| Core seam | One optional request field | One field plus a synthetic wallet module, a request resolver, and an error-path fix (435 lines, 7 files) | +| Plugin | A standard central-exchange plugin | Standard shape plus two non-obvious transport constraints (no-`Bearer` auth, forced [CORS](#cors) bypass) and a route-type filter | +| Send scene | A selector and a toggle | 847 lines changed in one file: linked amounts, expiry re-quoting, destination tags, gating, and two address-entry hooks | +| Address entry | Reuse `AddressTile2` unchanged | Two new hooks and a chain-detection module, after a user report | +| Chain metadata | Four chains | 36 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 a cross-chain address validator (now `parseCrossChainAddress`), 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 "the provider has no route for this pair" and "something went wrong". Phase 5 closed the UI half of this: a missing route now turns its control off with an explanation ([Availability fallbacks](#availability-fallbacks)). Raising the availability change itself with the provider remains open. +3. **The provider's published metadata was assumed correct.** The chain table was written as a faithful snapshot. Two of its regexes are defective, one of them so permissive it matches every string. Snapshotting external validation data needs an audit pass, not just a transcription. +4. **PIVX payouts are unusable and the design cannot tell.** A PIVX order returns a deposit address that is not a PIVX address (`EXMD…` rather than base58 `D…`), so the send fails at spend time with an opaque wallet error. Reproduced directly against the API with the plugin's own payload shape. The design has no validation of provider-returned deposit addresses against the from-chain. +5. **Undocumented intent regressed in code.** The phase 2 followup made send-to-any Houdini-exclusive, but the change was autosquashed into the feature commit and neither the PR body nor this document was updated. Phase 5 then read the unconditional restriction as drift against the documented fan-out and "fixed" it, shipping a live regression that phase 6 had to revert on operator correction. The lesson: an operator-directed behavior change must update the PR body and this document in the same turn it lands, because both are treated as behavior contracts by later work, and a squashed history cannot testify to intent. +6. **The chain table was never checked against the routes it claims.** Item 3 caught bad regexes by reading them. Nobody asked the prior question: does the provider serve each of these chains at all? Phase 10 asked it and got two answers, both bad. Four chains (`celo`, `fantom`, `polkadot`, `ton`) have no mainnet native in the API, so every quote naming one spent a token lookup to be told nothing, on a rate-limited API, with the miss uncached. Six more (`algorand`, `ecash`, `hyperevm`, `sonic`, `stellar`, `zcash`) had natives the plugin could never find, because the API returns `address: ""` for those chains rather than `null` and the native lookup tested `address == null`, which is false for an empty string. Ten of 38 advertised destinations were dead, and nothing in the type system, the tests, or a code review could have seen it: the defect lives in the agreement between a snapshot and a live API. A table transcribed from an external source needs a periodic reconciliation against that source, and every field the code branches on needs one live case exercising each branch. + +7. **Missing funds were treated as a precondition instead of a task.** Phase 10 found the six empty-string chains, fixed them, confirmed the fix was present in the installed bundle, and then wrote "drive them once one is funded" as a follow-up, because none of those assets held a balance. That was the wrong shape of answer. Funding a wallet is a swap away, the principal stays inside the account, and only the spread and network fee are spent, so "unfunded" is a step to perform rather than a blocker to report. The cost of getting it wrong was a full extra phase before the highest-value fix of the round was exercised at all. Phase 11 funded Sonic by swapping into it and executed a private send between two of the six, which took under twenty minutes and about a dollar. The general rule this leaves behind: when a test needs an asset the account does not hold, acquire it and continue, and treat any follow-up phrased as "once X is funded" as a task that was skipped rather than one that was blocked. + +8. **The lookup's own checks lost two served chains.** Phase 10 read TON as unserved and took it out of the table. It is served: its coin's row carries a contract-style address, and the lookup rejected any native with an address. The same lookup compared each row's `chain` with the queried name, and `bitcoincash` answers with rows on `bch`, so Bitcoin Cash never resolved in either direction while it sat in both tables. A live replay of the plugin's own predicate against every mapped chain (2026-09-10) showed both. The tests could not, because the scripted catalogue echoed the queried chain and ignored `mainnet=true`; it now answers the way the live catalogue does. + +### 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. Its replacement in [Phase 27](#phase-27-the-review-that-moved-the-seams), a request type of its own, changed the contract's shape and left the plugin's destination reading as it was. +- Routing memos through `getMemos` on the wallet rather than as a descriptor field the plugin reads. Plugins kept one code path for destination memos. +- The `swapSendAllowed` predicate. Every constrained caller was excluded by construction, and no regression in payment protocol, [FIO](#fio), or deep-link sends appeared across four phases of testing. +- Extracting `PriceImpactText` instead of recreating it. The swap confirmation scene and the send scene have not drifted. + +### Verification highlights + +- Four real on-chain executions across the phases, txids in [Section 9](#9-testing), including the reported cross-chain address-entry path end to end. +- 41 unit tests covering the URI splitter and chain detection, including a regression test that fails if the Cardano catch-all pattern returns. +- 10-case entry-path and chain matrix on device covering BTC, LTC, ETH, POL, SOL, plus USDC and USDT token sources, with screenshots attached to [#6066](https://github.com/EdgeApp/edge-react-gui/pull/6066). +- `verify-repo.sh` green on the gui: 605 tests, 95 suites, 107 snapshots. diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 58ba92ab788..e9bbcd24e1d 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -958,6 +958,7 @@ const strings = { transaction_details_error_invalid: 'Invalid Transaction', sub_category_label: 'Sub-category', transaction_details_recipient_addresses: 'Recipient Addresses', + transaction_details_exchange_deposit_address: 'Exchange Deposit Address', transaction_details_advance_details_header: 'Advanced Details', transaction_details_advance_details_fee_setting: 'Fee Setting', transaction_details_advance_details_device: 'Device', @@ -1001,6 +1002,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', @@ -1273,6 +1277,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: @@ -1674,6 +1680,60 @@ 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_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_swap_send_warning_title: 'Swap before send', + stealth_swap_send_warning_title_private: 'Private swap before send', + stealth_swap_send_warning_body: + 'Your funds are swapped before they reach the recipient.', + stealth_swap_send_warning_body_private: + 'Your funds are swapped over a private route before they reach the recipient.', + transaction_may_take_longer: + 'This transaction may take longer than usual to complete.', + stealth_swap_send_modal_title: 'This send uses a swap provider', + stealth_swap_send_modal_message_kyc: + 'In rare cases, the provider or its exchange partners may hold a transaction for AML/KYC review and ask you to verify your identity before releasing the funds.', + stealth_swap_send_modal_message_recovery_1s: + 'A held or failed swap is resolved with the provider. %1$s cannot cancel, refund, or recover funds once they are sent.', + stealth_swap_send_modal_message_2s: + '%1$s reaches this recipient by swapping through %2$s. Your wallet pays the provider, and the provider pays the recipient.', + stealth_swap_send_modal_message_timing: + 'The send is not complete until the provider forwards the funds. Expect it to take longer than a normal send.', + stealth_detected_network_title: 'Which network is this address on?', + stealth_detected_network_message: + 'This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.', send_scene_error_title: 'Error:', 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 1011c0097c7..bcb0173fbd9 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -758,6 +758,7 @@ "transaction_details_error_invalid": "Invalid Transaction", "sub_category_label": "Sub-category", "transaction_details_recipient_addresses": "Recipient Addresses", + "transaction_details_exchange_deposit_address": "Exchange Deposit Address", "transaction_details_advance_details_header": "Advanced Details", "transaction_details_advance_details_fee_setting": "Fee Setting", "transaction_details_advance_details_device": "Device", @@ -796,6 +797,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", @@ -1008,6 +1012,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)", @@ -1312,6 +1318,42 @@ "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_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_swap_send_warning_title": "Swap before send", + "stealth_swap_send_warning_title_private": "Private swap before send", + "stealth_swap_send_warning_body": "Your funds are swapped before they reach the recipient.", + "stealth_swap_send_warning_body_private": "Your funds are swapped over a private route before they reach the recipient.", + "transaction_may_take_longer": "This transaction may take longer than usual to complete.", + "stealth_swap_send_modal_title": "This send uses a swap provider", + "stealth_swap_send_modal_message_kyc": "In rare cases, the provider or its exchange partners may hold a transaction for AML/KYC review and ask you to verify your identity before releasing the funds.", + "stealth_swap_send_modal_message_recovery_1s": "A held or failed swap is resolved with the provider. %1$s cannot cancel, refund, or recover funds once they are sent.", + "stealth_swap_send_modal_message_2s": "%1$s reaches this recipient by swapping through %2$s. Your wallet pays the provider, and the provider pays the recipient.", + "stealth_swap_send_modal_message_timing": "The send is not complete until the provider forwards the funds. Expect it to take longer than a normal send.", + "stealth_detected_network_title": "Which network is this address on?", + "stealth_detected_network_message": "This address belongs to another network. Choose the network the recipient is on and Edge will convert your %1$s for them.", "send_scene_error_title": "Error:", "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/CdnUris.ts b/src/util/CdnUris.ts index d633fe30bb5..e207b0cb9a6 100644 --- a/src/util/CdnUris.ts +++ b/src/util/CdnUris.ts @@ -19,11 +19,25 @@ export const getBorrowPluginIconUri = ( ).symbolImage } +/** + * Swap plugins whose icon is a single-color mark on a transparent background. + * The CDN has an `icon-light.png` beside their `icon.png` for light themes. + */ +const themedSwapPluginIcons = new Set(['houdini']) + +export function hasThemedSwapPluginIcon(pluginId: string): boolean { + return themedSwapPluginIcons.has(pluginId) +} + /** * Swap Plugin Icons */ export function getSwapPluginIconUri(pluginId: string, theme: Theme): string { - return `${theme.iconServerBaseUri}/exchangeIcons/${pluginId}/icon.png` + const iconPath = `exchangeIcons/${pluginId}/icon` + if (themedSwapPluginIcons.has(pluginId)) { + return getThemedIconUri(theme, iconPath) + } + return `${theme.iconServerBaseUri}/${iconPath}.png` } /** diff --git a/src/util/corePlugins.ts b/src/util/corePlugins.ts index 5de505de340..57231e414a8 100644 --- a/src/util/corePlugins.ts +++ b/src/util/corePlugins.ts @@ -129,6 +129,7 @@ function buildSwapPlugins(): EdgeCorePluginsInit { changelly: swapInit('changelly'), exolix: swapInit('exolix'), godex: swapInit('godex'), + houdini: swapInit('houdini'), lifi: swapInit('lifi'), letsexchange: swapInit('letsexchange'), mptrade: swapInit('mptrade'), diff --git a/src/util/fake/fakeCurrencyConfig.ts b/src/util/fake/fakeCurrencyConfig.ts index 703b8d70b3c..f87b08d1d46 100644 --- a/src/util/fake/fakeCurrencyConfig.ts +++ b/src/util/fake/fakeCurrencyConfig.ts @@ -36,6 +36,7 @@ export function makeFakeCurrencyConfig( getTokenDetails: async () => [], getTokenId: async () => '', importKey: async () => ({}), + parseUri: async () => ({}), removeCustomToken: async () => {}, watch: () => () => {} } diff --git a/src/util/houdiniChains.ts b/src/util/houdiniChains.ts new file mode 100644 index 00000000000..282fc4bdc13 --- /dev/null +++ b/src/util/houdiniChains.ts @@ -0,0 +1,587 @@ +import type { EdgeTokenId } from 'edge-core-js' + +import type { EdgeAsset } from '../types/types' +import { peekPaymentUri } from './paymentUri' + +/** + * A destination chain HoudiniSwap can pay out to, keyed by the Edge currency + * pluginId. `addressValidation` is Houdini's own per-chain regex, used only to + * prefilter which chain a pasted address could belong to; the chosen chain's + * own `parseUri` is what validates it. `memoNeeded` chains + * show a destination-tag row whose value rides the send request's `toMemos` + * to the plugin and onward as `destinationTag` on order creation. + */ +export interface HoudiniChain { + pluginId: string + houdiniShortName: string + memoNeeded: boolean + /** + * Whether Houdini can route this asset to ITSELF privately, from the + * `hasSelfPrivate` flag on their token query. Same-asset private is their + * dominant flow, and it is an asset capability rather than a per-pair + * verdict, so it reads off the token metadata with no quote and no probing. + */ + hasSelfPrivate: boolean + + /** + * The chain's EVM network id, for the chains that have one. A payment code + * for any EVM network is written `ethereum:
@`, so the + * scheme names only the family and this is what names the chain. Absent for + * everything that is not an EVM network, which is why a chain id matching + * nothing here resolves to nothing rather than to Ethereum. + * + * Values come from each chain's own currency plugin (`chainParams.chainId`), + * which the GUI cannot read at runtime: `EdgeCurrencyInfo.defaultSettings` is + * deprecated and always empty. + */ + evmChainId?: number + addressValidation: RegExp +} + +/** + * Snapshot of Houdini's mainnet native tokens (v2 partner API, re-fetched + * 2026-09-10) 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` and `polkadot` are not listed: the API returns no mainnet + * native for them, so offering them would add destinations whose every quote + * throws. + * + * `telos` is not listed either, though the names match. Houdini's `telos` is + * the Telos EVM network (`0x` addresses, chain id 40); Edge's `telos` plugin is + * the EOSIO-based native chain, whose accounts are names. No Edge Telos wallet + * can pay a `0x` deposit address or be paid at one, so offering the route + * would hand the user a destination their own Telos wallet cannot use. + * + * This is a snapshot on purpose. Houdini is an aggregator whose per-pair + * availability fluctuates too fast to track and whose Cloudflare blocks tight + * probing loops, so nothing here may be discovered at runtime: asset-level + * capability lives in this table, and pair-level capability is learned only + * from a real user-initiated quote (`pairCaps`). A follow-up can refresh the + * table from the API once chain metadata is exposed through the swap plugin. + */ +export const HOUDINI_CHAINS: HoudiniChain[] = [ + { + pluginId: 'algorand', + houdiniShortName: 'algorand', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^[A-Z0-9]{58,58}$/ + }, + { + pluginId: 'arbitrum', + houdiniShortName: 'arbitrum', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 42161, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'avalanche', + houdiniShortName: 'avalanche', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 43114, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'base', + houdiniShortName: 'base', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 8453, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'binancesmartchain', + houdiniShortName: 'bsc', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 56, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'bitcoin', + houdiniShortName: 'bitcoin', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: + /^([13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-z0-9]{39}|bc1[a-z0-9]{59})$/ + }, + { + pluginId: 'bitcoincash', + houdiniShortName: 'bitcoincash', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: + /^([13][a-km-zA-HJ-NP-Z1-9]{25,34})$|^((bitcoincash:)?(q|p)[a-z0-9]{41})$|^((BITCOINCASH:)?(Q|P)[A-Z0-9]{41})$/ + }, + { + pluginId: 'bitcoinsv', + houdiniShortName: 'bsv', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/ + }, + { + pluginId: 'cardano', + houdiniShortName: 'cardano', + memoNeeded: false, + hasSelfPrivate: true, + // Houdini's own regex ends in `|^[a-zA-z0-9]*|[0-9A-Za-z]{45,65}$`, whose + // first alternative is unanchored and zero-length and so matches EVERY + // string, including empty. Those two catch-alls are dropped here: they + // would accept any typo as a Cardano address, and they make any pasted + // address look like it could be paying Cardano. + addressValidation: + /^([1-9A-HJ-NP-Za-km-z]{59}|[0-9A-Za-z]{100,104}|[0-9a-fA-F]{64}|addr[0-9A-Za-z]{45,65})$/ + }, + { + pluginId: 'cosmoshub', + houdiniShortName: 'cosmoshub-4', + memoNeeded: true, + hasSelfPrivate: true, + addressValidation: /^(cosmos1)[0-9a-z]{38}$/ + }, + { + pluginId: 'dash', + houdiniShortName: 'dash', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^[X7][0-9A-Za-z]{33}$/ + }, + { + pluginId: 'dogecoin', + houdiniShortName: 'doge', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^(D|A|9)[a-km-zA-HJ-NP-Z1-9]{33,34}$/ + }, + { + pluginId: 'ecash', + houdiniShortName: 'eCash', + memoNeeded: false, + hasSelfPrivate: true, + // The provider's published pattern spells the prefix-less cashaddr forms as + // `[0-9A-Za-z]`, which matches EVERY 42-character alphanumeric string and so + // claims every `0x` EVM address as a candidate eCash destination. Narrowed + // to the bech32 charset cashaddr actually uses (lowercase, no `1bio`) with + // its `q`/`p` type prefix, which is strictly narrowing and leaves real eCash + // addresses matching. Same class of defect as the Cardano catch-all above. + addressValidation: + /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$|^[qp][qpzry9x8gf2tvdw0s3jn54khce6mua7l]{41}$|^ecash:[qp][qpzry9x8gf2tvdw0s3jn54khce6mua7l]{29,69}$/ + }, + { + pluginId: 'ethereum', + houdiniShortName: 'ethereum', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 1, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'hedera', + houdiniShortName: 'hedera', + memoNeeded: true, + hasSelfPrivate: true, + // Dots escaped: unescaped they are wildcards, so the pattern accepted + // anything shaped like 0X0Y12345 as a Hedera account id. The length bound + // is 1 and not the provider's 4: account ids are assigned sequentially, so + // early ones are genuinely short (0.0.98) and a four-digit floor rejects + // real destinations. + addressValidation: /^0\.0\.[0-9]{1,20}$/ + }, + { + pluginId: 'hyperevm', + houdiniShortName: 'hyperevm', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 999, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'litecoin', + houdiniShortName: 'litecoin', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^(L|M|3)[A-Za-z0-9]{33}$|^(ltc1)[0-9A-Za-z]{39}$/ + }, + { + pluginId: 'monad', + houdiniShortName: 'MON', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 143, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'monero', + houdiniShortName: 'monero', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^[48][a-zA-Z\d]{94}([a-zA-Z\d]{11})?$/ + }, + { + pluginId: 'opbnb', + houdiniShortName: 'opbnb', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 204, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'optimism', + houdiniShortName: 'optimism', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 10, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'pivx', + houdiniShortName: 'pivx', + memoNeeded: false, + hasSelfPrivate: true, + // Houdini writes `[0-9A-za-z]`, where `A-z` also spans `[ \ ] ^ _ \``. + // PIVX addresses are base58, so the strict class is used instead. + addressValidation: /^D[1-9A-HJ-NP-Za-km-z]{33}$/ + }, + { + pluginId: 'polygon', + houdiniShortName: 'polygon', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 137, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'pulsechain', + houdiniShortName: 'pulsechain', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 369, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'ripple', + houdiniShortName: 'ripple', + memoNeeded: true, + hasSelfPrivate: true, + addressValidation: /^r[1-9A-HJ-NP-Za-km-z]{25,34}$/ + }, + { + pluginId: 'robinhood', + houdiniShortName: 'Robinhood', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 4663, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'rsk', + houdiniShortName: 'rootstock', + memoNeeded: false, + hasSelfPrivate: false, + evmChainId: 30, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'solana', + houdiniShortName: 'solana', + memoNeeded: false, + hasSelfPrivate: true, + // The provider's published lower bound of 32 reaches down into the length + // band the Bitcoin-family legacy forms occupy (P2PKH and P2SH are 33-34 + // base58 characters, as are Dogecoin, Litecoin and PIVX), so a legacy UTXO + // address offered Solana as a destination it is not an account on. A Solana + // address is a base58 32-byte ed25519 public key, which is 43-44 characters + // and 42 only for a value small enough to be vanishingly unlikely, so the + // floor moves to 42: strictly narrowing, and clear of every UTXO form. + addressValidation: /^[1-9A-HJ-NP-Za-km-z]{42,44}$/ + }, + { + pluginId: 'sonic', + houdiniShortName: 'sonic', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 146, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + }, + { + pluginId: 'stellar', + houdiniShortName: 'xlm', + memoNeeded: true, + hasSelfPrivate: true, + addressValidation: /^G[A-D]{1}[A-Z2-7]{54}$/ + }, + { + pluginId: 'sui', + houdiniShortName: 'sui', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^(0x)[0-9A-Za-z]{64}$/ + }, + { + pluginId: 'thorchainrune', + houdiniShortName: 'thorchain', + memoNeeded: true, + hasSelfPrivate: true, + addressValidation: /^(thor1)[0-9a-z]{38}$/ + }, + { + pluginId: 'ton', + houdiniShortName: 'ton', + memoNeeded: true, + hasSelfPrivate: true, + addressValidation: /^(EQ|UQ)[A-Za-z0-9_-]{46}$/ + }, + { + pluginId: 'tron', + houdiniShortName: 'tron', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^T[1-9A-HJ-NP-Za-km-z]{33}$/ + }, + { + pluginId: 'zcash', + houdiniShortName: 'Zcash', + memoNeeded: false, + hasSelfPrivate: true, + addressValidation: /^t1[1-9A-HJ-NP-Za-km-z]{33}$/ + }, + { + pluginId: 'zksync', + houdiniShortName: 'zksync-era', + memoNeeded: false, + hasSelfPrivate: true, + evmChainId: 324, + addressValidation: /^(0x)[0-9A-Za-z]{40}$/ + } +] + +/** + * Houdini's own minimum order sizes, in USD. Their guidance, verbatim: "on your + * side you should stick to our hardcoded minimums that under 25 USD there are + * no routes for private." + * + * Confirmed against the live v2 API on 2026-07-30 rather than taken on faith. + * Cross-asset TRX to LTC answered 422 "Amount is too low, minimum is 10 USD" at + * 8 USD, returned standard routes only from 12 to 24 USD, and added private + * routes from 25 USD up. Same-asset TRX to TRX answered 422 "Amount is too low, + * minimum is 25 USD" below 25 and returned private routes only above it. + * + * These are floors, not the whole story: individual tokens carry higher + * server-side minimums that cannot be known upfront (Polygon private is + * effectively 60 USD), which arrive as quote errors carrying the real minimum. + */ +export const HOUDINI_MIN_USD = { + /** Private (multi-exchange) routes, which every stealth flow requires. */ + private: '25', + /** Standard (single-exchange) routes, used by a plain swap-and-send. */ + standard: '10', + /** On-chain DEX routes, offered only for assets with `hasDex`. */ + dex: '5' +} as const + +/** Look up the Houdini destination chain for an Edge asset, if served. */ +export function getHoudiniChain( + pluginId: string, + tokenId: EdgeTokenId +): HoudiniChain | undefined { + // Only native (chain) assets are offered as destinations for now: + if (tokenId != null) return undefined + return HOUDINI_CHAINS.find(chain => chain.pluginId === pluginId) +} + +/** + * Whether a pasted address fits the chain's address format. A prefilter for + * detection only: the chain's `parseUri` is the validator. + */ +export function isValidHoudiniAddress( + chain: HoudiniChain, + address: string +): boolean { + return chain.addressValidation.test(address.trim()) +} + +/** + * Find the destination chains a pasted string could be paying, for input the + * source wallet itself could not parse. An address belonging to another chain + * is not a typo: it is a cross-chain send whose recipient asset the user has + * not picked yet, so the caller can offer the swap instead of an error. + * + * An explicit URI scheme (`ethereum:0x...`) names the chain outright and wins. + * A bare address is matched against each served chain's own regex, which is + * ambiguous by construction for the EVM family, so every match is returned for + * the caller to disambiguate rather than guessing and misdirecting funds. + */ +export function detectHoudiniChains( + text: string, + opts: { + /** The sending wallet's chain. */ + sourcePluginId: string + /** + * The sending wallet's token, or `null` for the chain's own coin. + * + * The source chain is only excluded from the candidates when the source IS + * that chain's coin, because there the destination would be the same asset + * the user is already sending and the plain send path covers it. From a + * TOKEN the source chain is a real destination: USDC on Ethereum paying out + * native ETH is a cross-asset route no plain send can make, and dropping it + * left a pasted `0x` address offering every OTHER EVM network but not the + * one the recipient actually holds. + */ + sourceTokenId: string | null + /** Whether the account has a currency plugin for this chain. */ + isSupported: (pluginId: string) => boolean + } +): HoudiniChain[] { + const { sourcePluginId, sourceTokenId, isSupported } = opts + const { addressCandidates, scheme, evmChainId } = peekPaymentUri(text) + + const served = HOUDINI_CHAINS.filter( + chain => + (chain.pluginId !== sourcePluginId || sourceTokenId != null) && + isSupported(chain.pluginId) + ) + const matchesAddress = (chain: HoudiniChain): boolean => + addressCandidates.some(candidate => isValidHoudiniAddress(chain, candidate)) + + // An EIP-681 chain id names the network outright, and it has to be read + // BEFORE the scheme: every EVM network writes `ethereum:`, so the scheme + // alone would resolve a Polygon or Base code to Ethereum mainnet. A chain id + // that matches nothing served resolves to nothing rather than falling back to + // the scheme, since guessing here picks the wrong chain to pay. + if (evmChainId != null) { + const named = served.find(chain => chain.evmChainId === evmChainId) + return named != null && matchesAddress(named) ? [named] : [] + } + + if (scheme != null) { + const named = served.find(chain => schemeNamesChain(scheme, chain)) + if (named != null && matchesAddress(named)) return [named] + } + + return served.filter(matchesAddress) +} + +/** + * Whether a payment URI's scheme names this chain, by either the Edge plugin id + * or the provider's own chain name (`ethereum:`, `bitcoincash:`). + * + * A scheme is the one part of a scanned code that states its chain outright, so + * it has to be checked even when a destination is already picked: chains that + * share an address format (the whole EVM family) validate each other's + * addresses, and accepting an `ethereum:` code against a picked Polygon + * destination would send to the wrong chain and price the URI's amount in the + * wrong asset. + */ +export function schemeNamesChain(scheme: string, chain: HoudiniChain): boolean { + const schemeLower = scheme.toLowerCase() + return ( + chain.pluginId === schemeLower || + chain.houdiniShortName.toLowerCase() === schemeLower + ) +} + +/** + * The asset the recipient actually receives. + * + * A swap-send always pays out the destination chain's NATIVE asset, because + * the quote asks for `toTokenId: null` and token destinations are not offered + * at all. A plain send delivers the source asset verbatim, token included. + * Both the "Recipient receives" row and that row's picker read this, so the + * two cannot drift: naming the source token while the order paid out the + * chain's own coin told a USDT sender their recipient receives USDT. + */ +export function getRecipientAsset(opts: { + sourcePluginId: string + sourceTokenId: EdgeTokenId + /** `recipientPluginId ?? sourcePluginId`. */ + destPluginId: string + swapSendActive: boolean +}): EdgeAsset { + const { destPluginId, sourcePluginId, sourceTokenId, swapSendActive } = opts + return swapSendActive + ? { pluginId: destPluginId, tokenId: null } + : { pluginId: sourcePluginId, tokenId: sourceTokenId } +} + +/** One row of the "Recipient receives" picker. */ +export interface RecipientAssetChoice { + /** The asset this row names, which is what the recipient would receive. */ + asset: EdgeAsset + /** + * What `recipientPluginId` becomes when this row is picked. `undefined` + * clears the explicit destination chain, leaving the source chain. + */ + recipientPluginId: string | undefined +} + +/** + * The rows of the "Recipient receives" picker, in display order. + * + * Each row names what the recipient gets once THAT row is picked, which is + * not always what the scene shows now: picking the first row clears the + * adopted destination, so without Stealth a token source falls back to a + * plain send of the token. Labelling that row from the current swap state + * named the chain's native coin while the pick reverted to the token. + * + * The first row is the source chain with no destination adopted. The rest are + * the served destination chains. The source chain appears again only when + * adopting it pays out something the first row does not, which is a token + * source without Stealth: the first row is the token, and the chain's native + * coin is a separate payout. Any other second row for the source chain quotes + * identically to the first and differs only in whether turning Stealth off + * degrades to a plain send, which no user can tell apart. + * + * Callers must key the rows on the asset rather than on its display name. The + * POL ERC-20 on Ethereum and the Polygon chain share both their name and their + * currency code, so a name-keyed list marks both selected and resolves either + * tap to the same row. + */ +export function getRecipientAssetChoices(opts: { + sourcePluginId: string + sourceTokenId: EdgeTokenId + /** Whether Stealth alone makes this a swap-send, with no chain adopted. */ + stealthActive: boolean + /** Served destination chains, already filtered to what the account holds. */ + servedPluginIds: string[] +}): RecipientAssetChoice[] { + const { servedPluginIds, sourcePluginId, sourceTokenId, stealthActive } = opts + const choices: RecipientAssetChoice[] = [ + { + asset: getRecipientAsset({ + sourcePluginId, + sourceTokenId, + destPluginId: sourcePluginId, + swapSendActive: stealthActive + }), + recipientPluginId: undefined + }, + ...servedPluginIds.map(pluginId => ({ + asset: { pluginId, tokenId: null }, + recipientPluginId: pluginId + })) + ] + const seen = new Set() + return choices.filter(choice => { + const key = recipientAssetKey(choice.asset) + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +/** + * A stable per-row identity for the picker, since a display name is not one. + * Natives key on the pluginId alone so the value reads as the chain, which is + * also what the row's `testID` becomes. + */ +export function recipientAssetKey(asset: EdgeAsset): string { + return asset.tokenId == null + ? asset.pluginId + : `${asset.pluginId}:${asset.tokenId}` +} diff --git a/src/util/paymentUri.ts b/src/util/paymentUri.ts new file mode 100644 index 00000000000..2c756b267f4 --- /dev/null +++ b/src/util/paymentUri.ts @@ -0,0 +1,115 @@ +import type { EdgeCurrencyConfig } from 'edge-core-js' +import URL from 'url-parse' + +/** + * What a scanned payment code says about its own chain, read without loading + * any chain's parser. Detection uses this to pick the destination chain; the + * chosen chain's `parseUri` then reads the code for real. + */ +export interface PaymentUriPeek { + /** + * Bare-address readings for the per-chain address prefilter, in priority + * order: the raw text itself (bare addresses, and cashaddr-style addresses + * whose on-chain form keeps the `prefix:`), the scheme-prefixed path, and + * the naked path with EIP-681's `pay-` prefix and `@chainId` suffix removed. + * + * Empty for an EIP-681 function call + * (`ethereum:@1/transfer?address=`), whose path is the TOKEN + * CONTRACT rather than the payee: matching a chain on it would offer to pay + * a contract. + */ + addressCandidates: string[] + + /** + * The URI scheme (`ethereum` in `ethereum:0x...`), absent for plain text. + * Names the destination chain outright, which is how a cross-chain paste can + * be resolved without guessing between chains that share an address format. + */ + scheme?: string + + /** + * The EIP-681 `@chainId` suffix (`137` in `ethereum:0x...@137`), decimal or + * `0x` hex as written. + * + * Every EVM network's payment code uses the `ethereum:` scheme and states + * which network it means here, so the scheme alone identifies the FAMILY and + * this identifies the CHAIN. Reading the scheme without it sends a Polygon, + * Arbitrum or Base code to Ethereum mainnet. + */ + evmChainId?: number +} + +/** + * Reads the scheme and EIP-681 chain id off scanned or pasted text. Plain text + * that is not a URI passes through as its own single candidate. + */ +export function peekPaymentUri(text: string): PaymentUriPeek { + const trimmed = text.trim() + const url = new URL(trimmed, {}, false) + if (url.protocol === '') return { addressCandidates: [trimmed] } + + const scheme = url.protocol.slice(0, -1) + // url-parse lowercases the host of a `scheme://` form, which corrupts a + // case-sensitive address, so that form reads its path from the text itself: + const path = url.slashes + ? trimmed.slice(url.protocol.length + 2).split('?')[0] + : url.pathname + const [target, ...functionCall] = path.replace(/^pay-/, '').split('/') + const [bareAddress, chainIdText] = target.split('@') + const evmChainId = parseChainId(chainIdText) + if (functionCall.length > 0) { + return { addressCandidates: [], scheme, evmChainId } + } + + const addressCandidates: string[] = [] + for (const candidate of [trimmed, `${scheme}:${path}`, bareAddress]) { + if (candidate === '' || addressCandidates.includes(candidate)) continue + addressCandidates.push(candidate) + } + return { addressCandidates, scheme, evmChainId } +} + +function parseChainId(text: string | undefined): number | undefined { + if (text == null) return undefined + if (/^\d+$/.test(text)) return parseInt(text, 10) + if (/^0x[0-9a-f]+$/i.test(text)) return parseInt(text, 16) + return undefined +} + +/** A payment destination on a chain the user holds no wallet for. */ +export interface CrossChainPayment { + publicAddress: string + /** The amount the code asks for, in the destination chain's native units. */ + nativeAmount?: string + /** + * The destination memo the code carries (an XRP `dt`, a `memo` parameter). + * Memo-required payout chains credit the recipient by this value, so a + * scanned exchange deposit code that carries one has to keep it. + */ + memo?: string +} + +/** + * Reads scanned or pasted text with the destination chain's own URI parser, + * which covers that chain's URI dialect, address forms and checksums without + * needing a wallet on it. + * + * Resolves undefined for anything the parser rejects, and for a code that + * names one of the chain's tokens: this flow pays out the chain's own coin, + * so honoring a token code would pay the wrong asset. + */ +export async function parseCrossChainPayment( + currencyConfig: EdgeCurrencyConfig, + text: string +): Promise { + const { currencyCode } = currencyConfig.currencyInfo + try { + const parsed = await currencyConfig.parseUri(text.trim(), currencyCode) + const { publicAddress, nativeAmount, tokenId, uniqueIdentifier } = parsed + if (publicAddress == null || publicAddress === '') return + if (tokenId != null) return + return { publicAddress, nativeAmount, memo: uniqueIdentifier } + } catch (error: unknown) { + return undefined + } +} diff --git a/src/util/stealthSwap.ts b/src/util/stealthSwap.ts new file mode 100644 index 00000000000..4776e234ced --- /dev/null +++ b/src/util/stealthSwap.ts @@ -0,0 +1,87 @@ +import type { + EdgeAccount, + EdgePluginMap, + EdgeSwapRequestOptions, + EdgeTokenId +} from 'edge-core-js' + +import type { DisableAsset } from '../actions/ExchangeInfoActions' + +/** + * The swap provider that powers both Stealth flows, and every send-to-address + * quote with them. Named once so the request restriction below and the send + * scene's terms modal agree on it. + */ +export const STEALTH_SWAP_PLUGIN_ID = 'houdini' + +/** + * Where both Stealth toggles' "Learn more" sends the user. + * + * PLACEHOLDER: the feature spec names this gist as the stand-in until the + * Stealth Send article exists. The final URL is a single edit here, since the + * send scene and the swap amount-entry scene both read this constant. + * + * A gist is a mutable page its owner can rewrite, opened in the system browser + * from inside a flow the user is trusting with fund movement, so replace it + * with the first-party article once that is published. + */ +export const STEALTH_LEARN_MORE_URI = + 'https://gist.github.com/j0ntz/b3f8101f0a1f79539150fc73511bff8b' + +interface StealthSwapFlags { + /** + * Query Houdini even when the user switched it off in their exchange + * settings. That setting governs which providers the swap aggregator may + * use, so it is the user's answer about swapping, not about sending: a send + * feature that happens to be powered by Houdini must not disappear because a + * swap provider was turned off. Set on the send scene only; the Exchange + * scene keeps honoring the setting. + */ + ignoreProviderSetting?: boolean +} + +/** + * Restricts a swap request to the Houdini privacy provider, for Stealth Swap + * and Stealth Send. Every other enabled swap provider is disabled for the + * request, and any preferred-provider override is cleared so it cannot fight + * the restriction. + */ +export function makeStealthSwapRequestOptions( + account: EdgeAccount, + opts: EdgeSwapRequestOptions = {}, + flags: StealthSwapFlags = {} +): EdgeSwapRequestOptions { + const disabled: EdgePluginMap = { ...opts.disabled } + for (const swapPluginId of Object.keys(account.swapConfig)) { + if (swapPluginId !== STEALTH_SWAP_PLUGIN_ID) disabled[swapPluginId] = true + } + return { + ...opts, + disabled, + forceEnabled: + flags.ignoreProviderSetting === true + ? { ...opts.forceEnabled, [STEALTH_SWAP_PLUGIN_ID]: true } + : opts.forceEnabled, + preferPluginId: undefined, + preferType: undefined + } +} + +/** + * Whether the info server's swap kill switch covers an asset. Each entry names + * a chain plus one token, no token for the chain's own coin, `allTokens` for + * every token, or `allCoins` for everything on the chain. + */ +export function disableAssetsCover( + disableAssets: DisableAsset[], + pluginId: string, + tokenId: EdgeTokenId +): boolean { + return disableAssets.some( + disableAsset => + disableAsset.pluginId === pluginId && + ((disableAsset.tokenId ?? null) === tokenId || + disableAsset.tokenId === 'allCoins' || + (disableAsset.tokenId === 'allTokens' && tokenId != null)) + ) +} diff --git a/src/util/swapErrorDisplay.ts b/src/util/swapErrorDisplay.ts new file mode 100644 index 00000000000..b048bcd3d24 --- /dev/null +++ b/src/util/swapErrorDisplay.ts @@ -0,0 +1,215 @@ +import { captureException } from '@sentry/react-native' +import { + asMaybeInsufficientFundsError, + asMaybeSwapAboveLimitError, + asMaybeSwapBelowLimitError, + asMaybeSwapCurrencyError, + asMaybeSwapPermissionError, + type EdgeDenomination, + type EdgeSwapRequest, + type EdgeSwapSendRequest +} 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 | EdgeSwapSendRequest + fromDenomination: EdgeDenomination + toDenomination: EdgeDenomination + /** + * Destination currency code. A send-to-address request carries no + * destination wallet to read one from, so the caller supplies it. + */ + toCurrencyCode?: string +} + +/** + * Turn a failed swap quote into something worth showing a user: the provider's + * own message, the limit that was crossed, or the specific reason the pair is + * unavailable. Callers render the result rather than a catch-all string, so a + * user who is 0.1 LTC under the floor is told the floor. + */ +export function processSwapQuoteError({ + error, + swapRequest, + fromDenomination, + toDenomination, + toCurrencyCode +}: ProcessSwapQuoteErrorOpts): SwapErrorDisplayInfo | undefined { + // Basic sanity checks (should never fail): + if (error == null) return + + // Some plugins get the insufficient funds error wrong: + const errorMessage = + error instanceof Error ? error.message : JSON.stringify(error) + + // Track swap errors to sentry: + trackSwapError(error, swapRequest, toCurrencyCode) + + // Check for known error types: + const insufficientFunds = asMaybeInsufficientFundsError(error) + if (insufficientFunds != null || errorMessage === 'InsufficientFundsError') { + return { + title: lstrings.exchange_insufficient_funds_title, + message: lstrings.exchange_insufficient_funds_message, + error + } + } + + if ( + error instanceof Error && + error.message === 'Unexpected pending transactions' + ) { + return { + title: lstrings.exchange_insufficient_funds_title, + message: lstrings.exchange_pending_funds_error, + error + } + } + + const aboveLimit = asMaybeSwapAboveLimitError(error) + if (aboveLimit != null) { + const currentCurrencyDenomination = + aboveLimit.direction === 'to' ? toDenomination : fromDenomination + + const { nativeMax } = aboveLimit + const nativeToDisplayRatio = currentCurrencyDenomination.multiplier + const displayMax = convertNativeToDisplay(nativeToDisplayRatio)(nativeMax) + + return { + title: lstrings.exchange_generic_error_title, + message: !zeroString(displayMax) + ? sprintf( + lstrings.amount_above_limit, + displayMax, + currentCurrencyDenomination.name + ) + : lstrings.no_amount_above_limit, + error + } + } + + const belowLimit = asMaybeSwapBelowLimitError(error) + if (belowLimit != null) { + const currentCurrencyDenomination = + belowLimit.direction === 'to' ? toDenomination : fromDenomination + + const { nativeMin } = belowLimit + const nativeToDisplayRatio = currentCurrencyDenomination.multiplier + const displayMin = convertNativeToDisplay(nativeToDisplayRatio)(nativeMin) + + return { + title: lstrings.exchange_generic_error_title, + message: !zeroString(displayMin) + ? sprintf( + lstrings.amount_below_limit, + displayMin, + currentCurrencyDenomination.name + ) + : lstrings.no_amount_below_limit, + error + } + } + + const currencyError = asMaybeSwapCurrencyError(error) + if (currencyError != null) { + const fromCurrencyCode = getCurrencyCode( + swapRequest.fromWallet, + swapRequest.fromTokenId + ) + const toCode = + toCurrencyCode ?? + ('toWallet' in swapRequest + ? getCurrencyCode(swapRequest.toWallet, swapRequest.toTokenId) + : swapRequest.toPluginId) + + 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 | EdgeSwapSendRequest, + toCurrencyCode?: string +): void { + // The destination, from whichever request shape carries it. A send request + // has no `toWallet` at all, and falling back to the SOURCE wallet there + // tagged every stealth-send failure as if the swap had ended on the chain it + // started on, which is exactly the pair-specific triage these tags exist + // for. The send request names the destination chain, and the caller + // supplies the payout currency code. + const toWallet = 'toWallet' in swapRequest ? swapRequest.toWallet : undefined + const toWalletKind = + toWallet?.currencyInfo.pluginId ?? + ('toPluginId' in swapRequest ? swapRequest.toPluginId : 'unknown') + const toCurrency = + toWallet != null + ? getCurrencyCode(toWallet, swapRequest.toTokenId) + : toCurrencyCode ?? 'unknown' + + captureException(error, scope => { + // This is a warning level error because it's expected to occur but not wanted. + scope.setLevel('warning') + // Searchable tags: + scope.setTags({ + errorType: 'swapQuoteFailure', + swapFromWalletKind: swapRequest.fromWallet.currencyInfo.pluginId, + swapFromCurrency: getCurrencyCode( + swapRequest.fromWallet, + swapRequest.fromTokenId + ), + swapToCurrency: toCurrency, + swapToWalletKind: toWalletKind, + swapDirectionType: swapRequest.quoteFor + }) + // Unsearchable context data: + scope.setContext('Swap Request Details', { + fromTokenId: String(swapRequest.fromTokenId), // Stringify to include "null" + fromWalletType: swapRequest.fromWallet.type, + toTokenId: String(swapRequest.toTokenId), // Stringify to include "null" + // The destination's own wallet type, which a send-to-address request + // does not have. Naming the destination chain there is the honest + // answer; reading the SOURCE wallet claimed the swap ended where it + // started. + toWalletType: toWallet?.type ?? `address:${toWalletKind}`, + quoteFor: swapRequest.quoteFor + }) + return scope + }) +}