Skip to content
Draft
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions src/core/currency/wallet/currency-wallet-cleaners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export const asEdgeTxSwap = asObject<EdgeTxSwap>({
payoutCurrencyCode: asString,
payoutTokenId: asOptional(asEdgeTokenId),
payoutNativeAmount: asString,
payoutWalletId: asString,
payoutWalletId: asOptional(asString),
refundAddress: asOptional(asString)
})

Expand Down Expand Up @@ -190,9 +190,10 @@ export const asEdgeTxActionSwap = asObject<EdgeTxActionSwap>({
canBePartial: asOptional(asBoolean),
fromAsset: asEdgeAssetAmount,
toAsset: asEdgeAssetAmount,
payoutWalletId: asString,
payoutWalletId: asOptional(asString),
payoutAddress: asString,
refundAddress: asOptional(asString)
refundAddress: asOptional(asString),
swapType: asOptional(asValue('swapSend', 'stealthSend', 'stealthSwapSend'))
})

export const asEdgeTxActionStake = asObject<EdgeTxActionStake>({
Expand Down
196 changes: 181 additions & 15 deletions src/core/swap/swap-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -30,6 +32,7 @@ export async function fetchSwapQuotes(
): Promise<EdgeSwapQuote[]> {
const {
disabled = {},
forceEnabled = {},
noResponseMs,
preferPluginId,
promoCodes = {},
Expand All @@ -41,12 +44,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 }
)
Expand All @@ -57,20 +90,38 @@ 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)
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)
Expand All @@ -86,12 +137,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,10 +168,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)
}
)
Expand All @@ -129,11 +187,115 @@ export async function fetchSwapQuotes(
return await timeout(promise, noResponseMs)
}

function wrapQuote(
/**
* 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.
*/
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)
}
Comment thread
j0ntz marked this conversation as resolved.
}

/**
* 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<EdgeSwapPlugin>,
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<EdgeSwapQuote>({
canBePartial: quote.canBePartial,
expirationDate: quote.expirationDate,
Expand All @@ -153,6 +315,10 @@ function wrapQuote(

async close() {
await quote.close()
if (!closed) {
closed = true
onClose()
}
close(out)
}
})
Expand Down
76 changes: 76 additions & 0 deletions src/core/swap/synthetic-wallet.ts
Original file line number Diff line number Diff line change
@@ -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<EdgeAddress[]> {
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<EdgeMemo[]> {
return toMemos
},

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), 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
}
Loading
Loading