Skip to content

Add core-side synthetic destination for swap-to-address - #730

Draft
j0ntz wants to merge 3 commits into
masterfrom
jon/stealth-send-swap
Draft

Add core-side synthetic destination for swap-to-address#730
j0ntz wants to merge 3 commits into
masterfrom
jon/stealth-send-swap

Conversation

@j0ntz

@j0ntz j0ntz commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Technical Design Document

stealth-send-swap.md

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

none

Description

Asana task

The core half of swap-to-address, which is what lets a swap target a pasted destination instead of a wallet the user holds. EdgeSwapRequest accepts an optional toAddressInfo descriptor as an alternative to toWallet, exactly one of the two required; the core builds a synthetic, bridgified destination wallet from it, backed by the real currencyConfig, so swap plugins receive an EdgeCurrencyWallet unchanged. Nothing Houdini-specific lives here: the same mechanism powers provider-agnostic send-to-any-asset, verified in-app with a ChangeNOW-routed cross-chain send.

Judgement calls, with their alternates:

  • Slim descriptor. EdgeSwapToAddressInfo carries only toPluginId, toAddress, and optional toMemos. toPluginId selects the currencyConfig and is not derivable from the request; toAddress is the payload; the destination token comes from the request's own toTokenId. A descriptor also carrying toTokenId was rejected as a two-sources-of-truth hazard.
  • Destination memos ride getMemos on the synthetic wallet. For memo-required payout chains such as an XRP destination tag, the descriptor's toMemos is only the GUI-to-core transport; plugins never read the descriptor. The synthetic wallet exposes getMemos() (EdgeSyntheticDestinationWallet), so the plugin-facing mechanism stays wallet-shaped and plugins keep one code path. Plugins reading a tag field off the descriptor was rejected to keep the plugin interface generic. A no-descriptor design is not available: the value has to cross the GUI-core bridge somewhere, and the descriptor is the plain-data channel that crosses yaob cleanly.
  • EdgeTxActionSwap.payoutWalletId and EdgeTxSwap.payoutWalletId are both optional. A swap-to-address destination has no payout wallet; payoutAddress carries the destination. Making only the action type optional left a latent inconsistency in EdgeTxSwap. Keeping them required and writing the synthetic://<pluginId> id was rejected: it embeds fake wallet ids in persistent transaction metadata.
  • EdgeTxActionSwap.swapType (swapSend, stealthSend, stealthSwapSend) names the send-shaped flows so a UI can title a transaction by the flow the user ran instead of inferring it. These stay swaps to every existing consumer, which a separate actionType would not; absent for a normal wallet-to-wallet swap.
  • EdgeSwapRequest.privacy. 'required' restricts a quote to routes that keep the sender unlinkable from the recipient, and a plugin that cannot offer one must decline rather than answer with a transparent route. A quote carries no route type back to the caller, so a silent downgrade would be undetectable; making the caller state its requirement puts the decision where the intent lives.
  • EdgeSwapRequestOptions.forceEnabled lets a caller query named plugins the user switched off in their swap settings, because a private send is a send feature that happens to be powered by a swap provider. An explicit disabled entry still wins, so a caller that disables everything except one provider cannot have that restriction defeated.
  • Log privacy. Both swap-quote logging paths redact the pasted destination address and the memo values, since a deposit tag can identify the recipient's exchange account.
  • The synthetic wallet is released by reference count. It is bridgified, so yaob holds it in the account's object table until something closes it, and the caller reaches it through quote.request.toWallet. One wallet is built per fetchSwapQuotes call and shared by every quote that call returns, so it closes when the last of them closes, and immediately when none survives or all reject. Without that, every quote refresh on a swap-to-address screen left another wallet in the table for the life of the account. Closing it with the first quote was rejected: the other quotes from the same call still carry it.

The plugin-selection rule that forceEnabled and disabled encode is a named predicate (isSwapPluginQueryable) rather than an inline condition inside fetchSwapQuotes, with its full truth table under test. The corner that matters is exactly what a stealth send does in one call: every other plugin disabled, this one force-enabled.

Testing. 177 mocha tests pass, tsc and eslint clean, verify-repo.sh PASSED. 12 of those tests are new: the yaob bridge-crossing proof covers descriptor-only construction, plugin-faithful reads, memo round-trip through getMemos, and the synthetic wallet surviving the wire format back to the GUI; the plugin-selection truth table covers all eight flag combinations; two more cover the synthetic wallet's release, including a double-close that must not free it twice. In-app, linked into edge-react-gui with the Stealth Send UI: a real cross-chain send-to-address executed on the iOS simulator through this mechanism, deposit broadcast on chain, swap success scene reached.


Note

Medium Risk
Changes the public swap request shape and payout metadata (optional payoutWalletId), and introduces synthetic wallets on the quote path; swap plugins are unchanged but GUIs must handle new fields and redacted logging expectations.

Overview
Adds swap-to-address: EdgeSwapRequest can use toAddressInfo (toPluginId, pasted toAddress, optional toMemos) instead of toWallet, with exactly one required. fetchSwapQuotes resolves this via resolveSwapRequest, building a bridgified synthetic destination wallet (makeSyntheticDestinationWallet) backed by the account’s real currencyConfig so swap plugins still see a normal toWallet (including optional getMemos on EdgeSyntheticDestinationWallet).

Quote aggregation gains EdgeSwapRequestOptions.forceEnabled and isSwapPluginQueryable (disabled beats forceEnabled). Swap quote logging redacts pasted addresses and memo values. EdgeSwapRequest.privacy: 'required' is typed for callers that must not get a silent transparent route.

Transaction metadata types add optional swapType on EdgeTxActionSwap (swapSend, stealthSend, stealthSwapSend) and make payoutWalletId optional on EdgeTxActionSwap and EdgeTxSwap when the payout is address-only. On-disk cleaners and SwapCurrencyError are updated for optional toWallet / toAddressInfo.

Reviewed by Cursor Bugbot for commit c914c85. Bugbot is set up for automated code reviews on this repo. Configure here.

@j0ntz

j0ntz commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (in-app swap-to-address via synthetic destination)

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

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

Comment thread src/core/swap/swap-api.ts
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from edc8fbd to c12ad45 Compare July 3, 2026 03:13
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch 2 times, most recently from 2814d2c to ca9ed1d Compare July 30, 2026 07:56
@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

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

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

@j0ntz

j0ntz commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit ca9ed1d. Configure here.

@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch 2 times, most recently from 01110fe to c914c85 Compare August 17, 2026 21:18
j0ntz added 3 commits August 25, 2026 15:22
- EdgeSwapRequest accepts an optional toAddressInfo descriptor
  (toPluginId, toAddress, toMemos) as an alternative to toWallet, with
  exactly one of the two required. The core builds a synthetic,
  bridgified destination wallet backed by the real currencyConfig, so
  swap plugins receive an EdgeCurrencyWallet unchanged.
- Destination memos (e.g. an XRP destination tag) use the descriptor
  only as GUI-to-core transport; plugins read them off the synthetic
  wallet's getMemos method (EdgeSyntheticDestinationWallet).
- EdgeTxActionSwap.payoutWalletId and EdgeTxSwap.payoutWalletId become
  optional, since a swap-to-address destination has no payout wallet.
- The pasted destination address and memo values are redacted from
  swap-quote logs.
A swap-to-address send, a private same-asset send, and a private
cross-asset send all settle through a swap provider and carry every other
field of EdgeTxActionSwap, so they stay swaps to existing consumers. What
differs is the flow the user ran, which a UI needs in order to title the
transaction. The optional swapType field names it, and the saved-action
cleaner carries it so it survives a round trip.
A privacy feature powered by one provider needs two things the swap API
could not express: a quote that must be sender-unlinkable rather than
merely routed through the provider, and a provider the user's swap
settings cannot switch off for that one feature.
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from c914c85 to e2bef4e Compare August 25, 2026 22:24
@j0ntz
j0ntz marked this pull request as draft August 27, 2026 23:00
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