Skip to content

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

Open
j0ntz wants to merge 56 commits into
developfrom
jon/stealth-send-swap
Open

Add Stealth Send and Stealth Swap (Houdini privacy routing)#6066
j0ntz wants to merge 56 commits into
developfrom
jon/stealth-send-swap

Conversation

@j0ntz

@j0ntz j0ntz commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

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, and pr-checks.yml runs only the WIP blocker), so the branch reviews cleanly as-is; it will not compile against published edge-core-js 2.47.1, which lacks toAddressInfo and EdgeTxActionSwap.swapType. Locally it is verified against the linked dep branches (tsc clean, eslint clean, full jest suite green).

Requirements

If you have made any visual changes to the GUI. Make sure you have:

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

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 history reconstructs 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 SendScene2 in 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. The TransactionListTop reroute and HoudiniSendScene re-skin from the prototype are gone.

  • Stealth ON or a cross-asset recipient turns the send into a live account.fetchSwapQuotes({ toAddressInfo }) quote; plain same-asset sends keep the existing UI and behavior (regression-checked, including multi-recipient UTXO sends).
  • Linked "You send"/"Recipient gets" rows: the edited side is the guaranteed amount, the other tracks the live quote as an estimate. Each row says which it is in its own title, parenthesised and tinted: You send (Guaranteed) in green, Recipient gets (Estimated) in warning orange, with the ~ prefix kept on the estimated amount. Editing "Recipient gets" issues quoteFor: '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.
  • Inline quote row: real rate, winning provider label, and the price delta REUSED from the swap confirmation scene via the new shared PriceImpactText (the prototype's recreated delta UI is deleted); real expiry countdown that re-quotes on expiry; real network fee from the quote.
  • EVERY send-to-address quote is restricted to Houdini via the shared 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.)
  • Destination tag row appears only when the destination chain is memoNeeded per the Houdini chain metadata; the entered tag rides toAddressInfo.toMemos to the provider (which forwards it as destinationTag on order creation).
  • Address entry reuses AddressTile2 + the existing AddressModal family. Cross-chain destination addresses cannot go through the source wallet's parseUri, so AddressTile2 gains a crossChainAddressValidation override that validates against the destination chain's own regex (Houdini's per-chain addressValidation). 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 (new src/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.
  • Chain metadata (src/util/houdiniChains.ts) is a generated snapshot of Houdini GET /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.
  • Success: same-asset non-stealth keeps the Transaction Success modal + details; cross-asset or stealth lands on SwapSuccessScene.
  • Slide label reads "Slide to send stealthily" when stealth is on; the SafeSlider always submits (no separate confirmation scene).

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' getMaxSwappable is 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), and SwapDetailsCard DOES 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 are stealth_*). "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/payoutWalletId across swap scenes, SwapProviderRow, ExchangeQuoteComponent, CategoriesActions, TransactionDetailsScene, and SwapDetailsCard.

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 under maestro/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 an ethereum:0x...?amount=0.007 URI (live HoudiniSwap quote, 1 LTC = 0.0241865 ETH, executed, tx 176c833c6f0ef09ea9c2ba5eb6a39e079a13262aadd362d87d195350114a54dc), and the same flow with Stealth Send ON (executed, tx 53f03d92da63c9b20dc29a0c296b4043cefb71e334066bf78529d6cec2b11cb6). 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 through AddressTile2.changeAddress at once: Paste, Enter address, and Scan. Deep links are deliberately unaffected: they pre-fill an address, so swapSendAllowed is 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 writes A-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:

Case Source Input Result
Executed My Ether 4 (ETH) pasted ltc1q65h… Litecoin adopted, live quote 1 ETH = 39.59988278 LTC, 0.25 LTC guaranteed, broadcast 0xa87fd77e1a64310d565e462cbc91e5f3e0e748ff1bbb62857455aef37e4044e7 (0.00631315 ETH, $11.93)
Enter address My Bitcoin 3 (BTC) typed 0xF0825… picker → Ethereum (the reported case)
Enter address My Bitcoin 3 (BTC) typed 0xF0825… picker → Polygon
Enter address My Ether 4 (ETH) typed FeT6HHp8… Solana, no picker
Scan My Ether 4 (ETH) bitcoin:bc1q…?amount=0.0004 Bitcoin, scheme resolves without a picker
Scan My Bitcoin 3 (BTC) ethereum:0xF0825…?amount=0.004 Ethereum, amount on the receive side
Paste My Litecoin 2 (LTC) bc1q7f5w… Bitcoin, no picker
Enter address My Algorand (USDC) typed 0xF0825… picker → Ethereum, token source
Enter address My Tron 2 (USDT) typed 0xF0825… picker → Ethereum, token source
Deep link simctl openurl ethereum:0xF0825…?amount=0.004 own Ethereum wallets offered, unchanged behavior

Chains exercised: BTC, LTC, ETH, POL, SOL, plus USDC and USDT token sources. verify-repo.sh passes (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 SwapCurrencyError now 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 optional onError first-chance hook on swapProcessing). 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/routeCaps mechanics 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_CHAINS via getHoudiniChain) rather than a hardcoded asset shape. Same-asset wallets pin to the top through a new opt-in grouping prop on WalletListModal (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 existing routeCaps affordance.

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: EdgeTxActionSwap gains an optional swapType (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, while swapData keeps orderId and payoutAddress so 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. handleSliderComplete returned inside its swapSendActive branch before reaching the checkPin block, the makeSpend effect returned before setSpendingLimitExceeded ever ran so the flag sat at its initial false, and disableSlider set 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 in SwapProcessingScene so 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 same onError path SwapCreateScene uses, with a new disableStealth param 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: tsc clean, 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.

ErrorCard renders anything that is not an I18nError as "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 of SwapProcessingScene into src/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 a toCurrencyCode option 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 pinSpendingLimitsEnabled and 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.

  • Privacy is now asked for, not inferred. 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.
  • The toggle re-quotes on every flip. It was deliberately excluded from the quote effect's dependencies on the grounds that the request did not vary with it. That was true then and is not now, so a cross-asset pair would have shown standard-route pricing under a Stealth label. Paul's suspicion was correct.
  • Minimums validated, then enforced client-side. All three floors were confirmed against the live v2 API before being written down (cross-asset 422 at 8 USD naming the 10 USD minimum, standard-only routes 12 through 24, private from 25; same-asset 422 naming 25 below it, private-only above). They live as HOUDINI_MIN_USD constants 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.
  • Same-asset private capability comes from hasSelfPrivate on 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.
  • Rate limits handled per their doc. Every call retries a 429 behind the reported retryAfter, doubling on top of it, and fails after three attempts as a rate limit rather than as an unavailable route. It throws a plain Error, so the SwapCurrencyError path never fires and no routeCaps verdict 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); no x-ratelimit-* headers come back, so the exact tier cannot be read off a response.
  • Order details render on a stealth transaction. The card resolved its payout denomination through the payout wallet and returned null without one, and a swap-to-address payoutWalletId is 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.
  • The send path ignores the exchange-settings provider toggle (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.
  • Fiat values on the Houdini amount rows, inline in parentheses through the shared FiatText component, 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.
  • Audit findings beyond the asks. Ten of 38 advertised destinations were dead. Four chains (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 for null.

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:

  • A standard route arms at $14.99 (46 TRX), rate 1 TRX = 0.00712379 LTC at 0.91%, slider live. Before this round the forward filter was private-only, so this exact pair and amount produced an error card. Both amount rows carry fiat ($14.99 / $14.85).
  • The Stealth toggle refuses at $14.99 with "Private routing needs at least $25. Enter a larger amount to send privately." as both a toast and a persistent info line, and no request is sent.
  • Flipping the toggle at $26.07 invalidates the held quote and refetches, captured mid-flight with the slider back to disabled.
  • The private route arms with a measurably different price: 0.00705375 LTC at 1.88% against the standard route's 0.91%, and a fresh 28m expiry against the standard quote's >2h. Same pair, same amount, different route, which is the privacy signal proven end to end rather than by label.
  • Executed to the success scene: 80 TRX ($26.42), order sqc1ci6sNL8ygoGLFHyFK4, deposit txid e57e12b09e090de7367bbc742e306e43933dc1ba59b50cd80ea198f56e0dcddd.
  • Its details scene titles it "Stealth Swap & Send" with no recipient, shows the Exchange Details card (order id, HoudiniSwap, 80 TRX to 0.5643004 LTC, Fixed Quote), reads "Payout Address: Hidden for privacy", and lists Houdini's TRON deposit address as the recipient address rather than the real LTC destination.

Screenshots attached. Static: tsc clean on all three repos, verify-repo.sh PASSED on all three (gui jest 92 suites / 556 tests / 107 snapshots).

Not verified on device at the time. The hasSelfPrivate: false branch (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.

  • HIGH, stealth swap skipped the privacy flag. The Exchange scene's Stealth Swap restricted the provider but never set 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.
  • HIGH, a held quote survived a wallet switch. An order is created against the source wallet's refund address, so switching to another wallet on the SAME asset left the previous wallet's order armed and approvable. The picker now clears swap state on any wallet change, and the quote effect depends on the wallet id.
  • MEDIUM, the floor guard raced an in-flight quote. The generation counter only advanced on paths that fetched, so a request issued at a fundable amount could land after the amount dropped under the floor and re-arm the slider. Every run of the effect now retires its predecessor.
  • MEDIUM, the swap error outlived swap-send mode. A minimum-amount or unroutable-pair message stayed on screen over the plain send the user switched to. Swap errors are now tracked separately so leaving the mode retracts them without clearing an error the plain-send effect owns.
  • MEDIUM, token sends were mislabelled. A quote always asks for 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.
  • MEDIUM, the fixed-to fallback read stale rates. It seeds from exchange rates inside the quote effect, which cannot depend on those rates without re-quoting on every tick and burning the provider's rate limit. It now reads them through a ref, and a single retry fires when a missing rate becomes usable.

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.

  • The six empty-string chains are driven. Sonic held less than the 25 USD private floor, so it was funded by swapping into it (0.19962 SOL, $14.66, standard route, 662.71 S out). A private Stealth Send of 1300 S ($27.66) from Sonic to Stellar then executed to the success scene, order 7erEQCvTU8VyySASH4DqLo, deposit txid 0x19e770c2b7bca225caae2f3a55835c42626104bf59669b6bceddd3697db6f537; it settled, with Stellar going 30.484 to 187.921 XLM. Both ends of that pair are chains the provider reports with address: "", so neither could resolve a token id before this branch and the pair could not quote at all.
  • Token lookups now remember a miss. celo, fantom and polkadot have no entry in GET /tokens?mainnet=true, and the ton chain carries one token and no native. They were briefly hardcoded to null in the plugin's chain table; that was reverted, because resolveTokenId finding no match already throws the same SwapCurrencyError the 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 a GET /tokens call on every quote against a rate-limited API. resolveTokenId now 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.
  • The PIN spending limit does not count fees, matching origin/develop for every send in the app. Previously carried as an open question; the reasoning and the rejected alternatives are in the design doc.
  • Unit coverage from 26 to 88 across the three repos. New: the stealth swap options, the shared swap error mapping, the chain-table lookup and floor constants, the core's plugin-selection truth table, and eleven offline plugin behaviors (route filter, both spellings of a native coin's missing contract address, dex never taken, same-asset allowed, an unserved chain declined, the fixed-versus-floating label, and the rate-limit backoff retried then exhausted). The plugin's new tests run on scripted local responses rather than recorded fixtures, because a fixture cannot express a sequence of statuses or a route mix the live API will not produce on demand.
  • A composable maestro suite. A flow per user-visible branch in maestro/14-stealth/, built from reusable subflows in maestro/common/stealth-*.yaml so a later session can drive one specific state in a handful of runFlow steps. maestro/14-stealth/README.md covers 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.
  • Three testIDs added for that suite: the wallet picker's search field, the scene search footer, and wallet rows keyed by name. Rows repeat their name inside a picker's search field and a placeholder vanishes once the field holds a query, so text selectors matched the wrong node.

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.sh PASSED 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: false branch 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.

  • All ten maestro flows pass end to end, verified one at a time with a driver kill between (a failing flow takes the maestro driver down and every later flow then reports 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.
  • The provider floor now reads in the user's own fiat. The minimums are USD because that is the unit the provider enforces, but the message wrote the raw figure with a dollar sign, so a EUR user saw $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.
  • Token lookups now remember a miss. celo, fantom and polkadot have no entry in GET /tokens?mainnet=true and ton carries one token and no native. They were briefly hardcoded to null; that was reverted, because resolveTokenId finding no match already throws the same SwapCurrencyError the 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.
  • Four review findings fixed. An expired swap quote stayed approvable, because the expiry handler bumped the re-quote nonce without dropping the quote the slider gates on. An expired payment request's error could be cleared by entering swap-send, leaving an expired request behind a live quote; expiry is now a property of the request and disables the slider in either mode. Clearing the address reset the error state directly instead of through the owners, stranding 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.
  • Hedera addresses: the pattern demanded four digits after 0.0., which rejects real early account ids like 0.0.98. Relaxed, with a regression test.
  • One finding was rejected with reasoning (edge-exchange-plugins#469): typed limit errors are already thrown wherever a native limit is knowable, and the 422 path carries only a USD figure, so classifying it would replace the provider's specific minimum with a generic string.

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.

  • A dead quote can no longer be approved. 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. The toggle drops it now. On the swap confirmation scene, expiry only disabled the slider by navigating away, which the terms check defers, so expiry is recorded in state and gates the slider directly.
  • A wallet switch resets the whole spend. An asset change reset spendInfo and 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.
  • A superseded makeSpend cannot write over a quoting scene. Entering swap-send mode did not bump makeSpendCounter, so a plain makeSpend already 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 through clearPlainSendError() rather than setError(undefined).
  • The expiry message stays on screen. It was written into the shared error state without an owner, so entering swap-send retracted it while the expiry flag stayed set: a disabled slider with nothing explaining it. Expiry belongs to the request, so the plain-send clear refuses it. A wallet or asset switch retires whatever error is showing, alongside the toggle and route capabilities it already retires.
  • Myself appears whenever a routable wallet exists. The control gated on another wallet of the same type, which is the right question for a plain send but hid the cross-chain picker from exactly the account it exists for: one wallet on the source chain and the rest elsewhere. Callers without selfTransfer keep 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.

EdgeRow gained a titleState node it renders in parentheses after the title, and EdgeText gained PositiveText beside the existing WarningText. 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 nested EdgeText would have forced. Judgement call: the first cut widened EdgeRow.title to a node so the scene could compose its whole header, and it was reverted before pushing. It put raw text in a fragment, which react-native/no-raw-text rejects, 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 skip title entirely, which duplicates EdgeRow'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 inputText appends, which silently committed a concatenated amount on any re-edit. The payment-URI walk asserted a bare Guaranteed that 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 against Recipient 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.sh PASSED (prepare, eslint on the changed set, full jest suite); tsc --noEmit clean.


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 fetchSwapQuotes with toAddressInfo. Cross-chain addresses and payment URIs are resolved via new paymentUri parsing and houdiniChains metadata (38 native chains, USD floor constants, regex fixes). Stealth Swap adds a matching toggle on Exchange; both flows route quotes only through Houdini via makeStealthSwapRequestOptions, set privacy: 'required when 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 swapType with 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.

j0ntz added 4 commits July 2, 2026 17:25
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.
@j0ntz

j0ntz commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (iOS sim, live wiring)

agent proof 1216251688512498 01 stealth send toggle

agent proof 1216251688512498 01 stealth send toggle

agent proof 1216251688512498 02 recipient chain picker

agent proof 1216251688512498 02 recipient chain picker

agent proof 1216251688512498 03 send to any quote

agent proof 1216251688512498 03 send to any quote

agent proof 1216251688512498 04 send to any success

agent proof 1216251688512498 04 send to any success

agent proof 1216251688512498 05 stealth swap toggle

agent proof 1216251688512498 05 stealth swap toggle

agent proof 1216251688512498 06 stealth swap houdini only

agent proof 1216251688512498 06 stealth swap houdini only

agent proof 1216251688512498 07 learn more browser

agent proof 1216251688512498 07 learn more browser

agent proof 1216251688512498 08 xrp destination tag

agent proof 1216251688512498 08 xrp destination tag

agent proof 1216251688512498 09 plain send regression

agent proof 1216251688512498 09 plain send regression

agent proof 1216251688512498 10 multi recipient gating

agent proof 1216251688512498 10 multi recipient gating

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from a5c408d to 9460a75 Compare July 8, 2026 23:05
@j0ntz

j0ntz commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

📸 Followup test evidence: send-to-any restricted to Houdini + 1rem asset row

agent proof 1216251688512498 01 send to any houdini 1rem row

agent proof 1216251688512498 01 send to any houdini 1rem row

agent proof 1216251688512498 02 send to any configured

agent proof 1216251688512498 02 send to any configured

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from 9460a75 to 31be7d8 Compare July 14, 2026 21:22
@j0ntz

j0ntz commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (followup: vertical spacing fix)

agent proof 1216251688512498 03 vertical gap matched

agent proof 1216251688512498 03 vertical gap matched

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch 2 times, most recently from 8275d96 to 456e38b Compare July 24, 2026 23:46
@j0ntz

j0ntz commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (followup: plain-send regression with QR amount)

qr plain send

qr plain send

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from 456e38b to 9d29ac7 Compare July 26, 2026 08:10
@j0ntz

j0ntz commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (followup: scanned payment QR, executed end to end)

qr crosschain live quote

qr crosschain live quote

qr crosschain executed

qr crosschain executed

qr crosschain txdetails

qr crosschain txdetails

qr stealth live quote

qr stealth live quote

qr stealth executed

qr stealth executed

qr stealth txdetails

qr stealth txdetails

Captured by the agent's in-app test run (build-and-test).

@j0ntz

j0ntz commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (followup: cross-chain address entry across chains, tokens, and entry paths)

paste detected litecoin

paste detected litecoin

paste live quote armed

paste live quote armed

paste swap executed

paste swap executed

paste swap txdetails

paste swap txdetails

type btc to eth picker

type btc to eth picker

network picker

network picker

deeplink eth uri

deeplink eth uri

token source usdc

token source usdc

token source usdt

token source usdt

Captured by the agent's in-app test run (build-and-test).

@j0ntz

j0ntz commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (followup: route availability reflected in the UI)

stealth armed

stealth armed

stealth autodisable toast

stealth autodisable toast

standard quote after fallback

standard quote after fallback

preemptive tap toast

preemptive tap toast

fallback swap executed

fallback swap executed

fallback swap txdetails

fallback swap txdetails

fixedto fallback card

fixedto fallback card

fixedto preemptive tap toast

fixedto preemptive tap toast

card cleared after edit

card cleared after edit

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from f89cc68 to 86089c7 Compare July 28, 2026 07:43
@j0ntz

j0ntz commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (followup: Houdini exclusivity restored + flip-input amounts)

crossasset stealth on errorcard

crossasset stealth on errorcard

yousend flipinput modal

yousend flipinput modal

stealth off houdini only errorcard

stealth off houdini only errorcard

raw error no route

raw error no route

recipientgets flipinput modal

recipientgets flipinput modal

stealth on stays on errorcard

stealth on stays on errorcard

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from 86089c7 to a00bf68 Compare July 28, 2026 21:16
@j0ntz

j0ntz commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (followup: routable Myself picker + transaction identity)

myself grouped list

myself grouped list

myself crossasset adopted

myself crossasset adopted

stealth send quote

stealth send quote

stealth send executed

stealth send executed

tx list title

tx list title

stealth details from list

stealth details from list

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz marked this pull request as ready for review July 29, 2026 00:26
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread src/actions/CategoriesActions.ts
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/components/scenes/SendScene2.tsx Outdated
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch 2 times, most recently from 0a5b386 to bf3e1d9 Compare July 29, 2026 00:43
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/components/scenes/SwapConfirmationScene.tsx
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/components/scenes/SendScene2.tsx
Comment thread src/components/scenes/SwapCreateScene.tsx
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from 1eff6c6 to 9737aa6 Compare July 29, 2026 01:21
@j0ntz

j0ntz commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

📸🪓 Test evidence

🪓 Hack-forced evidence: pinSpendingLimitsEnabled forced true and the limit forced to $1 in SendScene2, because enabling the real setting needs the account password (settings lock) which the roster does not provide. Reverted; git status clean. Temporary uncommitted edit, reverted before commit; the marked frames prove the rendering, not the trigger.

error card provider floor

error card provider floor

error card no route

error card no route

myself picker grouped

myself picker grouped

🪓 HACK-FORCED: pin row on swap send

🪓 HACK-FORCED: pin row on swap send

Captured by the agent's in-app test run (build-and-test).

j0ntz added 6 commits July 30, 2026 15:42
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.
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from a12ec03 to 571da74 Compare July 30, 2026 22:43
Comment thread src/util/houdiniChains.ts Outdated
Comment thread src/components/scenes/SendScene2.tsx
j0ntz added 2 commits July 30, 2026 15:50
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.
Comment thread src/components/scenes/SwapConfirmationScene.tsx
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.
Comment thread src/components/scenes/SendScene2.tsx Outdated
Comment thread src/components/scenes/SendScene2.tsx
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.
Comment thread src/components/tiles/AddressTile2.tsx
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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b0a8e4f. Configure here.

Comment thread src/components/scenes/SendScene2.tsx
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.
@j0ntz

j0ntz commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor

cursor Bot commented Jul 31, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

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.
@j0ntz
j0ntz marked this pull request as draft August 1, 2026 05:05
j0ntz added 5 commits July 31, 2026 22:20
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.
@j0ntz

j0ntz commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence: amount-row title states

tiles send guaranteed

tiles send guaranteed

tiles receive guaranteed

tiles receive guaranteed

tiles receive guaranteed quoted

tiles receive guaranteed quoted

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz marked this pull request as ready for review August 1, 2026 06:06
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant