Add Stealth Send and Stealth Swap (Houdini privacy routing) - #6066
Add Stealth Send and Stealth Swap (Houdini privacy routing)#6066j0ntz wants to merge 61 commits into
Conversation
edge-core-js swap-to-address makes EdgeSwapRequest.toWallet optional (an address-only toAddressInfo destination replaces it) and makes EdgeTxActionSwap.payoutWalletId and EdgeTxSwap.payoutWalletId optional. Sweep the consumers: wallet-to-wallet swap scenes throw if their destination wallet is missing, savedAction and swap-details readers tolerate a missing payout wallet.
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.
HOUDINI_CHAINS snapshots Houdini's GET /chains intersected with Edge currency pluginIds (per-chain address validation regex + memoNeeded), mirroring the edge-exchange-plugins chain mapping. Register the houdini swap plugin through HOUDINI_INIT env config like other providers.
📸 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). |
Toggling Stealth Send left the standing quote in state until the re-quote effect ran, so for a render the slider was armed against a route priced under the other privacy setting. And an expired swap quote only disabled its slider by navigating away, which the terms check defers, leaving the scene on screen with a dead quote and a live slider.
Two writes raced on the same state. A source-asset change reset the spend and then the wallet-or-asset block wrote a spread of the PRE-reset value over it, putting the old recipients back. And entering swap-send mode did not retire a plain makeSpend already in flight, so its success handler could land afterward and write plain-send state, including a cleared error, over a scene the quote effect had already put a swap error on.
The control gated on another wallet of the same type, which is right for the plain send that owns that test. A Stealth Send supplies the whole set of assets it can route to, so the gate hid the picker from exactly the account it exists for: one wallet on the source chain and the rest elsewhere.
The expiry error was written into the shared error state as a plain-send one, so entering swap-send retracted it while the expiry flag stayed set: a disabled slider with nothing on screen saying why. The message belongs to the REQUEST, so the plain-send clear now refuses it. A source wallet or asset switch retires whatever error is showing, for the same reason it already retires the toggle and the learned route capabilities.
|
bugbot run |
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. |
Section 6 records the two bounds on the rate-limit retry and why the max path does not create an exchange. Section 7 records the Myself gate. Section 8 states the error-ownership rule and the retire-on-change rule that every finding in this round was a violation of. Phase 12 gains the second round.
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 in parentheses 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 state word sat under the amount, so each row read as three stacked lines and the amount was not the last thing on it. Put the word in the row's own title instead, parenthesised and tinted: green for the side the user fixed, warning orange for the side the quote is still estimating. The maestro flows that target these rows matched the title exactly, so they now allow the trailing state word, and the payment-URI walk asserts the whole title rather than a bare Guaranteed that no longer stands alone.
The flip input opens pre-filled and inputText appends, so re-editing a row committed the new digits concatenated onto the old amount rather than the amount asked for. Erase before typing; on a first edit there is nothing to erase and nothing changes. While here, assert both amount-row titles on the cross-asset walk, so a regression that swaps or drops the guaranteed and estimated states fails a flow instead of only changing a screenshot.
Phase 13: what moved, the EdgeRow and EdgeText additions it needed, the raw-text lint that ruled out composing the header in the scene, and the on-device drive of both states.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
The transaction details scene titled its spend-target row "Recipient Addresses". On a send-shaped swap that names the wrong party: the row holds the provider's deposit address, and the pasted recipient never reaches spendTargets at all. On a private send it reads as exactly the disclosure the flow exists to prevent, which is how it was reported. The row is now titled from the saved action, so a swapType makes it "Exchange Deposit Address" and every other transaction keeps the original wording. The audit that went with it found a leak the report had not. A token send pays its fee in the chain's own coin, so makeSwapPluginQuote files a second action under tokenId null built from the plugin's own copy, which carries no swapType. Every privacy rule keyed on one then reads false there, and the fee row renders the payout address the token row hides. The send scene stamps that row too, under the same condition the plugin writes it, so no parent-currency entry is invented for a mainnet send that has none. That stamp makes the fee row a send-shaped swap to every consumer, and the title map is applied before the asset action is read, which would have retitled it "Private Send" and put a second private send in the list. The map now applies only when the asset action is not a network fee, while name suppression stays bound to the flow.
📸🪓 Test evidence
ordinary swap recipient addresses 🪓 HACK-FORCED: exchange deposit address Captured by the agent's in-app test run (build-and-test). |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e1f42eb. Configure here.
|
Both Bugbot findings on Expiry error survives address reset. Correct, and the sibling call site already had the right order. Fee row stamp failure leaks payout. Right, and the fix went further than hardening the write. The stamp is best effort by design (the send must not fail because a title did not save), so resting a privacy rule on it was the actual mistake: it failed OPEN. The rule now fails closed independently of the stamp. |
EdgeRow wrote the parentheses around whatever titleState node it was handed, which put them outside the caller's colour component: the word came out green or orange while its own brackets kept the title's colour, so 'Recipient gets (Estimated)' read as three colours instead of two. The row now contributes only the separating space and the caller passes the punctuation it wants tinted. That is also the only shape of the contract under which a caller can colour punctuation at all.
Phase 15: the provider credentials returned, the first genuinely swapType-stamped transaction was made from a token source, and the deposit-address label is now proven without the hack Phase 14 needed. Notes the env.json cause behind the previous phase's blocked testing, since a disabled plugin and a missing credential reach the user through the same pickBestError path.
📸🪓 Test evidence: fee-row payout suppression (hack-forced state)
🪓 HACK-FORCED: fee row payout suppressed Captured by the agent's in-app test run (build-and-test). |


































































CHANGELOG
Does this branch warrant an entry to the CHANGELOG?
Dependencies
toAddressInfo+EdgeSyntheticDestinationWallet)Ready for review, but do not merge until those publish and the version pins bump here. This repo runs no typecheck on pull requests (Travis is limited to
develop/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
Design document (
src/docs/stealth-send-swap.md, committed on this branch). Written from the implemented state: the body is current reality,Phase historyreconstructs the phases, and the retrospective records where the design was wrong or silent.Production Stealth Send + Stealth Swap (UI Proposal A), replacing every prototype hack from #6054 with real wiring. The prototype PRs (#6054, #6031) stay open as reference.
Send (SendScene2 becomes a send-to-address swap). Judgement call: integrated INTO
SendScene2in place, no feature flag and no parallel scene (the spec's "SendScene2 becomes a send-to-address swap" reading). The new "Recipient receives" selector (variant A1: visible before address entry) and Stealth Send toggle card render only when the scene is an unconstrained plain send; callers that pre-lock or hide tiles, pre-fill an address (payment protocol, deep links), pay FIO requests, or take over broadcast/completion keep today's behavior untouched. Alternate (a feature-flagged parallel scene replacing the wallet Send button, the prototype's approach) rejected: it forks the send flow and leaves every other send entry point without the feature. TheTransactionListTopreroute andHoudiniSendScenere-skin from the prototype are gone.account.fetchSwapQuotes({ toAddressInfo })quote; plain same-asset sends keep the existing UI and behavior (regression-checked, including multi-recipient UTXO sends).You send (Guaranteed)in green,Recipient gets (Estimated)in warning orange, with the~prefix kept on the estimated amount. Editing "Recipient gets" issuesquoteFor: 'to'(reverse quote). Both amounts are entered through the standard crypto/fiat flip input (FlipInputModal2, committed on close, max hidden); the destination side borrows the user's own wallet on the destination chain for denominations and rates, falling back to a text modal when none exists.PriceImpactText(the prototype's recreated delta UI is deleted); real expiry countdown that re-quotes on expiry; real network fee from the quote.makeStealthSwapRequestOptions(all other providers disabled per-request), stealth toggle on or off: send-to-any is a privacy feature and never shops the destination address to other swap providers. (The first cut restricted only stealth sends and fanned plain cross-asset sends out; a followup made the restriction unconditional at operator direction. A pair Houdini cannot route errors instead of falling back to another provider, by design.)memoNeededper the Houdini chain metadata; the entered tag ridestoAddressInfo.toMemosto the provider (which forwards it asdestinationTagon order creation).AddressTile2+ the existingAddressModalfamily. Cross-chain destination addresses cannot go through the source wallet'sparseUri, soAddressTile2gains acrossChainAddressValidationoverride that validates against the destination chain's own regex (Houdini's per-chainaddressValidation). A scanned QR carries a payment URI (ethereum:0x...?amount=0.5), never a bare address, so the cross-chain branch first splits the URI generically (newsrc/util/paymentUri.ts, unit-tested) and validates each address candidate. A URI-supplied amount is the amount the RECIPIENT should receive, so in swap-send mode it sets the receive side as the guaranteed amount and lets the quote compute the send side; plain sends keep today's behavior of filling the send amount.src/util/houdiniChains.ts) is a generated snapshot of HoudiniGET /chains(2026-07-02) intersected with Edge currency pluginIds (38 chains including 6 memo chains), replacing the prototype's 4-chain hardcode and hard-coded memoNeeded flags. Native-asset destinations only for now (token destinations are a follow-up); dynamic sourcing from the API through the swap plugin is a follow-up noted in the module.SwapSuccessScene.Known conflict, multiple recipients (UTXO). Gated in both directions per the spec: stealth ON or a mismatched recipient hides "Add Another Address"; with multiple recipients present, the stealth toggle is disabled and the card expands with "Stealth Send and cross-asset recipients are not available when sending to multiple recipients," and the recipient-asset selector locks. Multi-recipient sends now also show a Total Amount row (the UI call the task left open) instead of only per-recipient amounts.
Other conflicts noticed by inspection (documented calls): max-spend is not offered in swap-send mode (the plain-mode max flow still works; a swap-send max via the plugins'
getMaxSwappableis a clean follow-up); FIO sends are constrained-mode and keep today's flow. Two calls recorded here were later REVERSED and are now shipped: PIN spending limits DO gate swap-sends (a swap-send is a send to an arbitrary external address, not a wallet-to-wallet swap; see the PIN-gate round below), andSwapDetailsCardDOES render on a stealth send's tx detail, resolving the payout asset from the saved action rather than needing a payout wallet (see the feedback round below).Swap. Stealth Swap toggle card on the amount-entry scene (final copy, working inline "Learn more"); the quote request is restricted to Houdini and the restriction survives re-quotes on the confirmation scene; the powered-by card renders as a fixed provider (no chevron, no "tap to change provider") via a now-optional
PoweredByCard.onPress.Copy is the final verbatim spec copy; the prototype's
houdini_incognito_*keys are gone (new keys arestealth_*). "Learn more" opens https://gist.github.com/j0ntz/b3f8101f0a1f79539150fc73511bff8b in the browser as the placeholder (final URL TBD).savedAction sweep for the now-optional
toWallet/payoutWalletIdacross swap scenes,SwapProviderRow,ExchangeQuoteComponent,CategoriesActions,TransactionDetailsScene, andSwapDetailsCard.Testing (iOS sim, edge-funds, deps linked via updot): a REAL cross-asset send-to-address executed end to end: Base ETH to Ethereum address, live ChangeNOW quote (this drive predates the Houdini-only followup above, when plain sends still fanned out), slider approved, deposit broadcast on-chain (txid
0xfd51a5c5d4ba44267d257506c977a8e88952f56987e653d2b812502fa739cf8b), SwapSuccessScene reached, transaction details render. Stealth Send/Swap quote paths verified live through the Houdini plugin (the shared dev API key's free-tier 10-exchanges/day cap blocked executing the stealth orders themselves this run; the quote retrieval, provider restriction, XRP destination-tag row, and tag-to-order threading are all verified, the last via the plugin's live-recorded fixture test). Multi-recipient gating, plain-send regression, and "Learn more" verified on-device; jest suite green (snapshots updated for the new rows); maestro walks added undermaestro/14-stealth/.Followups verified on-device since the first review pass. The "Recipient receives" row's icon-to-title vertical gap now matches a text row's (
marginTop: 0.375rem, measured at 3x: 24px vs the 25px reference). Scanned payment URIs are handled in both send-to-address modes and were driven to EXECUTION on a funded wallet: cross-chain LTC to anethereum:0x...?amount=0.007URI (live HoudiniSwap quote, 1 LTC = 0.0241865 ETH, executed, tx176c833c6f0ef09ea9c2ba5eb6a39e079a13262aadd362d87d195350114a54dc), and the same flow with Stealth Send ON (executed, tx53f03d92da63c9b20dc29a0c296b4043cefb71e334066bf78529d6cec2b11cb6). Both reached the SwapSuccessScene; screenshots are attached to the PR.A URI amount is routed to the guaranteed RECEIVE side only for a cross-asset destination. Same-asset (stealth) sends keep it on the send side, because the provider offers no receive-priced (fixed) route when the source and destination assets match, so guaranteeing the receive side there would make every same-asset payment URI unquotable.
Cross-chain address entry now sets itself up (followup). A user reported that pasting an Ethereum address into a Bitcoin wallet did not work, "as well as the text entry", and that the full URL failed too. Reproduced and fixed: the cross-chain override above only engaged once "Recipient receives" had already been changed, so until then every entry path handed the input to the SOURCE wallet's
parseUri, which rejected it as an invalid address. Users reach for the address first, so the feature read as broken.Input the sending wallet cannot parse is now matched against the served destination chains (
detectHoudiniChains). 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. The detected chain becomes the recipient asset and the send continues as a swap. This covers every entry path throughAddressTile2.changeAddressat once: Paste, Enter address, and Scan. Deep links are deliberately unaffected: they pre-fill an address, soswapSendAllowedis false and they resolve to the user's own wallet on the URI's chain, as before.Two HoudiniSwap address-validation regexes are corrected in the same branch. The published Cardano pattern ends in
|^[a-zA-z0-9]*|..., an unanchored zero-length alternative that matches EVERY string including empty, so it accepted any typo as a Cardano address and made every pasted address look like it could be paying Cardano. The PIVX class writesA-z, which also spans the six punctuation characters between the alphabet halves; PIVX addresses are base58.Followup testing. One executed swap plus an entry-path and chain matrix, all on the iOS sim against the live provider:
ltc1q65h…0xa87fd77e1a64310d565e462cbc91e5f3e0e748ff1bbb62857455aef37e4044e7(0.00631315 ETH, $11.93)0xF0825…0xF0825…FeT6HHp8…bitcoin:bc1q…?amount=0.0004ethereum:0xF0825…?amount=0.004bc1q7f5w…0xF0825…0xF0825…simctl openurlethereum:0xF0825…?amount=0.004Chains exercised: BTC, LTC, ETH, POL, SOL, plus USDC and USDT token sources.
verify-repo.shpasses (install, prepare, lint, full jest); 26 unit tests cover the URI splitter and chain detection.Two EVM chains that share an address format still cannot be told apart from a bare address, and the source wallet parses such an address as its own, so sending from one EVM chain to another still needs "Recipient receives" set by hand. That is unchanged by this PR and is called out as a follow-up.
Route availability reflected in the UI (followup). Whether the provider offers a private route, or a receive-priced (fixed) route, is a live property of each pair: a 24-pair sweep found private routes offered only from bitcoin and monero sources today, where litecoin worked two days ago. Until now a missing route surfaced as a generic unexpected-error card while the Stealth toggle stayed armed and a fixed receive amount stayed fixed.
A quote failing with
SwapCurrencyErrornow teaches the scene what the pair lacks (routeCaps, per pair, session-lived). No private route on a SAME-asset pair: the Stealth toggle turns itself off with a toast and a persistent info line, degrading the swap into the plain direct send it had upgraded; the Stealth Swap create scene returns to the filled-in form the same way (new optionalonErrorfirst-chance hook onswapProcessing). A cross-asset pair with no route keeps the plain error card: the request is Houdini-only with or without the toggle, so flipping it cannot help. No receive-priced route: a fixed receive amount (typed, or carried by a scanned payment URI) falls back to a guaranteed send amount seeded from display rates, with a toast and a warning card on the send scene that clears when the user edits an amount or changes the destination. Re-arming the toggle or re-fixing the receive amount on a known-unavailable pair answers with a pre-emptive toast instead of another doomed quote. Amount errors (below/above limits) keep the error card, since the route exists and the amount is the problem.Followup testing (iOS sim, live provider): stealth auto-disable driven to the toast, info line, and pre-emptive refusal on ETH -> LTC; the fallback re-quote on that drive executed through a provider fan-out that a later followup removed (txid
0x2f281f4ea143a775355da7876290aaa2a9a7d44160a68eeac89b1ebe92ac284c, 0.006 ETH, $11.34); the toast/info-line/routeCapsmechanics it proved are unchanged. Fixed-to fallback driven on a funded PIVX wallet: "Recipient gets" 1000 PIVX -> reverse quote failed -> toast, warning card, send side guaranteed at the rate-seeded amount; tapping "Recipient gets" afterwards refused with the pre-emptive toast; editing "You send" cleared the card. Screenshots attached. The design doc gained a flowchart of every UX branch (src/docs/stealth-send-swap.md, section 8), render-checked on GitHub.Slack comms for the HoudiniSwap team about the route-availability change and the PIVX deposit-address defect are posted on the Asana task.
Houdini exclusivity restored (followup). The route-availability followup above had briefly made the provider restriction conditional on the Stealth toggle, on the strength of this PR body's then-stale fan-out description (the Houdini-only followup had been autosquashed into the feature commit and never documented, so the unconditional restriction read as drift). Operator correction: all send and swap functionality is Houdini-exclusive. The unconditional restriction is restored, the stealth auto-disable is scoped to same-asset pairs where turning the toggle off actually changes the outcome, and this body, the design doc (phase 6 + retrospective item 5), and the flowchart now all state the exclusive routing so the history cannot mislead again. The swap-send amount rows also moved from plain text modals to the standard flip input (
FlipInputModal2), per the same followup.Followup testing (iOS sim, live provider): ETH -> pasted LTC address, 0.006 ETH via the new flip modal, Stealth OFF: the quote went Houdini-only and the error card carried
SwapCurrencyError: HoudiniSwap does not support ethereum:null to litecoin:null(the regressed code produced an armed ChangeNOW quote on this exact pair and amount). Stealth ON, same drive: toggle stayed on, same error card, no auto-disable, no re-quote. Both flip modals captured (ETH/USD and LTC/USD). The same-asset auto-disable degrade was not drivable: provider route availability flapped during testing and no funded same-asset pair lacked a private route at a fundable amount; its mechanics are the phase 5 lines already driven to execution. Screenshots attached.Routable Myself picker and transaction identity (followup). Two features plus a standing rule that supported-destination logic is route-derived everywhere.
The "Myself" recipient picker listed only wallets holding the exact source asset, so a send that can route to another chain could not pick one of the user's own wallets there. It now offers the source asset plus every chain the provider pays out to, read from the route metadata (
HOUDINI_CHAINSviagetHoudiniChain) rather than a hardcoded asset shape. Same-asset wallets pin to the top through a new opt-in grouping prop onWalletListModal(pinnedAssets/pinnedTitle/otherTitle); every other caller keeps today's ordering. A cross-asset pick runs through the same adoption path address detection uses. Private-route availability does not filter the list: the Stealth toggle handles that per selection, through the existingrouteCapsaffordance.Swap-sends, stealth sends, and stealth swap-sends were all titled "Swap Funds" in the transaction list, and the two private flows displayed the recipient they exist to conceal. Each flow now names itself on the swap action:
EdgeTxActionSwapgains an optionalswapType(swapSend,stealthSend,stealthSwapSend, dependent core PR), which only the send scene can determine, since the plugin sees an ordinary swap and every send-to-address quote is restricted to the privacy provider anyway. The list and details map it to "Swap & Send", "Stealth Send", and "Stealth Swap & Send". Recipient suppression is display-only: the broadcast path skips the recipient write into transaction metadata for a stealth send and the details card hides the payout address, whileswapDatakeepsorderIdandpayoutAddressso support can trace an order.The audit the standing rule asked for found one gate,
getHoudiniChain, which returns undefined for a non-null tokenId. That single point is why tokens are absent from address detection, the recipient picker, quote gating, and the Myself picker alike; relaxing it surfaces token destinations everywhere at once. Rationale for all three mechanism decisions is recorded in the design doc in the same commit as the code.Followup testing (iOS sim, live provider): the Myself picker shows the source wallet's ETH siblings under "Same Asset" and every routable chain under "Other Assets", with the source wallet excluded; picking a Litecoin wallet from it adopted Litecoin as the recipient asset and filled the address. A same-asset Stealth Send of 0.552 LTC to another of the user's own LTC wallets, picked through that list, EXECUTED to the success scene (txid
7ca59bc5e58c48df1fc75a048dd5f4178157bb9baf904fb22d5d3d62118d5df0, $25.53, live HoudiniSwap private route at 1.94%). It lands in the transaction list titled "Stealth Send" with no recipient, beside older sends still reading "Swap Funds", and its details scene shows the same title and no payout address. Screenshots attached.PIN gate and quote-state hardening (review round). Marking this PR ready for review turned the reviewer bots on for the first time. This repo runs no typecheck on pull requests, so the draft had bought nothing and hidden everything. Seven findings followed across two rounds; all are fixed and resolved.
The one that matters: a swap-send bypassed the PIN spending limit entirely.
handleSliderCompletereturned inside itsswapSendActivebranch before reaching thecheckPinblock, the makeSpend effect returned beforesetSpendingLimitExceededever ran so the flag sat at its initial false, anddisableSliderset its swap-send value in the first arm of an else-if chain that made the PIN arm unreachable, so the slider never prompted either. The limit calculation now lives in one handler both paths call, the check runs before both submit paths, and the swap-send slider carries the same gate and prompt. This reverses a documented decision (the design doc had listed PIN limits on stealth sends as "not doing, consistent with the existing swap flow"): that comparison was wrong, because a wallet-to-wallet swap moves funds between wallets the user already controls while a swap-send goes to an arbitrary external address. The reversal and its reasoning are recorded in the design doc.The rest are robustness: amount edits clear the standing quote and the quote effect carries a generation so a superseded response cannot land last and win; the fixed-to fallback no longer switches to an unseedable send side and strand the scene; an empty quote list raises the no-providers error instead of dereferencing
quotes[0], fixed inSwapProcessingSceneso both swap callers are covered; clearing the address resets the destination chain, tag, receive amount and quote; changing the source wallet also clears the fixed-to warning; and an expiring Stealth Swap quote degrades to a standard swap through the sameonErrorpath SwapCreateScene uses, with a newdisableStealthparam so the toggle actually turns off.Not verified on device. This session lost its simulator to a slot re-allocation partway through, so none of these fixes has been driven in-app. Static coverage only:
tscclean, eslint clean, full jest suite green (92 suites, 556 tests). The PIN prompt on a swap-send is the one worth driving first.Real failures in the error UI (followup). Operator followup: swap and send errors must show the real cause, not a generic alert.
ErrorCardrenders anything that is not anI18nErroras "Unexpected Error" with a canned network-connection body and a Report Error button, and every swap-send failure took that path. The mapping that already produces good text for wallet-to-wallet swaps moved out ofSwapProcessingSceneintosrc/util/swapErrorDisplay.ts, and the send scene now uses it: the limit that was crossed and by how much, the pair that cannot route, the geo restriction, or, for anything without a known shape, the provider's own message. It takes atoCurrencyCodeoption because a send-to-address request has no destination wallet to read a code from.Three review findings from the same round landed with it. The PIN spending-limit flag is now DERIVED during render rather than state written inside the makeSpend effect, which runs after the render that already holds a live quote and could leave the slider armed for a frame while the flag still read false. Changing the source wallet now clears the Stealth toggle and the learned route capabilities, which were learned from the previous pair. And a private send's title now outranks any stored
metadata.name, which could otherwise display the recipient the flow exists to conceal.Verified on device (iOS sim, edge-funds, live HoudiniSwap): a cross-asset send-to-address of 10 TRX from My Tron 2 to a Litecoin address, against the provider's 10 USD floor, renders "Exchange Error / HoudiniSwap: Amount is too low, minimum is 10 USD". The same scene at an unroutable amount renders the provider's no-DEX-routes message. Both replace the previous "Unexpected Error" card. The Myself picker is included as evidence of the grouped destination list. The PIN row screenshot is 🪓 hack-forced: enabling the real setting needs the account password behind the settings lock, which the test roster does not carry, so
pinSpendingLimitsEnabledand the limit were forced locally, captured, and reverted. It proves the row renders on a swap-send at all, which it never did before; it does not prove the settings path that turns it on.Provider feedback round (followup). A feedback round from the Houdini team plus two internal reviewers, with a standing rule that every supported-destination decision must be route-derived, and two hard constraints: no availability probing, polling, or per-pair caching anywhere, and graceful rate-limit handling.
EdgeSwapRequest.privacy: 'required'(core) makes the Stealth toggle change what is asked of the provider rather than only what the transaction is called. Houdini serves private routes from 25 USD and standard routes from 10, so a plain Swap & Send may now take a standard route in the band where private has none, while a Stealth send declines rather than silently accepting a single-leg route it cannot detect.HOUDINI_MIN_USDconstants and are enforced BEFORE any request, which also keeps a user thumbing through small amounts from spending the provider's rate limit on refusals we already know about.hasSelfPrivateon the provider's token query, mirrored onto each chain entry, so it answers with no quote and no request. One served chain (RBTC) is false.retryAfter, doubling on top of it, and fails after three attempts as a rate limit rather than as an unavailable route. It throws a plainError, so theSwapCurrencyErrorpath never fires and norouteCapsverdict is written from a throttled request. Tier check: 6 quote calls inside 27s drew no 429, so the test credentials are not on the free tier (5/min); nox-ratelimit-*headers come back, so the exact tier cannot be read off a response.nullwithout one, and a swap-to-addresspayoutWalletIdis synthetic, so EVERY swap-send rendered no card at all. It now reads the payout asset's config off the saved action. Only the payout address stays masked, since the reason for storing it is support traceability, which needs the order id readable.EdgeSwapRequestOptions.forceEnabled); the Exchange scene keeps honoring it. That setting governs which providers the swap aggregator may use, so switching off a swap provider should not silently remove private sends.FiatTextcomponent, which is how the rest of the app shows a crypto amount's fiat value. A first pass put them on a second line; the operator called that out and it was replaced.celo,fantom,polkadot,ton) have no mainnet native at the provider, so every quote to them threw while the UI offered them; they are removed. Six more (algorand,ecash,hyperevm,sonic,stellar,zcash) could never resolve a token id because the API spells "no contract address" as an empty string on those chains and the native lookup tested only fornull.Followup testing (iOS sim, edge-funds, live HoudiniSwap, deps linked via updot). All on My Tron 2 to a Litecoin address adopted through the Myself picker:
sqc1ci6sNL8ygoGLFHyFK4, deposit txide57e12b09e090de7367bbc742e306e43933dc1ba59b50cd80ea198f56e0dcddd.Screenshots attached. Static:
tscclean on all three repos,verify-repo.shPASSED on all three (gui jest 92 suites / 556 tests / 107 snapshots).Not verified on device at the time. The
hasSelfPrivate: falsebranch (RBTC is the only served chain with it, and no roster account holds a funded RBTC wallet), the 429 backoff path (the credentials are above free tier, so a real 429 was not reachable without deliberately hammering the provider, which the same feedback round forbids), and the six empty-string chains' quotes. The last of those is now driven; see the follow-up sweep below.Review round on the above. Six Bugbot findings across three passes, all fixed and resolved, each re-verified on the sim afterwards.
privacy: 'required', so a wallet-to-wallet stealth swap could be served Houdini's transparent standard route while the UI labelled it private. Both swap-scene requests now set it, including the expiry re-quote.toTokenId: null, so a token pays out the destination chain's native asset and crosses assets even on its own chain; it was titled "Stealth Send". Labelling now follows the payout asset, while the activation and auto-disable decisions follow whether a recipient asset was adopted, which also gives a token send the degrade path it was missing.Follow-up sweep (followup). Operator followup: work the previous round's own follow-up list rather than carry it, and answer three of its items outright. Funding a wallet a test needs is a step to perform, not a precondition to report. A product question left open for two rounds was to be decided.
7erEQCvTU8VyySASH4DqLo, deposit txid0x19e770c2b7bca225caae2f3a55835c42626104bf59669b6bceddd3697db6f537; it settled, with Stellar going 30.484 to 187.921 XLM. Both ends of that pair are chains the provider reports withaddress: "", so neither could resolve a token id before this branch and the pair could not quote at all.celo,fantomandpolkadothave no entry inGET /tokens?mainnet=true, and thetonchain carries one token and no native. They were briefly hardcoded tonullin the plugin's chain table; that was reverted, becauseresolveTokenIdfinding no match already throws the sameSwapCurrencyErrorthe whitelist check throws, so the provider was declining them correctly all along. The real cost was that the miss was never cached, so each of those chains spent aGET /tokenscall on every quote against a rate-limited API.resolveTokenIdnow memoizes misses as well as hits, and deliberately does NOT cache a lookup the provider failed to answer, since a 429 says nothing about whether a chain is served.origin/developfor every send in the app. Previously carried as an open question; the reasoning and the rejected alternatives are in the design doc.maestro/14-stealth/, built from reusable subflows inmaestro/common/stealth-*.yamlso a later session can drive one specific state in a handful ofrunFlowsteps.maestro/14-stealth/README.mdcovers running it whole or one flow at a time, what each env var needs from the account, and the gotchas (the confirm slider is a pan gesture that ignores coordinate swipes; notification cards float over the slider). The two flows that move funds carry their own tag, so an ordinary suite run cannot spend.Followup testing (iOS sim,
edge-funds, live HoudiniSwap, deps linked via updot). Beyond the executed send above: the Myself picker's Same Asset grouping above Other Assets with the source wallet excluded; the transparent route on the same pair (~157.577 XLM at 2.27%, expiry >2h); the Stealth toggle invalidating a held quote and refetching, caught mid-flight with the slider back to disabled; the transaction titled "Stealth Swap & Send" with the Exchange Details card rendering and the recipient row showing the provider's deposit address rather than the Stellar payout; and inline parenthesised fiat on both new rows. The 25 USD private floor is now confirmed on three separate pairs. Screenshots attached.verify-repo.shPASSED on all three repos.Not verified on device. The four newly-declined chains: the send scene never offered them, so only a wallet-to-wallet Exchange swap could reach them, and no funded native wallet exists for any of the four; the decline path has a unit test instead. The
hasSelfPrivate: falsebranch and a real 429 remain as before. Android untested.Suite green, fiat correctness, and a review round (followup). Operator followup: work the previous round's follow-up list, and along the way two corrections.
Failed to connect, which reads as a suite-wide break). The eight non-spending flows passed in one clean sweep; both funded flows executed real orders unattended. Getting there fixed seven defects in the suite: a search-field focus race that silently dropped the typed filter, transaction and settings rows never satisfying the default 100% visibility, a transaction row's accessible label being the whole row joined (so a text match resolved to a container and tapped nothing), a PIN loop that paid for six passes after the gate cleared, a picker in the Exchange flow that had not been given the same wait as the shared subflows, a memo-chain default naming a display name the picker does not use, and the two funded flows both running the same direction so they could not be run back to back.$25.00. Converted through the same rates the scene already uses and formatted by the shared helper: verified on device by switching the account to EUR, which renders "Private routing needs at least €21.80", then switching back.celo,fantomandpolkadothave no entry inGET /tokens?mainnet=trueandtoncarries one token and no native. They were briefly hardcoded tonull; that was reverted, becauseresolveTokenIdfinding no match already throws the sameSwapCurrencyErrorthe whitelist check throws. The real cost was an uncached miss spending a lookup per quote against a rate-limited API. A lookup the provider FAILED to answer is deliberately not cached, since a rate limit says nothing about whether a chain is served.swapErrorShown. And switching source wallet cleared the destination chain but kept the entered payout address, dropping the scene into plain-send showing an address the new wallet cannot pay.0.0., which rejects real early account ids like0.0.98. Relaxed, with a regression test.Second review round on this PR. Cursor Bugbot reviewed each push and filed four more findings on the send scene's state handling; all four were real and are fixed with in-thread reasoning on each.
spendInfoand the wallet-or-asset block below then wrote a spread of the PRE-reset value over it, putting the old recipients back. It is one write now, so every recipient goes with the wallet rather than just the first.makeSpendcannot write over a quoting scene. Entering swap-send mode did not bumpmakeSpendCounter, so a plainmakeSpendalready in flight could land afterward and write plain-send state, including a cleared error, over a swap error the quote effect had just set. The success path also clears throughclearPlainSendError()rather thansetError(undefined).selfTransferkeep the old test.Amount-row title states (followup). Operator followup on the two linked amount rows: the state word sat on its own line under the amount, so each row read as three stacked lines and the number was not the last thing on it. The word now rides in the row's own title, parenthesised, green for the guaranteed side and warning orange for the estimated one, on both rows.
EdgeRowgained atitleStatenode it renders after the title, andEdgeTextgainedPositiveTextbeside the existingWarningText. Both colour components set colour only, so a span nested in the 0.75rem header keeps the header's size instead of jumping to the 1rem body size a nestedEdgeTextwould have forced. Judgement call: the first cut widenedEdgeRow.titleto a node so the scene could compose its whole header, and it was reverted before pushing. It put raw text in a fragment, whichreact-native/no-raw-textrejects, and it would have made every caller wanting a tinted word rebuild the shared header style. Alternate considered and rejected: render the header inside the row's children and skiptitleentirely, which duplicatesEdgeRow's own header style into the scene and drifts the moment that style changes.Two maestro changes came with it, both of which say something about the old titles. The shared amount-row subflow matched a row by its exact title, which the appended state word breaks, so it matches a prefix; and it now erases the flip input before typing, because the input opens pre-filled and
inputTextappends, which silently committed a concatenated amount on any re-edit. The payment-URI walk asserted a bareGuaranteedthat is no longer its own element and now asserts the whole title, which also binds the guarantee to the row that should hold it. The cross-asset walk gained assertions on both titles so a regression fails a flow rather than only changing a screenshot.The parentheses belong to the caller, not to the row.
EdgeRowfirst wrote them itself around whatever node it was handed, which put them outside the caller's colour component: the word came out green or orange while its own brackets kept the title's colour, soRecipient gets (Estimated)rendered in three colours where it should read as two. The row now contributes only the separating space and the scene passes(Guaranteed)and(Estimated)complete, which is also the only shape of the contract under which a caller can tint punctuation at all. Verified on device: both rows in one frame, brackets tinted with their word.Followup testing (iOS sim,
edge-funds, live HoudiniSwap, deps linked via updot). XLM to Sonic through the Myself picker, nothing sent. 158 XLM on the send side:You send (Guaranteed)green with a bare amount againstRecipient gets (Estimated)orange with the tilde, live quote 1 XLM = 7.67561925 S. Editing "Recipient gets" to 1200 S swapped both states and the reverse quote resolved at 1 XLM = 7.56043868 S with the slider armed. All four title strings are asserted in-flow, not just screenshotted. Screenshots attached.verify-repo.shPASSED (prepare, eslint on the changed set, full jest suite);tsc --noEmitclean.Round: the row that named the wrong party. A tester reported the recipient visible in a private send, with a screenshot of the transaction details scene. The report was half right, and the wrong half is the interesting one. The screenshot pointed at the "Recipient Addresses" row, which holds
spendTargets[0].publicAddress. On a send-shaped swap that is the provider's deposit address; the payee rides onsavedAction.payoutAddressalone and was already suppressed everywhere it renders. The reported transaction settles it on chain: the row's address received 38,693 sats, the gross send side the scene showed as "Exchange 0.00038693", while the payee was owed the 37,580 net shown as "To 0.0003758 BTC". That address has two transactions and forwarded the full amount, which is what a single-use deposit address looks like. So the data was right and the label was wrong, and a label reading "recipient" over an address the user never chose is not cosmetic on a flow whose whole promise is that no such address exists. The row is now titled from the action: aswapTypeon the saved action makes it "Exchange Deposit Address", and every other transaction keeps the original wording. Judgement call: scoped to send-shaped swaps rather than every swap. Ordinary Exchange-scene swaps carry the same mislabel ondevelop, and widening the fix there changes flows this branch does not own.The audit that went with it found a leak the report had not. A token send pays its fee in the chain's own coin, so
makeSwapPluginQuotefiles a second action undertokenId: nullbuilt from the plugin's own copy of the saved action, which carries noswapType. Every privacy rule keyed on one then reads false on that row, and its Exchange Details renders the payout address the token row hides.stampSwapSendActionnow stamps that row too, under the same condition the plugin writes it (hasParentFeeRow: a token id plus a parent network fee), so no parent-currency entry is invented for a mainnet send that has none. Stamping it makes the fee row a send-shaped swap to every consumer, and the title map is applied before the asset action is read, which would have retitled it "Private Send" and put a second private send in the list; the map now applies only when the asset action is not a network fee, while name suppression stays bound to the flow.Round testing (iOS sim
A80B413E,edge-funds, deps linked via updot).tsc --noEmitclean; full jest suite green at 605 tests across 95 suites, nine of them new acrossCategoriesActionsandstealthSwap. The fee-row title test fails on the pre-fix code and passes with it, which is the only reason to trust it. On device, an ordinary ChangeNOW swap still reads "Recipient Addresses", so ordinary swaps are unchanged. The positive case could not be driven organically: no transaction on this host carries a stampedswapType(every existing one predates the stamp), and a fresh Houdini quote is impossible here becauseenv.jsonhas noHOUDINI_INITcredentials in any checkout, socorePlugins.tsnever registers the plugin and a stealth quote dies in the core'spickBestErroron zero queried plugins. An attempted 0.7 LTC ($31.67) private send reached the quote and stopped there; no funds moved. The retitled row was therefore captured with a temporary hack relaxingisSwapSendto fire on any swap action, reverted before commit; that frame proves the row renders, not the trigger. Both frames are attached to the PR, the hack-forced one labelled.Note
High Risk
Large changes to send/swap execution, external payout addresses, privacy routing, and PIN spending limits; real funds move through a third-party provider and require coordinated core/plugin releases.
Overview
Adds Stealth Send and Stealth Swap: send and exchange flows can route through Houdini with optional private routing (
privacy: 'required'), client-side USD minimums, and Houdini-only quote options for send-to-address swaps.Send (
SendScene2) gains a pre-address Recipient receives picker, Stealth Send toggle, livefetchSwapQuoteswithtoAddressInfo, linked You send / Recipient gets rows (guaranteed vs estimated), quote UI (rate, expiry, fees, fiat), and cross-chain address handling viahoudiniChains/detectHoudiniChains(payment URIs, chain picker for ambiguous EVM addresses). Myself lists routable destinations (grouped same-asset vs other). Multi-recipient sends show Total Amount and block stealth/cross-asset. Completed flows stampswapTypeon saved actions for list/detail copy.Exchange adds a Stealth Swap toggle with the same Houdini restriction and privacy flag on re-quote.
Transactions use flow-specific titles, hide payout recipient on private flows, label spend target Exchange Deposit Address, and show exchange order details without exposing the real payout address.
Adds
src/util/houdiniChains.ts(chain table, validation fixes,HOUDINI_MIN_USD), shared swap error display, Maestro flows undermaestro/14-stealth/, testIDs for automation, and unit tests for chain detection and tx display rules.Depends on unpublished
edge-core-js/edge-exchange-pluginsAPIs (toAddressInfo,swapType, synthetic destination wallet).Reviewed by Cursor Bugbot for commit c0af608. Bugbot is set up for automated code reviews on this repo. Configure here.