Add Stealth Send and Stealth Swap (Houdini privacy routing) - #6066
Add Stealth Send and Stealth Swap (Houdini privacy routing)#6066j0ntz wants to merge 18 commits into
Conversation
📸 Test evidence (iOS sim, live wiring)agent proof 1216251688512498 01 stealth send toggle agent proof 1216251688512498 02 recipient chain picker agent proof 1216251688512498 03 send to any quote agent proof 1216251688512498 04 send to any success agent proof 1216251688512498 05 stealth swap toggle agent proof 1216251688512498 06 stealth swap houdini only agent proof 1216251688512498 07 learn more browser agent proof 1216251688512498 08 xrp destination tag agent proof 1216251688512498 09 plain send regression agent proof 1216251688512498 10 multi recipient gating Captured by the agent's in-app test run (build-and-test). |
a5c408d to
9460a75
Compare
9460a75 to
31be7d8
Compare
8275d96 to
456e38b
Compare
456e38b to
9d29ac7
Compare
📸 Test evidence (followup: cross-chain address entry across chains, tokens, and entry paths)paste detected litecoin paste live quote armed paste swap executed paste swap txdetails type btc to eth picker network picker deeplink eth uri token source usdc token source usdt Captured by the agent's in-app test run (build-and-test). |
📸 Test evidence (followup: route availability reflected in the UI)stealth armed stealth autodisable toast standard quote after fallback preemptive tap toast fallback swap executed fallback swap txdetails fixedto fallback card fixedto preemptive tap toast card cleared after edit Captured by the agent's in-app test run (build-and-test). |
f89cc68 to
86089c7
Compare
📸 Test evidence (followup: Houdini exclusivity restored + flip-input amounts)crossasset stealth on errorcard yousend flipinput modal stealth off houdini only errorcard raw error no route recipientgets flipinput modal stealth on stays on errorcard Captured by the agent's in-app test run (build-and-test). |
86089c7 to
a00bf68
Compare
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
0a5b386 to
bf3e1d9
Compare
1eff6c6 to
9737aa6
Compare
📸🪓 Test evidence
error card provider floor error card no route myself picker grouped 🪓 HACK-FORCED: pin row on swap send Captured by the agent's in-app test run (build-and-test). |
1552cb1 to
5871557
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ 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.
5871557 to
a518c23
Compare
📸 Test evidence: recipient asset picker, search, and a live Stealth Sendplain send names source token picker agrees and has search picker search matches name and code stealth on names native payout picker stealth on source chain once live private quote armed stealth send success tx details stealth swap send Captured by the agent's in-app test run (build-and-test). |
46a0279 to
48a41b2
Compare
df02ff3 to
fe21e98
Compare
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.
fe21e98 to
1c074e1
Compare





































































Technical Design Document
stealth-send-swap.md
CHANGELOG
Does this branch warrant an entry to the CHANGELOG?
Dependencies
toAddressInfo+EdgeSyntheticDestinationWallet)Ready for review. Two things block merge. The dependency PRs above must publish and the version pins here must bump. And
STEALTH_LEARN_MORE_URIinsrc/constants/stealthConstants.tsis 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 todevelop/staging, andpr-checks.ymlruns only the WIP blocker), so the branch reviews cleanly as-is; it will not compile against published edge-core-js 2.47.1, which lackstoAddressInfoandEdgeTxActionSwap.swapType. Locally it is verified against the linked dep branches:tscclean, eslint clean, full jest suite green.Requirements
If you have made any visual changes to the GUI. Make sure you have:
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 (
SendScene2becomes 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.swapSendAllowedpredicate, so payment protocol, FIO requests, deep links, and any caller that pre-locks tiles or takes over broadcast keep today's behavior.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.PriceImpactText, a real expiry countdown that re-quotes, and the quote's own network fee.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 writesethereum: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
swapTyperather than inferred, because only the scene knows which shape ran. The two private flows suppress the recipient in the UI while keepingorderIdandpayoutAddressin 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
developand re-reviewed against it. Behavior changes from that round, in order of consequence:hidePayoutAddressreadsavedAction.swapType, which reaches the saved action through a best-effortsaveTxActionthe 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.detectHoudiniChainsdropped the source chain unconditionally, so a USDC-on-Ethereum send with a pasted0xaddress 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.fixedToroute 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.toWallet, and the tags fell back to the SOURCE wallet, so every stealth-send failure looked like a same-chain error in monitoring.InteractionManager.runAfterInteractions, matching the plain-send path beside it; the twosaveTxActionwrites 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:
SafeSlidercarried a duplicatetestIDattribute 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. Onlytsccaught it.The maestro suite was repaired against current
developin the same round, and two of those repairs are product fixes rather than flow edits.walletListRowids 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 nowwalletPickerRow.<name>, threaded throughWalletListas atestIdPrefix. On the flow side,stealth-open-send.yamlnow 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.tsis 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 writesA-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 an0xEVM 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 theethereum: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.RadioListModalrows 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 filteringListModalalready 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.
tscand 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 newwalletPickerRowids. 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.
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 aConfirmContinueModal. Informational, so it never blocks the send.SwapConfirmationScenewas already callingswapVerifyTerms, and Houdini was the only routed provider with nopluginDatarow, so it showed nothing. Its entry carries Houdini's Terms of Use, Privacy Policy and AML/KYC links, and persists through the provider's ownagreedToTermsuser setting.The send scene cannot reuse the terms modal:
swapVerifyTermsruns onSwapConfirmationScenealone 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 inCategoriesActions, 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.