Skip to content

Add Stealth Send and Stealth Swap (Houdini privacy routing) - #6066

Draft
j0ntz wants to merge 18 commits into
developfrom
jon/stealth-send-swap
Draft

Add Stealth Send and Stealth Swap (Houdini privacy routing)#6066
j0ntz wants to merge 18 commits into
developfrom
jon/stealth-send-swap

Conversation

@j0ntz

@j0ntz j0ntz commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Technical Design Document

stealth-send-swap.md

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

Ready for review. Two things block merge. The dependency PRs above must publish and the version pins here must bump. And STEALTH_LEARN_MORE_URI in src/constants/stealthConstants.ts is a placeholder: both Stealth toggles' "Learn more" currently opens Edge's support home rather than the Stealth Send article, which does not exist yet. It needs the real URL, which is a one-line edit at that constant. This repo runs no typecheck on pull requests (Travis is limited to develop/staging, and pr-checks.yml runs only the WIP blocker), so the branch reviews cleanly as-is; it will not compile against published edge-core-js 2.47.1, which lacks toAddressInfo and EdgeTxActionSwap.swapType. Locally it is verified against the linked dep branches: tsc clean, eslint clean, full jest suite green.

Requirements

If you have made any visual changes to the GUI. Make sure you have:

  • Tested on iOS device
  • Tested on Android device
  • Tested on small-screen device (iPod Touch)
  • Tested on large-screen device (tablet)

Description

Asana task

Send from any wallet to an address on any chain the provider serves, privately, from the ordinary send scene. Edge could previously send an asset to an address on its own chain, or swap between two wallets the user holds; paying someone whose address is on another chain took two operations and a wallet the user may not want, and every ordinary send writes a direct sender-to-recipient edge on chain. A swap provider that pays out to an arbitrary address answers both, because the provider address sits between the two parties.

The design document linked above is the full account, including the per-phase history and the decisions with their rejected alternatives. What follows is the shape of the diff.

Send (SendScene2 becomes a send-to-address swap). The feature is integrated in place rather than as a parallel scene, which is the task's UI proposal A reading and the only version that reaches the send scene's many entry points.

  • A "Recipient receives" asset selector and a Stealth Send toggle appear only when the scene is an unconstrained plain send. Every constrained caller fails at least one clause of the swapSendAllowed predicate, so payment protocol, FIO requests, deep links, and any caller that pre-locks tiles or takes over broadcast keep today's behavior.
  • Stealth on, or a cross-asset recipient, turns the send into a live account.fetchSwapQuotes({ toAddressInfo }) quote. Plain same-asset sends keep the existing UI and behavior, including multi-recipient UTXO sends, which gained a Total Amount row.
  • Linked "You send" / "Recipient gets" rows: the edited side is the guaranteed amount and the other tracks the live quote as an estimate, each row naming its state in its own title, tinted green and warning orange. Both go through the standard flip input and open on fiat, as the Exchange scene's inputs do. Editing the receive side issues a reverse quote.
  • The quote row carries the real rate, the winning provider label, the price delta reused from the swap confirmation scene through the new shared PriceImpactText, a real expiry countdown that re-quotes, and the quote's own network fee.
  • A quote is retired the instant anything it was priced against moves, and the slider reads the live value through a ref rather than the one its render closed over: the PIN spending-limit check awaits, and a quote retired during that await would otherwise still be approved.
  • Every send-to-address quote is Houdini-only, toggle or no toggle: send-to-any is a privacy feature and never shops the destination address to other providers. A pair Houdini cannot route errors rather than falling back, by design.
  • What the provider cannot do for a pair is learned from real quote failures rather than probed. A same-asset pair with no private route turns the toggle off with a toast and degrades to the plain send it had upgraded; a missing receive-priced route falls back to a rate-seeded guaranteed send amount with a warning card that clears on edit; re-arming either on a known-unavailable pair answers pre-emptively instead of sending another doomed quote. Amounts under the applicable floor (25 USD private, 10 standard) are refused before a request goes out.
  • Multi-recipient and stealth/cross-asset are mutually exclusive, gated in both directions.

Cross-chain address entry. An address the sending wallet cannot read is matched against the served destination chains rather than reported as invalid. A URI scheme names its chain outright and wins; a bare address is matched on format, and where several chains share one (the whole EVM family) the user picks from a modal rather than the app guessing and misdirecting funds. This hangs off AddressTile2.changeAddress, so Paste, Enter address, and Scan are all covered at once. A scanned QR carries a payment URI, so a new generic splitter (src/util/paymentUri.ts) reads the scheme, the EIP-681 @chainId, address candidates, the amount, and any destination memo without a chain-specific parser. The chain id is read before the scheme, since every EVM network's code writes ethereum: and only the id names the network; an id nothing serves resolves to nothing rather than falling back to Ethereum. A URI amount is what the recipient should RECEIVE, so it guarantees the receive side for a cross-asset destination; a same-asset stealth send keeps it on the send side, because the provider serves no receive-priced route when the two assets match.

Swap. A Stealth Swap toggle on the amount-entry scene restricts the request to Houdini and demands a private route, with the restriction surviving re-quotes on the confirmation scene, and the powered-by card rendering as a fixed provider through a now-optional PoweredByCard.onPress.

Transaction identity. A swap-send, a stealth send, and a stealth swap-send are titled separately in the list and details, named on the swap action's new swapType rather than inferred, because only the scene knows which shape ran. The two private flows suppress the recipient in the UI while keeping orderId and payoutAddress in storage so support can trace a stuck order, and the spend-target row is retitled "Exchange Deposit Address", which is the address the funds actually went to.

Review round (2026-08-25). The branch is rebased on current develop and re-reviewed against it. Behavior changes from that round, in order of consequence:

  • The private-send redaction fails CLOSED. hidePayoutAddress read savedAction.swapType, which reaches the saved action through a best-effort saveTxAction the send deliberately swallows so a storage hiccup cannot fail a completed send. A stamp that never landed left the recipient's address on screen days later for a send the user was told was private. The test now also accepts the provider: a swap routed by the privacy provider is privacy-routed by construction, and hiding the payout address on an ordinary provider swap costs nothing, since that payout goes to the user's own wallet. The analogous fee-row case was already hardened this way; the primary row was not.
  • A token source can pay out on its own chain. detectHoudiniChains dropped the source chain unconditionally, so a USDC-on-Ethereum send with a pasted 0x address offered Arbitrum, Base and every other EVM network but not Ethereum, the one the recipient actually holds. The chain is now dropped only when the source IS that chain's coin.
  • The learned fixedTo route capability is keyed by privacy. Exact-out lives on fixed-rate routes, which Houdini's private routing does not serve, so a receive-priced failure learned with Stealth ON refused the editor with Stealth OFF, for a capability never tested on that route.
  • Sentry names the real destination. A send-to-address error has no toWallet, and the tags fell back to the SOURCE wallet, so every stealth-send failure looked like a same-chain error in monitoring.
  • The success navigation out of the slider goes through InteractionManager.runAfterInteractions, matching the plain-send path beside it; the two saveTxAction writes for a token send go out together instead of one after the other; the "require a destination wallet" guard is one helper instead of six copies in two different wordings; and the Stealth info card is one component instead of two copies.

A rebase artifact is also fixed: SafeSlider carried a duplicate testID attribute after 18 commits replayed over 155, because this branch turned the hardcoded id into a prop defaulting to the same value while upstream added the hardcoded one, and git merged both onto the element. Only tsc caught it.

The maestro suite was repaired against current develop in the same round, and two of those repairs are product fixes rather than flow edits. walletListRow ids were identical across a wallet's every enabled token, so a walk asking for "My Sonic" named seven rows at once and tapped whichever the matcher reached first; token rows now carry their currency code and the chain's own coin keeps the bare name. The picker modal reused those same ids while floating over a scene whose rows repeat the names, so a tap resolved to the COVERED row and dismissed the sheet instead of choosing a wallet; picker rows are now walletPickerRow.<name>, threaded through WalletList as a testIdPrefix. On the flow side, stealth-open-send.yaml now reaches the Send scene through Home → Send → "To Another Wallet/Exchange", since the Assets tab's wallet row no longer lands on that wallet's transaction list.

Chain metadata. src/util/houdiniChains.ts is a snapshot of Houdini's mainnet natives intersected with Edge pluginIds: 34 chains, 5 memo-required. Four of the provider's published address regexes are corrected in it with the reason inline: the Cardano pattern ends in an unanchored zero-length alternative that matches every string, the PIVX class writes A-z, which spans the punctuation between the alphabet halves, the eCash pattern spells its prefix-less form as [0-9A-Za-z]{42}, which is exactly the shape of an 0x EVM address, and Solana's floor of 32 base58 characters reaches into the 33-34 band the Bitcoin-family legacy forms occupy. Each entry also carries its EVM network id where it has one, since every EVM payment code writes the ethereum: scheme and only the EIP-681 chain id names the network.

What the recipient receives. The "Recipient receives" row and the picker that edits it now read one function. A swap-send pays out the destination chain's native asset, because the quote asks for toTokenId: null; a plain send delivers the source asset verbatim. Deciding that in two places let them disagree for a token source, where the picker offered "Tether (USDT)" and the row underneath read "Ethereum (ETH)". The row also follows the toggle rather than only the picked chain, so a plain USDT send stops claiming the recipient receives ETH. The source chain is listed once rather than twice: the duplicate quoted identically and differed only in whether turning Stealth off degrades to a plain send, which is not a distinction a user can see. RadioListModal rows gained an optional value, defaulting to the name so the other three callers are untouched, because a display name is not an identity: the POL ERC-20 on Ethereum shares its name and its currency code with the Polygon chain, which left Polygon unreachable as a destination from a POL wallet. The same opt-in shape adds the search box a 34-chain list wants, reusing the filtering ListModal already provides.

Deliberately out of scope, each with its reasoning in the design doc: token destinations, max spend in swap-send mode, multiple recipients with swap-send, and telling two EVM chains apart from a bare address.

Reading the diff. 18 commits, ordered shared primitives, then utilities, then the scenes that consume them, then the UI walks. The design document rides in the first commit and the CHANGELOG entry in the last. Each 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.

Testing. 76 unit tests across five new files here (payment URI splitting, chain detection and table invariants, the request restriction, the error mapping, and the transaction titling), plus 14 in edge-core-js and 31 in edge-exchange-plugins. Full jest suite green: 103 suites, 804 tests, 107 snapshots. tsc and eslint clean. In-app on the iOS simulator against this branch with both dep branches linked: the Stealth Swap toggle drives on and renders its explanation, and both wallet pickers resolve through the new walletPickerRow ids. The funded swap itself did not complete: the Exchange scene wedges blank on the confirm transition in this debug build, and it does so identically with Stealth OFF on an ordinary swap, so the wedge is the build rather than this branch.

On the iOS simulator against the live provider, across the phases: five real on-chain executions including the reported cross-chain address-entry path end to end, a Stealth Send of 26 USD from Litecoin paying out Sonic on the current branch (live private quote at 1 LTC = 2036.80293985 S, through the slider to the success scene and a transaction titled "Stealth Swap & Send"), a private Stealth Send priced in fiat through to the success scene, a stamped send from a token source confirming the "Exchange Deposit Address" title over Houdini's real deposit address, a 10-case entry-path and chain matrix covering BTC, LTC, ETH, POL and SOL plus USDC and USDT token sources, the stealth auto-disable and fixed-to fallback driven live, and the plain-send and multi-recipient regressions. Screenshots are attached to this PR. maestro/14-stealth/ holds a flow per user-visible branch, with the two that move funds behind their own tag so a run of the suite cannot spend.

Warning surfaces (2026-08-27). A Houdini-routed send looks like a send and behaves like a swap, and nothing on the scene said so. Three surfaces now do, each answering a different question.

  • A one-time modal on the send scene, the first time a send turns into a swap. It names the provider off account.swapConfig, and says the wallet pays the provider while the provider pays the recipient, and that the send is not complete until the provider forwards the funds. It follows the send scam warning beside it: a disklet key, runOnce, and a ConfirmContinueModal. Informational, so it never blocks the send.
  • A one-time terms modal on the dedicated swap scene, in the shape every other centralized provider already gets. SwapConfirmationScene was already calling swapVerifyTerms, and Houdini was the only routed provider with no pluginData row, so it showed nothing. Its entry carries Houdini's Terms of Use, Privacy Policy and AML/KYC links, and persists through the provider's own agreedToTerms user setting.
  • A warning card at the bottom of the send scene, in the existing warning cluster, for as long as the send stays a swap. A modal shown once cannot warn a user on their fortieth stealth send, and the extra wait is a property of every one of them. Private routing gets its own copy.

The send scene cannot reuse the terms modal: swapVerifyTerms runs on SwapConfirmationScene alone and a send-to-address quote never visits that scene, so one acknowledgement would have covered the dedicated swap and silently skipped every stealth send.


Note

High Risk
Large payment and swap-send surface (send and exchange scenes, cross-chain address resolution, Houdini-only quotes, and transaction identity) with external dependencies on edge-core-js and edge-exchange-plugins before merge.

Overview
Stealth Send turns the ordinary send scene into a live swap-to-address flow when the recipient asset differs from the source or when Stealth Send is on: linked You send / Recipient gets amounts (guaranteed vs estimated), quote expiry, Houdini-only routing, client-side minimums (25 USD private / 10 USD standard), and a Recipient receives picker before address entry. Plain same-asset sends stay as today, with Total Amount on multi-recipient UTXO sends.

Cross-chain addresses no longer fail as invalid: pasted, typed, or scanned values are matched to served chains (with an EVM disambiguation modal), and payment URIs drive the guaranteed side per the design doc.

Stealth Swap adds a matching toggle on the Exchange amount-entry scene (fixed Houdini provider on confirm). Transaction list and details use new swapType-based titles, hide the recipient on private flows, label the spend target as Exchange Deposit Address, and still surface exchange order details.

Supporting work in this diff includes Maestro flows under maestro/14-stealth/ (spend flows tagged separately), testIDs on send tiles, confirm slider, and modals for automation, unit tests for private-send transaction titling in CategoriesActions, eslint snapshot/test updates, and changelog entries.

Reviewed by Cursor Bugbot for commit a518c23. Bugbot is set up for automated code reviews on this repo. Configure here.

@j0ntz

j0ntz commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (iOS sim, live wiring)

agent proof 1216251688512498 01 stealth send toggle

agent proof 1216251688512498 01 stealth send toggle

agent proof 1216251688512498 02 recipient chain picker

agent proof 1216251688512498 02 recipient chain picker

agent proof 1216251688512498 03 send to any quote

agent proof 1216251688512498 03 send to any quote

agent proof 1216251688512498 04 send to any success

agent proof 1216251688512498 04 send to any success

agent proof 1216251688512498 05 stealth swap toggle

agent proof 1216251688512498 05 stealth swap toggle

agent proof 1216251688512498 06 stealth swap houdini only

agent proof 1216251688512498 06 stealth swap houdini only

agent proof 1216251688512498 07 learn more browser

agent proof 1216251688512498 07 learn more browser

agent proof 1216251688512498 08 xrp destination tag

agent proof 1216251688512498 08 xrp destination tag

agent proof 1216251688512498 09 plain send regression

agent proof 1216251688512498 09 plain send regression

agent proof 1216251688512498 10 multi recipient gating

agent proof 1216251688512498 10 multi recipient gating

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from a5c408d to 9460a75 Compare July 8, 2026 23:05
@j0ntz

j0ntz commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

📸 Followup test evidence: send-to-any restricted to Houdini + 1rem asset row

agent proof 1216251688512498 01 send to any houdini 1rem row

agent proof 1216251688512498 01 send to any houdini 1rem row

agent proof 1216251688512498 02 send to any configured

agent proof 1216251688512498 02 send to any configured

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from 9460a75 to 31be7d8 Compare July 14, 2026 21:22
@j0ntz

j0ntz commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (followup: vertical spacing fix)

agent proof 1216251688512498 03 vertical gap matched

agent proof 1216251688512498 03 vertical gap matched

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch 2 times, most recently from 8275d96 to 456e38b Compare July 24, 2026 23:46
@j0ntz

j0ntz commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (followup: plain-send regression with QR amount)

qr plain send

qr plain send

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from 456e38b to 9d29ac7 Compare July 26, 2026 08:10
@j0ntz

j0ntz commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (followup: scanned payment QR, executed end to end)

qr crosschain live quote

qr crosschain live quote

qr crosschain executed

qr crosschain executed

qr crosschain txdetails

qr crosschain txdetails

qr stealth live quote

qr stealth live quote

qr stealth executed

qr stealth executed

qr stealth txdetails

qr stealth txdetails

Captured by the agent's in-app test run (build-and-test).

@j0ntz

j0ntz commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (followup: cross-chain address entry across chains, tokens, and entry paths)

paste detected litecoin

paste detected litecoin

paste live quote armed

paste live quote armed

paste swap executed

paste swap executed

paste swap txdetails

paste swap txdetails

type btc to eth picker

type btc to eth picker

network picker

network picker

deeplink eth uri

deeplink eth uri

token source usdc

token source usdc

token source usdt

token source usdt

Captured by the agent's in-app test run (build-and-test).

@j0ntz

j0ntz commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (followup: route availability reflected in the UI)

stealth armed

stealth armed

stealth autodisable toast

stealth autodisable toast

standard quote after fallback

standard quote after fallback

preemptive tap toast

preemptive tap toast

fallback swap executed

fallback swap executed

fallback swap txdetails

fallback swap txdetails

fixedto fallback card

fixedto fallback card

fixedto preemptive tap toast

fixedto preemptive tap toast

card cleared after edit

card cleared after edit

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from f89cc68 to 86089c7 Compare July 28, 2026 07:43
@j0ntz

j0ntz commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (followup: Houdini exclusivity restored + flip-input amounts)

crossasset stealth on errorcard

crossasset stealth on errorcard

yousend flipinput modal

yousend flipinput modal

stealth off houdini only errorcard

stealth off houdini only errorcard

raw error no route

raw error no route

recipientgets flipinput modal

recipientgets flipinput modal

stealth on stays on errorcard

stealth on stays on errorcard

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from 86089c7 to a00bf68 Compare July 28, 2026 21:16
@j0ntz

j0ntz commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (followup: routable Myself picker + transaction identity)

myself grouped list

myself grouped list

myself crossasset adopted

myself crossasset adopted

stealth send quote

stealth send quote

stealth send executed

stealth send executed

tx list title

tx list title

stealth details from list

stealth details from list

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz marked this pull request as ready for review July 29, 2026 00:26
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread src/actions/CategoriesActions.ts
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/components/scenes/SendScene2.tsx Outdated
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch 2 times, most recently from 0a5b386 to bf3e1d9 Compare July 29, 2026 00:43
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/components/scenes/SwapConfirmationScene.tsx
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/components/scenes/SwapCreateScene.tsx
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from 1eff6c6 to 9737aa6 Compare July 29, 2026 01:21
@j0ntz

j0ntz commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

📸🪓 Test evidence

🪓 Hack-forced evidence: pinSpendingLimitsEnabled forced true and the limit forced to $1 in SendScene2, because enabling the real setting needs the account password (settings lock) which the roster does not provide. Reverted; git status clean. Temporary uncommitted edit, reverted before commit; the marked frames prove the rendering, not the trigger.

error card provider floor

error card provider floor

error card no route

error card no route

myself picker grouped

myself picker grouped

🪓 HACK-FORCED: pin row on swap send

🪓 HACK-FORCED: pin row on swap send

Captured by the agent's in-app test run (build-and-test).

Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/actions/CategoriesActions.ts
Comment thread src/components/cards/SwapDetailsCard.tsx
Comment thread src/util/swapErrorDisplay.ts
Comment thread src/util/houdiniChains.ts Outdated
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch 3 times, most recently from 1552cb1 to 5871557 Compare August 18, 2026 23:37

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5871557. Configure here.

Comment thread src/components/scenes/SendScene2.tsx Outdated
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from 5871557 to a518c23 Compare August 19, 2026 00:14
@j0ntz

j0ntz commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence: recipient asset picker, search, and a live Stealth Send

plain send names source token

plain send names source token

picker agrees and has search

picker agrees and has search

picker search matches name and code

picker search matches name and code

stealth on names native payout

stealth on names native payout

picker stealth on source chain once

picker stealth on source chain once

live private quote armed

live private quote armed

stealth send success

stealth send success

tx details stealth swap send

tx details stealth swap send

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch 2 times, most recently from 46a0279 to 48a41b2 Compare August 26, 2026 00:26
@j0ntz
j0ntz marked this pull request as draft August 27, 2026 23:00
@j0ntz

j0ntz commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence: swap-under-the-send warnings

swap send modal

swap send modal

swap send card

swap send card

houdini terms modal

houdini terms modal

swap confirm after terms

swap confirm after terms

swap success

swap success

swap tx details

swap tx details

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from df02ff3 to fe21e98 Compare September 3, 2026 01:18
j0ntz added 18 commits September 2, 2026 19:34
Extract the swap confirmation scene's price-impact computation and the quote
card's colored percentage text into PriceImpactText, so the send scene's quote
row can reuse the same delta UI.

The quote card and the provider row also stop narrowing the destination wallet
inline. EdgeSwapRequest.toWallet is optional once swap-to-address exists, and
every wallet-to-wallet surface needs the same guard, so requireDestinationWallet
holds the narrowing and its message in one place.
A row that wants one word of its header in a state color had to give up the
shared header styling and rebuild it. Add a titleState prop the row renders
after its title, and add the green PositiveText beside the existing orange
WarningText. Both set color only, so a span nested in a header keeps the
header's size. The caller supplies its own punctuation inside the node, which is
how parentheses take the state colour rather than the title's.
A UI walk cannot reliably target these surfaces by their visible text: a search
field's placeholder disappears as soon as the field holds a query, and a wallet
row repeats its own name inside the picker's search field, so a text selector
matches the field instead of the row. Key the wallet rows, transaction rows,
radio list items, search footers, the slider thumb, and the text-input and scan
modals by id instead.

RadioListModal also gains an optional message, so a picker that has to explain
why it is asking can say so between its title and its list.
getFiatExchangeRate reaches into the whole store for a fiat-to-fiat rate, which a
caller that already holds a GuiExchangeRates snapshot has no reason to do. Move
the arithmetic into getFiatRate over the snapshot and leave the store-shaped
function as a one-line wrapper, so existing callers are untouched.
A scanned QR carries a payment URI (ethereum:0x...?amount=0.5), never a bare
address, and the sending wallet's own parseUri cannot read one addressed to
another chain. Add a generic splitter that reports the scheme, the address, and
the requested amount without knowing anything about which chains exist.
HOUDINI_CHAINS snapshots Houdini's GET /chains intersected with Edge currency
pluginIds, carrying each chain's address-validation regex and whether it needs a
memo, and mirroring the edge-exchange-plugins chain mapping. Two of the
provider's published regexes are corrected here rather than routed around: the
Cardano pattern ends in an unanchored zero-length alternative that matches every
string including the empty one, and the PIVX class writes A-z, which also spans
the six punctuation characters between the alphabet halves.

Register the houdini swap plugin through HOUDINI_INIT env config like every other
provider.
Send-to-any-address is a privacy feature, so it never shops the destination
address to other swap providers: every send-shaped quote request disables all
providers except Houdini, stealth toggle on or off. The toggle instead decides
what is asked for, setting privacy to required so the provider must offer a
sender-unlinkable route rather than answer with a transparent one.

The request also force-enables Houdini past the account's exchange settings,
which govern swapping rather than sending.
ErrorCard renders anything that is not an I18nError as "Unexpected Error" with a
canned body and a Report Error button, so a user under the provider's floor would
be told nothing about the floor and an outage would look identical to a bug. The
wallet-to-wallet flow already maps these properly, so that mapping moves out of
SwapProcessingScene into swapErrorDisplay and both flows share it: the limit that
was crossed and by how much, the pair that cannot route, the geo restriction, or
the provider's own message for anything without a known shape.

It takes a toCurrencyCode option because a send-to-address request carries no
destination wallet to read a currency code from.
A picker whose list spans several assets may want one of them first. WalletList
and WalletListModal take an opt-in pinnedAssets filter with its own section
titles: matching rows render first, everything else follows. Callers that omit it
keep today's recent-then-all ordering, and searching stays flat as it already
does for every other caller.
A cross-chain destination address cannot go through the source wallet's parseUri,
which reads it as an invalid address for its own chain. AddressTile2 takes a
crossChainAddressValidation override so a caller that knows the destination chain
can validate against that chain's own rules instead, covering Paste, Enter
address, and Scan through the one changeAddress path they share.
The send scene offers a "Recipient receives" asset selector over the destination
chains the provider serves, and a Stealth Send toggle. Stealth or a cross-asset
recipient turns the send into a swap-to-address quote: live quotes through
account.fetchSwapQuotes with toAddressInfo, linked "You send"/"Recipient gets"
rows whose edited side is the guaranteed amount and whose other side tracks the
quote as an estimate, each row naming its state in its own title, the shared
price-impact indicator, an expiry countdown that re-quotes, the quote's network
fee, and a destination tag row on memo-required chains that rides toMemos to the
provider. Both amounts go through the standard flip input and open on fiat, as
the swap scene's inputs do. The confirm slider approves the quote and lands on
the swap success scene.

An address the sending wallet cannot read is matched against the served
destination chains rather than reported as invalid: a URI scheme names its chain
outright, a bare address is matched on format, and where several chains share one
format the user picks rather than the app guessing and misdirecting funds. A URI
amount is what the RECIPIENT should receive, so it sets the guaranteed receive
side for a cross-asset destination; a same-asset stealth send keeps it on the
send side, because the provider serves no receive-priced route when the two
assets match.

What the pair cannot route is learned from the quote failures themselves rather
than probed: a same-asset pair with no private route turns the toggle off with a
toast and degrades to the plain send it had upgraded, a missing receive-priced
route falls back to a rate-seeded guaranteed send amount, and re-arming either on
a known-unavailable pair answers pre-emptively instead of sending another doomed
quote. Amounts under the applicable floor are refused before a request goes out.

Plain same-asset sends are unchanged, including multi-recipient UTXO sends, which
now also show a total-amount row. Multi-recipient and stealth/cross-asset are
mutually exclusive, gated in both directions. Constrained callers (locked or
hidden tiles, FIO requests, payment protocol, custom broadcast or completion
hooks) keep today's behavior.
A Stealth Swap toggle on the amount-entry scene restricts the quote request to
the Houdini privacy provider and asks it for a private route, with a working
"Learn more" link. The confirmation scene keeps the restriction on its re-quotes
and renders the powered-by card as a fixed provider, with no chevron and no
"tap to change provider" hint, through a now-optional PoweredByCard onPress.

A pair the provider cannot route privately turns the toggle off and returns the
user to the filled-in form rather than a dead-end error, through a new optional
onError hook on swapProcessing that gets first refusal on the failure.
A swap-send, a stealth send, and a stealth swap-send all landed under the same
generic swap title, and the two private flows displayed the recipient they exist
to conceal. Each flow now names itself on the swap action's swapType, which only
the send scene can determine: the plugin sees an ordinary swap, and with every
send-to-address quote restricted to the privacy provider the winning plugin
cannot tell them apart either. The list and details map the field to a title.

A private send skips the recipient write into transaction metadata and hides the
payout address in the details text, while keeping it on the swap data so support
can trace an order. The order id and provider stay visible for the same reason:
the card used to resolve its payout denomination through the payout wallet and
render nothing without one, which hid them entirely on a synthetic destination.

The spend-target row is retitled from the saved action, so a send-shaped swap
reads "Exchange Deposit Address" (the address the funds actually went to) and
every other transaction keeps today's wording. A token send pays its fee in the
chain's own coin, so makeSwapPluginQuote files a second action under tokenId null
from the plugin's own copy, which carries no swapType; the send scene stamps that
row under the same condition the plugin writes it, and the title map applies only
where the asset action is not a network fee, so the fee row does not become a
second private send in the list.
A flow per user-visible branch in maestro/14-stealth, built from reusable
subflows in maestro/common so a later session can drive one specific state
without walking the simulator by hand. The two flows that move funds carry their
own tag, so a run of the suite cannot spend.
A row's label was also its selection key, which only holds while every
label is unique. It is not: the POL ERC-20 on Ethereum carries the same
display name and currency code as the Polygon chain, so a list holding
both marked both rows selected and resolved either tap to the same row.
Rows may now carry a `value`, which defaults to the name so existing
callers are unaffected, and which the row's testID follows.

`searchPlaceholder` turns on the search box `ListModal` already
provides, filtering on the label and its subtext. Lists that omit it are
unfiltered as before. Submitting is a no-op rather than resolving the
bridge with the raw search text, which would close the modal without
picking anything.
The dedicated swap scene already asks for a one-time terms
acknowledgement on every centralized provider it routes
through, keyed off the provider's own agreedToTerms user
setting. Houdini had no entry, so a plain swap routed by it
showed nothing.
Stealth Send and a cross-asset recipient both turn the send
scene into a swap-to-address, so the wallet pays the provider
and the provider pays the recipient. That two-transaction
shape is not visible on the scene, so say it once per account,
the same way the send scam warning does.
The one-time modal covers the first send only. A card in the
scene's warning area states, for every swap-routed send, that
the recipient is paid by a second transaction and the send
takes longer than usual. Private routing gets its own copy.
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from fe21e98 to 1c074e1 Compare September 3, 2026 02:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant