diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d907f876..9486f1728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 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. +- changed: Make `EdgeTxActionSwap.payoutWalletId` optional, since a swap-to-address destination has no payout wallet (`payoutAddress` carries the destination). + ## 2.44.0 (2026-04-06) - added: Expose engine otherMethods on EdgeMemoryWallet so memory wallets support custom methods like makeMaxSpend. diff --git a/src/core/currency/wallet/currency-wallet-cleaners.ts b/src/core/currency/wallet/currency-wallet-cleaners.ts index c4890912b..f95388b99 100644 --- a/src/core/currency/wallet/currency-wallet-cleaners.ts +++ b/src/core/currency/wallet/currency-wallet-cleaners.ts @@ -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..31010da29 100644 --- a/src/core/swap/swap-api.ts +++ b/src/core/swap/swap-api.ts @@ -17,7 +17,9 @@ import { EdgeSwapRequestOptions } from '../../types/types' import { fuzzyTimeout, timeout } from '../../util/promise' +import { CurrencyConfig } from '../account/plugin-api' 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,23 @@ 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) + 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 (private-send privacy): + toAddressInfo: + swapRequest.toAddressInfo == null + ? undefined + : { ...swapRequest.toAddressInfo, toAddress: '[redacted]' } }, { preferPluginId, promoCodes } ) @@ -63,14 +76,23 @@ 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 (private-send privacy): + const request = + toAddressInfo == null + ? rest + : { + ...rest, + toAddressInfo: { ...toAddressInfo, toAddress: '[redacted]' } + } const cleaned = { ...quote, request } pendingIds.delete(pluginId) log.warn(`${pluginId} gave swap quote:`, cleaned) @@ -86,12 +108,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,7 +139,7 @@ export async function fetchSwapQuotes( ) // Prepare quotes for the bridge: - return quotes.map(quote => wrapQuote(swapPlugins, request, quote)) + return quotes.map(quote => wrapQuote(swapPlugins, swapRequest, quote)) }, (errors: unknown[]) => { log.warn(`All ${promises.length} swap quotes rejected.`) @@ -129,6 +151,46 @@ export async function fetchSwapQuotes( return await timeout(promise, noResponseMs) } +/** + * 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 } = toAddressInfo + const { toTokenId } = request + if (ai.props.state.plugins.currency[toPluginId] == null) { + throw new Error( + `Cannot build swap destination: no currency plugin "${toPluginId}"` + ) + } + const currencyConfig = new CurrencyConfig(ai, accountId, toPluginId) + if (toTokenId != null && currencyConfig.allTokens[toTokenId] == null) { + throw new Error( + `Cannot build swap destination: no token "${toTokenId}" on plugin "${toPluginId}"` + ) + } + + return { + ...request, + toWallet: makeSyntheticDestinationWallet(currencyConfig, toAddress) + } +} + function wrapQuote( swapPlugins: EdgePluginMap, request: EdgeSwapRequest, diff --git a/src/core/swap/synthetic-wallet.ts b/src/core/swap/synthetic-wallet.ts new file mode 100644 index 000000000..89cdbf759 --- /dev/null +++ b/src/core/swap/synthetic-wallet.ts @@ -0,0 +1,64 @@ +import { bridgifyObject } from 'yaob' + +import { + EdgeAddress, + EdgeCurrencyConfig, + EdgeCurrencyWallet, + 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 +): 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 + }, + + 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). 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 ca3c2a9cf..30dec4447 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 } @@ -1499,10 +1508,34 @@ 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 +} + 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/synthetic-wallet.test.ts b/test/core/synthetic-wallet.test.ts new file mode 100644 index 000000000..78c816bf1 --- /dev/null +++ b/test/core/synthetic-wallet.test.ts @@ -0,0 +1,154 @@ +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, + EdgeSwapToAddressInfo, + 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 + ) + + // 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') + }) +})