Add Stealth Send and Stealth Swap (Houdini privacy routing) - #6066
Add Stealth Send and Stealth Swap (Houdini privacy routing)#6066j0ntz wants to merge 56 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). |
Adds unit tests for the two send-scene helpers that had none, and extends the chain-table tests past address detection into the lookup and the floor constants.
Wallet rows repeat their name inside a picker's search field, so a text selector matches the field rather than the row a UI walk means to tap.
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.
Adds the phase entry, the two decisions this round settled, the rewritten testing inventory, and the retrospective item on treating a missing test asset as a blocker rather than a task.
Both an unmapped chain and a chain whose token lookup finds nothing raise SwapCurrencyError, so the provider was declining correctly either way. The cost was an uncached miss, not a user-visible failure.
The minimums are stated in USD because that is the unit the provider enforces them in, but the message wrote the raw figure with a dollar sign, which is the wrong symbol and the wrong number for anyone not on USD. Converted through the same rates the scene already uses and formatted by the shared helper.
a12ec03 to
571da74
Compare
Switching to another wallet already cleared the destination chain, quote and toggle, but left the entered payout address, so the scene dropped back to plain-send still showing an address the new wallet cannot pay. Also relaxes the Hedera pattern: account ids are assigned sequentially, so early ones are short and the provider's four-digit floor rejects real destinations.
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.
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 b0a8e4f. Configure here.
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. |


























































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 in parentheses 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.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.Note
High Risk
Large send/swap and payment-path changes (Houdini-only routing, privacy flags, cross-chain address detection, PIN limits, and real fund-moving Maestro flows) touch critical money movement; merge also depends on unpublished edge-core-js and edge-exchange-plugins APIs.
Overview
Stealth Send extends plain Send to Address with a Recipient receives asset picker, linked You send / Recipient gets rows (flip-input amounts, fiat inline, quote expiry), a Stealth Send toggle, memo/destination-tag support, and live
fetchSwapQuoteswithtoAddressInfo. Cross-chain addresses and payment URIs are resolved via newpaymentUriparsing andhoudiniChainsmetadata (38 native chains, USD floor constants, regex fixes). Stealth Swap adds a matching toggle on Exchange; both flows route quotes only through Houdini viamakeStealthSwapRequestOptions, setprivacy: 'requiredwhen stealth is on, enforce client-side minimums, and surface real provider errors through shared swap-error display.UX and safety changes include PIN spending limits on swap-sends, route-capability handling (toggle re-quotes, private-floor messaging in user fiat), Myself picker grouped by routable assets, transaction titles via
swapTypewith recipient masking on private flows, exchange details without a payout wallet, and Maestro flows plus testIDs for automation. CHANGELOG documents the feature; eslint drops a few unused file exemptions; jest adds coverage for chains, URIs, and stealth swap options and updates UI snapshots.Reviewed by Cursor Bugbot for commit b0a8e4f. Bugbot is set up for automated code reviews on this repo. Configure here.