From 88e2504e904197b40da8382722f59655ea9b1bd0 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:15:44 -0700 Subject: [PATCH 1/3] Add core-side synthetic destination for swap-to-address - 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. --- .../wallet/currency-wallet-cleaners.ts | 4 +- src/core/swap/swap-api.ts | 161 +++++++++++++-- src/core/swap/synthetic-wallet.ts | 76 ++++++++ src/types/error.ts | 8 +- src/types/types.ts | 67 ++++++- test/core/swap-quote-close.test.ts | 63 ++++++ test/core/synthetic-wallet.test.ts | 183 ++++++++++++++++++ 7 files changed, 541 insertions(+), 21 deletions(-) create mode 100644 src/core/swap/synthetic-wallet.ts create mode 100644 test/core/swap-quote-close.test.ts create mode 100644 test/core/synthetic-wallet.test.ts diff --git a/src/core/currency/wallet/currency-wallet-cleaners.ts b/src/core/currency/wallet/currency-wallet-cleaners.ts index 4175d4e96..ca771ba71 100644 --- a/src/core/currency/wallet/currency-wallet-cleaners.ts +++ b/src/core/currency/wallet/currency-wallet-cleaners.ts @@ -146,7 +146,7 @@ export const asEdgeTxSwap = asObject({ payoutCurrencyCode: asString, payoutTokenId: asOptional(asEdgeTokenId), payoutNativeAmount: asString, - payoutWalletId: asString, + payoutWalletId: asOptional(asString), refundAddress: asOptional(asString) }) @@ -190,7 +190,7 @@ export const asEdgeTxActionSwap = asObject({ canBePartial: asOptional(asBoolean), fromAsset: asEdgeAssetAmount, toAsset: asEdgeAssetAmount, - payoutWalletId: asString, + payoutWalletId: asOptional(asString), payoutAddress: asString, refundAddress: asOptional(asString) }) diff --git a/src/core/swap/swap-api.ts b/src/core/swap/swap-api.ts index 87ccd20c3..f8adda43c 100644 --- a/src/core/swap/swap-api.ts +++ b/src/core/swap/swap-api.ts @@ -14,10 +14,12 @@ import { EdgeSwapPlugin, EdgeSwapQuote, EdgeSwapRequest, - EdgeSwapRequestOptions + EdgeSwapRequestOptions, + EdgeSwapToAddressInfo } from '../../types/types' import { fuzzyTimeout, timeout } from '../../util/promise' import { ApiInput } from '../root-pixie' +import { makeSyntheticDestinationWallet } from './synthetic-wallet' /** * Fetch quotes from all plugins, and sorts the best ones to the front. @@ -41,12 +43,42 @@ export async function fetchSwapQuotes( const { swapSettings, userSettings } = account const swapPlugins = state.plugins.swap + // Resolve the destination. A normal swap provides `toWallet`; a + // swap-to-address (private send) request provides `toAddressInfo` instead, and + // the core builds a synthetic destination wallet from it so plugins receive an + // `EdgeCurrencyWallet` unchanged. + const swapRequest = resolveSwapRequest(ai, accountId, request) + + // A swap-to-address request builds ONE synthetic destination wallet, shared + // by every quote this call produces. It is bridgified, so yaob keeps it in + // the account's object table until something closes it, and the caller + // reaches it through `quote.request.toWallet`. Release it once the LAST + // quote carrying it has been closed, and right away when no quote survives + // to carry it: without that, every quote refresh on a swap-to-address screen + // leaves another wallet in the table for the life of the account. (The same + // reasoning is why `resolveSwapRequest` reuses the account's long-lived + // `currencyConfig` rather than building one per request.) + const syntheticToWallet = + request.toAddressInfo == null ? undefined : swapRequest.toWallet + let openQuoteCount = 0 + const releaseSyntheticToWallet = (): void => { + if (syntheticToWallet == null) return + if (--openQuoteCount > 0) return + close(syntheticToWallet) + } + log.warn( 'Requesting swap quotes for: ', { - ...request, - fromWallet: request.fromWallet.id, - toWallet: request.toWallet.id + ...swapRequest, + fromWallet: swapRequest.fromWallet.id, + toWallet: swapRequest.toWallet?.id, + // Never log the pasted destination address or its memos + // (private-send privacy): + toAddressInfo: + swapRequest.toAddressInfo == null + ? undefined + : redactToAddressInfo(swapRequest.toAddressInfo) }, { preferPluginId, promoCodes } ) @@ -63,14 +95,24 @@ export async function fetchSwapQuotes( pendingIds.add(pluginId) promises.push( swapPlugins[pluginId] - .fetchSwapQuote(request, userSettings[pluginId], { + .fetchSwapQuote(swapRequest, userSettings[pluginId], { infoPayload: state.infoCache.corePlugins?.[pluginId] ?? {}, promoCode: promoCodes[pluginId] }) .then( quote => { upgradeSwapQuote(quote) - const { fromWallet, toWallet, ...request } = quote.request ?? {} + const { fromWallet, toWallet, toAddressInfo, ...rest } = + quote.request ?? {} + // Never log the pasted destination address or its memos + // (private-send privacy): + const request = + toAddressInfo == null + ? rest + : { + ...rest, + toAddressInfo: redactToAddressInfo(toAddressInfo) + } const cleaned = { ...quote, request } pendingIds.delete(pluginId) log.warn(`${pluginId} gave swap quote:`, cleaned) @@ -86,12 +128,12 @@ export async function fetchSwapQuotes( swapPluginId: pluginId, request: { // Stringify to include "null" - fromToken: String(request.fromTokenId), - fromWalletType: request.fromWallet.type, + fromToken: String(swapRequest.fromTokenId), + fromWalletType: swapRequest.fromWallet.type, // Stringify to include "null" - toToken: String(request.toTokenId), - toWalletType: request.toWallet.type, - quoteFor: request.quoteFor + toToken: String(swapRequest.toTokenId), + toWalletType: swapRequest.toWallet?.type, + quoteFor: swapRequest.quoteFor } }) } @@ -117,10 +159,17 @@ export async function fetchSwapQuotes( ) // Prepare quotes for the bridge: - return quotes.map(quote => wrapQuote(swapPlugins, request, quote)) + openQuoteCount = quotes.length + if (syntheticToWallet != null && quotes.length === 0) { + close(syntheticToWallet) + } + return quotes.map(quote => + wrapQuote(swapPlugins, swapRequest, quote, releaseSyntheticToWallet) + ) }, (errors: unknown[]) => { log.warn(`All ${promises.length} swap quotes rejected.`) + if (syntheticToWallet != null) close(syntheticToWallet) throw pickBestError(errors) } ) @@ -129,11 +178,91 @@ export async function fetchSwapQuotes( return await timeout(promise, noResponseMs) } -function wrapQuote( +/** + * Strips the private pieces (destination address and memos) out of a + * `toAddressInfo` descriptor so it can be logged. + */ +function redactToAddressInfo( + toAddressInfo: EdgeSwapToAddressInfo +): EdgeSwapToAddressInfo { + const { toMemos } = toAddressInfo + return { + ...toAddressInfo, + toAddress: '[redacted]', + toMemos: + toMemos == null + ? undefined + : toMemos.map(memo => ({ ...memo, value: '[redacted]' })) + } +} + +/** + * Validates the destination on a swap request and resolves it to a request that + * always carries a `toWallet`. Exactly one of `toWallet` or `toAddressInfo` must + * be present; when it is `toAddressInfo`, a synthetic destination wallet is built + * core-side from the descriptor. + */ +function resolveSwapRequest( + ai: ApiInput, + accountId: string, + request: EdgeSwapRequest +): EdgeSwapRequest { + const { toWallet, toAddressInfo } = request + + if ((toWallet == null) === (toAddressInfo == null)) { + throw new Error( + 'Swap request must include exactly one of `toWallet` or `toAddressInfo`' + ) + } + if (toAddressInfo == null) return request + + const { toPluginId, toAddress, toMemos } = toAddressInfo + const { toTokenId } = request + if (ai.props.state.plugins.currency[toPluginId] == null) { + throw new Error( + `Cannot build swap destination: no currency plugin "${toPluginId}"` + ) + } + // The account's own long-lived config, not a fresh one. A per-request + // `new CurrencyConfig` would be bridgified into the synthetic wallet and ride + // back to the caller inside `quote.request.toWallet`, and nothing closes it, + // so every swap-to-address quote would add a duplicate entry to yaob's object + // table for the lifetime of the account. + const { accountApi } = ai.props.output.accounts[accountId] + const currencyConfig = accountApi.currencyConfig[toPluginId] + if (toTokenId != null && currencyConfig.allTokens[toTokenId] == null) { + throw new Error( + `Cannot build swap destination: no token "${toTokenId}" on plugin "${toPluginId}"` + ) + } + + // Drop the descriptor from the resolved request so it keeps exactly one + // destination: a resolved request that rides back to the caller inside + // `quote.request` must be re-submittable without tripping the + // exactly-one-of rule above. + return { + ...request, + toAddressInfo: undefined, + toWallet: makeSyntheticDestinationWallet(currencyConfig, toAddress, toMemos) + } +} + +/** + * Wraps a plugin's quote in a bridgeable object the caller can hold. + * + * `onClose` fires exactly once per wrapper, however many times the caller + * calls `close`, so a caller that double-closes cannot release a shared + * resource (the synthetic destination wallet) early. Exported for testing. + */ +export function wrapQuote( swapPlugins: EdgePluginMap, request: EdgeSwapRequest, - quote: EdgeSwapQuote + quote: EdgeSwapQuote, + onClose: () => void = () => {} ): EdgeSwapQuote { + // A caller may close the same quote twice. `onClose` releases a shared + // resource by reference count, so it must fire exactly once per wrapper. + let closed = false const out = bridgifyObject({ canBePartial: quote.canBePartial, expirationDate: quote.expirationDate, @@ -153,6 +282,10 @@ function wrapQuote( async close() { await quote.close() + if (!closed) { + closed = true + onClose() + } close(out) } }) diff --git a/src/core/swap/synthetic-wallet.ts b/src/core/swap/synthetic-wallet.ts new file mode 100644 index 000000000..f977489c5 --- /dev/null +++ b/src/core/swap/synthetic-wallet.ts @@ -0,0 +1,76 @@ +import { bridgifyObject } from 'yaob' + +import { + EdgeAddress, + EdgeCurrencyConfig, + EdgeCurrencyWallet, + EdgeMemo, + EdgeReceiveAddress +} from '../../types/types' + +/** + * A prefix marking a synthetic destination wallet's `id`. A swap-to-address + * destination has no real payout wallet, so this id does not resolve to one; + * it only exists because plugins read `toWallet.id` for order metadata. + */ +export const SYNTHETIC_WALLET_ID_PREFIX = 'synthetic://' + +/** + * Build a synthetic, bridgified destination wallet for a swap-to-address + * request. It is backed by the real `currencyConfig` core already holds, so + * `currencyInfo` and `currencyConfig.allTokens` are authentic, while + * `getAddresses` / `getReceiveAddress` return the pasted destination address. + * + * It is bridgified here (core-side) so swap-plugin method calls work unchanged + * and so it survives the yaob wire format when it rides back to the GUI inside + * `quote.request.toWallet`. A GUI-built fake cannot do this: its function + * properties fail to cross the bridge (see the Phase 1 verdict). + */ +export function makeSyntheticDestinationWallet( + currencyConfig: EdgeCurrencyConfig, + toAddress: string, + toMemos: EdgeMemo[] = [] +): EdgeCurrencyWallet { + const { currencyInfo } = currencyConfig + + const addresses: EdgeAddress[] = [ + { addressType: 'publicAddress', publicAddress: toAddress } + ] + const receiveAddress: EdgeReceiveAddress = { + publicAddress: toAddress, + metadata: {}, + nativeAmount: '0' + } + + const wallet = { + id: `${SYNTHETIC_WALLET_ID_PREFIX}${currencyInfo.pluginId}`, + type: currencyInfo.walletType, + currencyConfig, + currencyInfo, + + async getAddresses(): Promise { + return addresses + }, + + /** + * Destination memos (e.g. an XRP destination tag) for the payout. + * Not part of `EdgeCurrencyWallet`; see `EdgeSyntheticDestinationWallet`. + * Plugins that support destination memos detect this method at runtime. + */ + async getMemos(): Promise { + return toMemos + }, + + async getReceiveAddress(): Promise { + return receiveAddress + } + } + bridgifyObject(wallet) + + // The synthetic destination only implements the `EdgeCurrencyWallet` surface + // that swap plugins read on `toWallet` (id, type, currencyInfo, + // currencyConfig, getAddresses, getReceiveAddress), plus the synthetic-only + // `getMemos` (see `EdgeSyntheticDestinationWallet`). It is never used as a + // source wallet, so the spend/sign/sync methods are intentionally absent. + return wallet as unknown as EdgeCurrencyWallet +} diff --git a/src/types/error.ts b/src/types/error.ts index 738dd7a10..f3471d2d1 100644 --- a/src/types/error.ts +++ b/src/types/error.ts @@ -309,9 +309,13 @@ export class SwapCurrencyError extends Error { readonly toTokenId: EdgeTokenId constructor(swapInfo: EdgeSwapInfo, request: EdgeSwapRequest) { - const { fromWallet, toWallet, fromTokenId, toTokenId } = request + const { fromWallet, toWallet, toAddressInfo, fromTokenId, toTokenId } = + request const fromPluginId = fromWallet.currencyConfig.currencyInfo.pluginId - const toPluginId = toWallet.currencyConfig.currencyInfo.pluginId + const toPluginId = + toWallet?.currencyConfig.currencyInfo.pluginId ?? + toAddressInfo?.toPluginId ?? + '' const fromString = `${fromPluginId}:${String(fromTokenId)}` const toString = `${toPluginId}:${String(toTokenId)}` diff --git a/src/types/types.ts b/src/types/types.ts index 49c9556bd..96b94dacb 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -284,7 +284,16 @@ export interface EdgeTxActionSwap { fromAsset: EdgeAssetAmount toAsset: EdgeAssetAmount payoutAddress: string - payoutWalletId: string + + /** + * The wallet that received the payout, for a normal wallet-to-wallet swap. + * Optional because a swap-to-address (private send) destination has no payout + * wallet; `payoutAddress` carries the destination in that case. GUI + * `savedAction` consumers that assume this is always present need a sweep + * before relying on it. + */ + payoutWalletId?: string + refundAddress?: string } @@ -603,7 +612,14 @@ export interface EdgeTxSwap { payoutCurrencyCode: string payoutNativeAmount: string payoutTokenId?: EdgeTokenId - payoutWalletId: string + + /** + * The wallet that received the payout, for a normal wallet-to-wallet swap. + * Optional because a swap-to-address (private send) destination has no + * payout wallet; `payoutAddress` carries the destination in that case. + */ + payoutWalletId?: string + refundAddress?: string } @@ -1507,10 +1523,55 @@ export interface EdgeSwapInfo { readonly supportEmail: string } +/** + * An address-only swap destination, used in place of a destination + * `toWallet` for "swap-to-address" (e.g. private send). The core builds a + * synthetic destination wallet from this descriptor, backed by the real + * `currencyConfig` for `toPluginId`, so swap plugins need no changes. The + * destination token is taken from the request's `toTokenId`, so it is not + * repeated here. + */ +export interface EdgeSwapToAddressInfo { + toPluginId: string + toAddress: string + + /** + * Destination memos (e.g. an XRP destination tag) for memo-required payout + * chains. This descriptor field is only the GUI-to-core transport: swap + * plugins never read it. The core copies it onto the synthetic destination + * wallet, which exposes it through `getMemos` (see + * `EdgeSyntheticDestinationWallet`), so plugins consume destination memos + * through the wallet surface alone. + */ + toMemos?: EdgeMemo[] +} + +/** + * The extra surface a core-built synthetic destination wallet exposes on top + * of the `EdgeCurrencyWallet` members swap plugins normally read. Real + * wallets do not implement `getMemos`; plugins that support destination + * memos should detect it at runtime, such as: + * `const { getMemos } = toWallet as Partial` + */ +export interface EdgeSyntheticDestinationWallet extends EdgeCurrencyWallet { + readonly getMemos: () => Promise +} + export interface EdgeSwapRequest { // Where? fromWallet: EdgeCurrencyWallet - toWallet: EdgeCurrencyWallet + + /** + * The destination wallet for a normal wallet-to-wallet swap. + * Provide exactly one of `toWallet` or `toAddressInfo`. + */ + toWallet?: EdgeCurrencyWallet + + /** + * An address-only destination, as an alternative to `toWallet`. + * Provide exactly one of `toWallet` or `toAddressInfo`. + */ + toAddressInfo?: EdgeSwapToAddressInfo // What? fromTokenId: EdgeTokenId diff --git a/test/core/swap-quote-close.test.ts b/test/core/swap-quote-close.test.ts new file mode 100644 index 000000000..2c312a181 --- /dev/null +++ b/test/core/swap-quote-close.test.ts @@ -0,0 +1,63 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { wrapQuote } from '../../src/core/swap/swap-api' +import { + EdgePluginMap, + EdgeSwapPlugin, + EdgeSwapQuote, + EdgeSwapRequest +} from '../../src/types/types' + +const swapInfo = { pluginId: 'fake', displayName: 'Fake', isDex: false } +const swapPlugins = { + fake: { swapInfo } +} as unknown as EdgePluginMap + +const makeQuote = (): EdgeSwapQuote => + ({ + pluginId: 'fake', + swapInfo, + fromNativeAmount: '1', + toNativeAmount: '1', + isEstimate: false, + async approve() { + throw new Error('not used') + }, + async close() {} + }) as unknown as EdgeSwapQuote + +const request = {} as unknown as EdgeSwapRequest + +describe('wrapQuote close', function () { + it('releases the shared destination once every quote is closed', async function () { + // The synthetic destination wallet is shared by every quote one + // `fetchSwapQuotes` call returns, so it may only be released after the + // last of them is closed. + let released = 0 + const release = (): void => { + released++ + } + const wrapped = [makeQuote(), makeQuote()].map(quote => + wrapQuote(swapPlugins, request, quote, release) + ) + + await wrapped[0].close() + expect(released).equals(1) + await wrapped[1].close() + expect(released).equals(2) + }) + + it('releases once per quote, however many times it is closed', async function () { + // The release is reference-counted, so a caller that closes the same quote + // twice must not decrement twice and free the shared wallet early. + let released = 0 + const wrapped = wrapQuote(swapPlugins, request, makeQuote(), () => { + released++ + }) + + await wrapped.close() + await wrapped.close() + expect(released).equals(1) + }) +}) diff --git a/test/core/synthetic-wallet.test.ts b/test/core/synthetic-wallet.test.ts new file mode 100644 index 000000000..b2ccbccf5 --- /dev/null +++ b/test/core/synthetic-wallet.test.ts @@ -0,0 +1,183 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' +import { Bridgeable, bridgifyObject, makeLocalBridge } from 'yaob' + +import { + makeSyntheticDestinationWallet, + SYNTHETIC_WALLET_ID_PREFIX +} from '../../src/core/swap/synthetic-wallet' +import { + EdgeCurrencyConfig, + EdgeCurrencyInfo, + EdgeCurrencyWallet, + EdgeMemo, + EdgeSwapToAddressInfo, + EdgeSyntheticDestinationWallet, + EdgeToken, + EdgeTokenId, + EdgeTokenMap +} from '../../src/index' + +// A real destination address the GUI would paste in: +const PAYOUT_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' +const USDC_TOKEN_ID = 'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + +const usdcToken: EdgeToken = { + currencyCode: 'USDC', + displayName: 'USD Coin', + denominations: [{ name: 'USDC', multiplier: '1000000' }], + networkLocation: { contractAddress: `0x${USDC_TOKEN_ID}` } +} +const allTokens: EdgeTokenMap = { [USDC_TOKEN_ID]: usdcToken } + +const currencyInfo: EdgeCurrencyInfo = { + pluginId: 'ethereum', + currencyCode: 'ETH', + walletType: 'wallet:ethereum', + displayName: 'Ethereum', + denominations: [{ name: 'ETH', multiplier: '1000000000000000000' }] +} as unknown as EdgeCurrencyInfo + +// Stand-in for the real, bridgeable `currencyConfig` the core already holds for +// the destination plugin. Only the surface the synthetic wallet reads is needed. +const currencyConfig = bridgifyObject({ + currencyInfo, + allTokens +}) as unknown as EdgeCurrencyConfig + +/** + * The shape the GUI sees across the bridge. It accepts ONLY the descriptor + * (plain data) and returns what a plugin-faithful consumer read off the + * core-built synthetic destination, plus the synthetic itself. + */ +interface ConsumerReads { + address: string + receiveAddress: string + tokenCurrencyCode: string | undefined + parentCurrencyCode: string + walletType: string + walletId: string +} +interface DestinationProof { + consumer: ConsumerReads + toWallet: EdgeCurrencyWallet +} +interface CoreSwapApi { + buildDestination: ( + toAddressInfo: EdgeSwapToAddressInfo, + toTokenId: EdgeTokenId + ) => Promise +} + +/** + * Mirrors the core side of the production GUI<->core bridge: it receives the + * descriptor plus the request's `toTokenId`, builds the synthetic destination, + * and runs a consumer that makes exactly the reads swap plugins make on + * `toWallet`. The destination token comes from the request, not the descriptor. + */ +class FakeCoreSwapApi extends Bridgeable implements CoreSwapApi { + async buildDestination( + toAddressInfo: EdgeSwapToAddressInfo, + toTokenId: EdgeTokenId + ): Promise { + const synthetic = makeSyntheticDestinationWallet( + currencyConfig, + toAddressInfo.toAddress, + toAddressInfo.toMemos + ) + + // Plugin-faithful consumer (runs core-side, by reference): the same reads + // `getAddress`, `getReceiveAddress`, `denominationToNative`, and the central + // plugins make on a destination wallet. + const addresses = await synthetic.getAddresses({ tokenId: null }) + const receiveAddress = await synthetic.getReceiveAddress({ tokenId: null }) + const token = + toTokenId != null + ? synthetic.currencyConfig.allTokens[toTokenId] + : undefined + + const consumer: ConsumerReads = { + address: addresses[0].publicAddress, + receiveAddress: receiveAddress.publicAddress, + tokenCurrencyCode: token?.currencyCode, + parentCurrencyCode: synthetic.currencyInfo.currencyCode, + walletType: synthetic.type, + walletId: synthetic.id + } + return { consumer, toWallet: synthetic } + } +} + +describe('synthetic destination wallet', function () { + // Same wire format edge-core-js uses in production (index.ts): a JSON + // round-trip on every message. + const guiApi: CoreSwapApi = makeLocalBridge(new FakeCoreSwapApi(), { + cloneMessage: message => JSON.parse(JSON.stringify(message)) + }) + + it('builds a working destination from a descriptor across the bridge', async function () { + // The GUI passes ONLY the descriptor (plain data), the exact thing that + // crosses the bridge cleanly, unlike the Phase 1 GUI-built fake wallet. + const toAddressInfo: EdgeSwapToAddressInfo = { + toPluginId: 'ethereum', + toAddress: PAYOUT_ADDRESS + } + const proof = await guiApi.buildDestination(toAddressInfo, USDC_TOKEN_ID) + + // The core-side consumer got a fully working destination: + expect(proof.consumer.address).equals(PAYOUT_ADDRESS) + expect(proof.consumer.receiveAddress).equals(PAYOUT_ADDRESS) + expect(proof.consumer.tokenCurrencyCode).equals('USDC') + expect(proof.consumer.parentCurrencyCode).equals('ETH') + expect(proof.consumer.walletType).equals('wallet:ethereum') + expect(proof.consumer.walletId).equals( + `${SYNTHETIC_WALLET_ID_PREFIX}ethereum` + ) + + // And the bridgified synthetic survives the trip back GUI-ward and is + // callable across the wire, the direct inverse of the Phase 1 failure, + // where the fake's function properties threw on argument unpack. + const guiAddresses = await proof.toWallet.getAddresses({ tokenId: null }) + expect(guiAddresses[0].publicAddress).equals(PAYOUT_ADDRESS) + }) + + it('builds a parent-currency destination when toTokenId is null', async function () { + const proof = await guiApi.buildDestination( + { + toPluginId: 'ethereum', + toAddress: PAYOUT_ADDRESS + }, + null + ) + + expect(proof.consumer.address).equals(PAYOUT_ADDRESS) + expect(proof.consumer.tokenCurrencyCode).equals(undefined) + expect(proof.consumer.parentCurrencyCode).equals('ETH') + }) + + it('exposes destination memos through getMemos across the bridge', async function () { + const toMemos: EdgeMemo[] = [{ type: 'number', value: '8675309' }] + const proof = await guiApi.buildDestination( + { + toPluginId: 'ethereum', + toAddress: PAYOUT_ADDRESS, + toMemos + }, + null + ) + + // The synthetic exposes the descriptor's memos to plugin-side readers, + // and the method still works across the yaob wire format: + const synthetic = proof.toWallet as EdgeSyntheticDestinationWallet + expect(await synthetic.getMemos()).deep.equals(toMemos) + }) + + it('returns no memos when the descriptor has none', async function () { + const proof = await guiApi.buildDestination( + { toPluginId: 'ethereum', toAddress: PAYOUT_ADDRESS }, + null + ) + const synthetic = proof.toWallet as EdgeSyntheticDestinationWallet + expect(await synthetic.getMemos()).deep.equals([]) + }) +}) From 42dfc0c5a271ba0e9bfe808e89ea41e77605bcc4 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:15:51 -0700 Subject: [PATCH 2/3] Name the send-shaped swap flows on the swap action 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. --- .../wallet/currency-wallet-cleaners.ts | 3 ++- src/types/types.ts | 23 +++++++++++++++++++ .../currency/wallet/currency-wallet.test.ts | 5 +++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/core/currency/wallet/currency-wallet-cleaners.ts b/src/core/currency/wallet/currency-wallet-cleaners.ts index ca771ba71..e64dd0960 100644 --- a/src/core/currency/wallet/currency-wallet-cleaners.ts +++ b/src/core/currency/wallet/currency-wallet-cleaners.ts @@ -192,7 +192,8 @@ export const asEdgeTxActionSwap = asObject({ toAsset: asEdgeAssetAmount, payoutWalletId: asOptional(asString), payoutAddress: asString, - refundAddress: asOptional(asString) + refundAddress: asOptional(asString), + swapType: asOptional(asValue('swapSend', 'stealthSend', 'stealthSwapSend')) }) export const asEdgeTxActionStake = asObject({ diff --git a/src/types/types.ts b/src/types/types.ts index 96b94dacb..b51810807 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -274,6 +274,22 @@ export interface EdgeFiatAmount { fiatAmount: string } +/** + * How a swap was initiated, when it was not a plain swap between two of the + * user's own wallets. These flows all settle through a swap provider and keep + * every other field of `EdgeTxActionSwap`, so they stay swaps to existing + * consumers; what differs is how the user reached them and, for the private + * flavors, how much of the destination a UI may reveal. + * + * - `swapSend`: cross-asset send to an address the user entered. + * - `stealthSend`: same-asset send routed privately through the provider. + * - `stealthSwapSend`: cross-asset send routed privately. + */ +export type EdgeTxActionSwapType = + | 'swapSend' + | 'stealthSend' + | 'stealthSwapSend' + export interface EdgeTxActionSwap { actionType: 'swap' swapInfo: EdgeSwapInfo @@ -285,6 +301,13 @@ export interface EdgeTxActionSwap { toAsset: EdgeAssetAmount payoutAddress: string + /** + * Names a send-shaped swap flow. Absent for a normal wallet-to-wallet swap. + * A UI keyed on this can title the transaction for the flow the user + * actually ran, instead of inferring it from the presence of other fields. + */ + swapType?: EdgeTxActionSwapType + /** * The wallet that received the payout, for a normal wallet-to-wallet swap. * Optional because a swap-to-address (private send) destination has no payout diff --git a/test/core/currency/wallet/currency-wallet.test.ts b/test/core/currency/wallet/currency-wallet.test.ts index dfd14db20..9ec0614fa 100644 --- a/test/core/currency/wallet/currency-wallet.test.ts +++ b/test/core/currency/wallet/currency-wallet.test.ts @@ -610,7 +610,10 @@ describe('currency wallets', function () { } ]) expect(txs[0].assetAction).deep.equals(assetAction) - expect(txs[0].savedAction).deep.equals(savedAction) + expect(txs[0].savedAction).deep.equals({ + swapType: undefined, + ...savedAction + }) expect(txs[0].swapData).deep.equals({ orderUri: undefined, refundAddress: undefined, From e2bef4ed8ca1e0c741f25aa008f2e120615de007 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Mon, 17 Aug 2026 11:15:59 -0700 Subject: [PATCH 3/3] Add swap route-privacy and force-enabled request options 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. --- CHANGELOG.md | 7 +++++++ src/core/swap/swap-api.ts | 35 ++++++++++++++++++++++++++++++++++- src/types/types.ts | 18 ++++++++++++++++++ test/core/swap.test.ts | 34 +++++++++++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe21f098..ca538a72a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- added: Accept an optional `toAddressInfo` descriptor on `EdgeSwapRequest` as an alternative to `toWallet`, so a swap can target a pasted destination address. The core builds a synthetic, bridgified destination wallet from the descriptor, backed by the real `currencyConfig`, leaving swap plugins unchanged. Exactly one of `toWallet` or `toAddressInfo` is required. +- added: Optional `toMemos` on `EdgeSwapToAddressInfo` for memo-required payout chains (e.g. an XRP destination tag). Swap plugins read the memos off the synthetic destination wallet's `getMemos` method (`EdgeSyntheticDestinationWallet`), never off the descriptor. +- added: Optional `swapType` on `EdgeTxActionSwap` (`EdgeTxActionSwapType`: `swapSend`, `stealthSend`, `stealthSwapSend`), naming the send-shaped swap flows so a UI can title a transaction by the flow the user ran instead of inferring it. Absent for a normal wallet-to-wallet swap. +- added: Optional `privacy` on `EdgeSwapRequest`. `'required'` restricts the quote to routes that keep the sender unlinkable to the recipient; a plugin that cannot offer one must decline rather than answer with a transparent route. +- added: Optional `forceEnabled` on `EdgeSwapRequestOptions`, letting a caller query named plugins that the user switched off in their swap settings. An explicit `disabled` entry still wins. +- changed: Make `EdgeTxActionSwap.payoutWalletId` and `EdgeTxSwap.payoutWalletId` optional, since a swap-to-address destination has no payout wallet (`payoutAddress` carries the destination). + ## 2.48.0 (2026-08-20) - added: `EdgeContext.setAttestationToken` to attach an `x-attestation-token` header on login-server requests. diff --git a/src/core/swap/swap-api.ts b/src/core/swap/swap-api.ts index f8adda43c..ec31fa4e8 100644 --- a/src/core/swap/swap-api.ts +++ b/src/core/swap/swap-api.ts @@ -32,6 +32,7 @@ export async function fetchSwapQuotes( ): Promise { const { disabled = {}, + forceEnabled = {}, noResponseMs, preferPluginId, promoCodes = {}, @@ -89,7 +90,15 @@ export async function fetchSwapQuotes( for (const pluginId of Object.keys(swapPlugins)) { const { enabled = true } = swapSettings[pluginId] != null ? swapSettings[pluginId] : {} - if (!enabled || disabled[pluginId]) continue + if ( + !isSwapPluginQueryable({ + enabled, + forceEnabled: forceEnabled[pluginId], + disabled: disabled[pluginId] + }) + ) { + continue + } // Start request: pendingIds.add(pluginId) @@ -178,6 +187,30 @@ export async function fetchSwapQuotes( return await timeout(promise, noResponseMs) } +/** + * Whether one swap plugin should be queried for a request. + * + * `forceEnabled` reaches a plugin the user switched off in their swap settings, + * for a caller whose feature is powered by that one named provider: the setting + * answers which providers the aggregator may choose among, not whether a + * feature built on a specific provider may work at all. An explicit `disabled` + * entry from the same call always wins, since that is the caller narrowing its + * own request rather than the user stating a preference. + */ +export function isSwapPluginQueryable(opts: { + enabled: boolean + /** + * Optional because both flags are read out of an `EdgePluginMap`, where an + * absent plugin reads as `undefined` rather than `false`. + */ + forceEnabled?: boolean + disabled?: boolean +}): boolean { + const { enabled, forceEnabled = false, disabled = false } = opts + if (disabled) return false + return enabled || forceEnabled +} + /** * Strips the private pieces (destination address and memos) out of a * `toAddressInfo` descriptor so it can be logged. diff --git a/src/types/types.ts b/src/types/types.ts index b51810807..f8be57b2b 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1603,6 +1603,15 @@ export interface EdgeSwapRequest { // How much? nativeAmount: string quoteFor: 'from' | 'max' | 'to' + + /** + * Route privacy requirement. `'required'` means the quote must come from a + * route that keeps the sender unlinkable to the recipient. A plugin that + * cannot offer one must decline the request rather than answer with a + * transparent route, since a caller asking for privacy would otherwise get + * a downgrade it has no way to detect. Omitted means any route will do. + */ + privacy?: 'required' } /** @@ -1828,6 +1837,15 @@ export interface EdgeSwapRequestOptions { disabled?: EdgePluginMap promoCodes?: EdgePluginMap + /** + * Plugins to query even when the user has switched them off in their swap + * settings. For a feature that is powered by one specific provider rather + * than by the swap aggregator, the provider toggle is not the user's answer + * about that feature, so the caller can opt out of it for a single request. + * `disabled` still wins: an explicitly disabled plugin stays disabled. + */ + forceEnabled?: EdgePluginMap + /** * If we have some quotes already, how long should we wait * for stragglers before we give up? Defaults to 20000ms. diff --git a/test/core/swap.test.ts b/test/core/swap.test.ts index e7e02028e..0927faed8 100644 --- a/test/core/swap.test.ts +++ b/test/core/swap.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai' import { describe, it } from 'mocha' -import { sortQuotes } from '../../src/core/swap/swap-api' +import { isSwapPluginQueryable, sortQuotes } from '../../src/core/swap/swap-api' import { EdgeSwapInfo, EdgeSwapQuote, EdgeSwapRequest } from '../../src/index' const typeHack: any = {} @@ -109,3 +109,35 @@ describe('swap', function () { expect(getIds(sorted)).equals('switchain, changenow, godex, thorchain') }) }) + +describe('swap plugin selection', function () { + // The whole truth table, because the interesting cases are the corners: a + // caller must be able to reach a provider the user switched off, and must + // never be able to reach one it disabled itself in the same call. + const cases: Array<{ + enabled: boolean + forceEnabled: boolean + disabled: boolean + queryable: boolean + }> = [ + { enabled: true, forceEnabled: false, disabled: false, queryable: true }, + { enabled: true, forceEnabled: true, disabled: false, queryable: true }, + { enabled: false, forceEnabled: false, disabled: false, queryable: false }, + // The send scene's stealth path: Houdini powers the feature, so the swap + // setting does not get to switch the feature off. + { enabled: false, forceEnabled: true, disabled: false, queryable: true }, + { enabled: true, forceEnabled: false, disabled: true, queryable: false }, + // `disabled` beats `forceEnabled`. Stealth sends disable every other + // plugin and force-enable Houdini in the same call, so a bug here would + // let the aggregator answer a request that demanded one provider. + { enabled: true, forceEnabled: true, disabled: true, queryable: false }, + { enabled: false, forceEnabled: false, disabled: true, queryable: false }, + { enabled: false, forceEnabled: true, disabled: true, queryable: false } + ] + + for (const { queryable, ...flags } of cases) { + it(`${JSON.stringify(flags)} -> ${String(queryable)}`, function () { + expect(isSwapPluginQueryable(flags)).equals(queryable) + }) + } +})