Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/core/currency/wallet/currency-wallet-cleaners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export const asEdgeTxActionSwap = asObject<EdgeTxActionSwap>({
canBePartial: asOptional(asBoolean),
fromAsset: asEdgeAssetAmount,
toAsset: asEdgeAssetAmount,
payoutWalletId: asString,
payoutWalletId: asOptional(asString),
payoutAddress: asString,
refundAddress: asOptional(asString)
})
Expand Down
84 changes: 73 additions & 11 deletions src/core/swap/swap-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Comment thread
j0ntz marked this conversation as resolved.
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 }
)
Expand All @@ -63,14 +76,23 @@ export async function fetchSwapQuotes(
pendingIds.add(pluginId)
promises.push(
swapPlugins[pluginId]
.fetchSwapQuote(request, userSettings[pluginId], {
.fetchSwapQuote(swapRequest, userSettings[pluginId], {
Comment thread
j0ntz marked this conversation as resolved.
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)
Expand All @@ -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
}
})
}
Expand All @@ -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.`)
Expand All @@ -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)
}
Comment thread
j0ntz marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved swap request breaks re-quote

Medium Severity

When toAddressInfo is resolved, the core adds a synthetic toWallet but leaves toAddressInfo on the same EdgeSwapRequest. Quotes that echo that request then carry both fields, so a later fetchSwapQuotes call with quote.request fails the “exactly one of toWallet or toAddressInfo” check.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bfcfcbb. Configure here.

}

function wrapQuote(
swapPlugins: EdgePluginMap<EdgeSwapPlugin>,
request: EdgeSwapRequest,
Expand Down
64 changes: 64 additions & 0 deletions src/core/swap/synthetic-wallet.ts
Original file line number Diff line number Diff line change
@@ -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<EdgeAddress[]> {
return addresses
},

async getReceiveAddress(): Promise<EdgeReceiveAddress> {
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
}
8 changes: 6 additions & 2 deletions src/types/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`
Expand Down
37 changes: 35 additions & 2 deletions src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.

refundAddress?: string
}

Expand Down Expand Up @@ -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
Expand Down
Loading