Repository navigation
Add Stealth Send and Stealth Swap (Houdini privacy routing) - #6066
Merged
Merged
Conversation
j0ntz
force-pushed
the
jon/stealth-send-swap
branch
2 times, most recently
from
July 14, 2026 21:22
9460a75 to
31be7d8
Compare
j0ntz
force-pushed
the
jon/stealth-send-swap
branch
5 times, most recently
from
July 28, 2026 21:16
86089c7 to
a00bf68
Compare
j0ntz
marked this pull request as ready for review
July 29, 2026 00:26
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
j0ntz
force-pushed
the
jon/stealth-send-swap
branch
2 times, most recently
from
July 29, 2026 00:43
0a5b386 to
bf3e1d9
Compare
j0ntz
force-pushed
the
jon/stealth-send-swap
branch
from
July 29, 2026 01:21
1eff6c6 to
9737aa6
Compare
j0ntz
force-pushed
the
jon/stealth-send-swap
branch
4 times, most recently
from
July 30, 2026 08:39
ed195cb to
e213d7c
Compare
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
j0ntz
force-pushed
the
jon/stealth-send-swap
branch
from
October 5, 2026 21:47
6ed2153 to
e99ea97
Compare
j0ntz
force-pushed
the
jon/stealth-send-swap
branch
from
October 5, 2026 22:31
e99ea97 to
e6d2ce5
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default 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 e6d2ce5. Configure here.
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.
The HoudiniSwap mark is a single light color on a transparent background, so it disappears on a light theme. The CDN now has an icon-light.png beside it. Only providers on the allowlist take the themed path, because the other providers have no light variant and the request would fail.
A HoudiniSwap swap or send showed no logo in the transaction list or on its details scene, where every other swap provider has one. Resolve the provider logo through one theme-aware lookup so the single-color HoudiniSwap mark follows the theme in both places.
j0ntz
force-pushed
the
jon/stealth-send-swap
branch
from
October 5, 2026 23:36
e6d2ce5 to
ccd9f42
Compare
j0ntz
enabled auto-merge
October 5, 2026 23:52
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Technical Design Document
stealth-send-swap.md
Description
Asana task
📄 Stealth Send and Stealth Swap: send to any address, on any chain, privately
CHANGELOG
Does this branch warrant an entry to the CHANGELOG?
Dependencies
toAddressInfo+EdgeSyntheticDestinationWallet)Both dependency PRs are merged and published: this branch landed on
edge-core-js2.51.0 andedge-exchange-plugins2.58.0, and the version pins ondevelopcarry those releases. Verified against them before the merge:tscclean, eslint clean, jest green.STEALTH_LEARN_MORE_URIinsrc/util/stealthSwap.tsships as the placeholder the task specifies: both Stealth toggles' "Learn more" opens that gist until the Stealth Send article exists. Swapping in the article URL is a one-line edit at that constant.Requirements
If you have made any visual changes to the GUI. Make sure you have:
Test evidence
e6d2ce5Show the HoudiniSwap logo on tx rows
2026-10-05
🪓 temporary uncommitted throw before liveQuote.approve() in the swap-send branch of SendScene2 so the real failure path runs; reverted, no funds moved
10. agent proof 1216251688512498 10 multi recipient gating
35. tx list title
4. exchange deposit address organic
1. you send opens on fiat
2. fiat typed crypto below
4. guaranteed row
3. picker search matches name and code
2. swap send card
3. houdini terms modal
4. swap confirm after terms
5. swap success
6. swap tx details
7. stealth card learn more
8. learn more opens gist
15. recipient picker lists monad
19. monad exchange error
23. bch pasted private quote armed
24. ton selected destination tag row
25. ton tag private quote
29. monad dex quote armed
32. organic password prompt after card tap
33. organic prompt closed by handler
35. detected network picker
36. picked network adopted
37. dash picked ethereum quote armed
40. dash recipient gets reverse quote armed
43. trx self stealth quote armed
44. trx self stealth success
45. trx self stealth txdetails
1. send payout tile reads tether
2. picker first row names tether
3. picker separate tron trx row
4. trx picked swap notice
5. first row reverts to usdt
7. requote first quote
8. requote replaces held quote
30. stealth on then uri tag carried
32. stealth armed tag seeded
33. myself usdt source lists trx
34. myself trx payout adopted
47. uri memo parsed shared parser
48. 🪓 swap send modal
9. 🪓 kill switch zano error replaces swap warning
10. 🪓 fallback card carries swap warning
49. typed address network picker alone
50. btc to eth swap send quote
51. btc to eth swap send success
52. 🪓 swap send approve failure slider locked
53. 🪓 swap send lock survives amount edit
54. houdiniswap logo on transaction row
55. houdiniswap logo on transaction details
Note
High Risk
Introduces real cross-chain sends and swaps through a third-party privacy provider, with privacy-sensitive transaction labeling and dependency on unpublished edge-core-js and edge-exchange-plugins changes.
Overview
Adds Stealth Send and Stealth Swap, routing sends and swaps through the Houdini privacy provider with toggles on Send and Exchange, cross-chain destination selection (including pasted foreign-chain addresses and payment URIs), swap-send quoting with private vs transparent routes, and UI that notes when a swap provider pays the recipient.
Transaction history labels Houdini
swapSendactions as Swap & Send, Stealth Send, or Stealth Swap & Send based on privacy and asset mismatch, suppressing stored recipient names on private sends. Houdini gets theme-aware exchange icons viagetPluginIdIcon, andHOUDINI_INITis wired into env splitting.Maestro adds a tagged
14-stealthsuite (plus reusable subflows) covering quotes, toggles, QR/cross-chain paths, and opt-instealth-spendflows; screenshot output is gitignored. Automation gains stabletestIDs on address/text-input modals, wallet picker search, transaction rows, and the confirm slider thumb; eslint allowsPositiveTextin raw-text rules.CHANGELOG documents the user-facing behavior; Send scene snapshots reflect the new recipient-asset row, Stealth Send card, and total-amount rows in swap-send confirmation.
Reviewed by Cursor Bugbot for commit ccd9f42. Bugbot is set up for automated code reviews on this repo. Configure here.