Skip to content

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

Merged
j0ntz merged 20 commits into
developfrom
jon/stealth-send-swap
Oct 5, 2026
Merged

j0ntz merged 20 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

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?

  • Yes
  • No

Dependencies

Both dependency PRs are merged and published: this branch landed on edge-core-js 2.51.0 and edge-exchange-plugins 2.58.0, and the version pins on develop carry those releases. Verified against them before the merge: tsc clean, eslint clean, jest green.

STEALTH_LEARN_MORE_URI in src/util/stealthSwap.ts ships 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:

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

Test evidence

e6d2ce5
Show 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 swapSend actions 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 via getPluginIdIcon, and HOUDINI_INIT is wired into env splitting.

Maestro adds a tagged 14-stealth suite (plus reusable subflows) covering quotes, toggles, QR/cross-chain paths, and opt-in stealth-spend flows; screenshot output is gitignored. Automation gains stable testIDs on address/text-input modals, wallet picker search, transaction rows, and the confirm slider thumb; eslint allows PositiveText in 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.

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch 2 times, most recently from 9460a75 to 31be7d8 Compare July 14, 2026 21:22
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch 5 times, most recently from 86089c7 to a00bf68 Compare July 28, 2026 21:16
@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 Outdated
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 Outdated
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
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/actions/CategoriesActions.ts Outdated
Comment thread src/components/cards/SwapDetailsCard.tsx Outdated
Comment thread src/util/swapErrorDisplay.ts
Comment thread src/util/houdiniChains.ts Outdated
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/SendScene2.tsx
Comment thread src/components/scenes/SwapCreateScene.tsx
Comment thread src/components/scenes/SendScene2.tsx Outdated
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/components/scenes/SendScene2.tsx
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch 4 times, most recently from ed195cb to e213d7c Compare July 30, 2026 08:39
@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot 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.

@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.

Stale Bugbot comment from a previous run.

Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/util/stealthSwap.ts
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from 6ed2153 to e99ea97 Compare October 5, 2026 21:47
Comment thread src/util/stealthSwap.ts
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from e99ea97 to e6d2ce5 Compare October 5, 2026 22:31

@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 default 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 e6d2ce5. Configure here.

Comment thread src/components/tiles/AddressTile2.tsx
j0ntz added 20 commits October 5, 2026 16: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.
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
j0ntz force-pushed the jon/stealth-send-swap branch from e6d2ce5 to ccd9f42 Compare October 5, 2026 23:36
@j0ntz
j0ntz enabled auto-merge October 5, 2026 23:52
@j0ntz
j0ntz merged commit 77ef928 into develop Oct 5, 2026
7 checks passed
@j0ntz
j0ntz deleted the jon/stealth-send-swap branch October 5, 2026 23:56
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.

2 participants