Skip to content

Commit edc8fbd

Browse files
committed
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.
1 parent dab7afd commit edc8fbd

7 files changed

Lines changed: 430 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
- 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.
6+
- 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.
7+
- changed: Make `EdgeTxActionSwap.payoutWalletId` and `EdgeTxSwap.payoutWalletId` optional, since a swap-to-address destination has no payout wallet (`payoutAddress` carries the destination).
8+
59
## 2.46.1 (2026-06-29)
610

711
- changed: Upgrade yaob to v0.4.0 to share a single bridge instance with currency plugins (mismatched yaob copies give the bridge separate object-id counters that cross-wire bridged nativeIo objects).

src/core/currency/wallet/currency-wallet-cleaners.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ export const asEdgeTxSwap = asObject<EdgeTxSwap>({
146146
payoutCurrencyCode: asString,
147147
payoutTokenId: asOptional(asEdgeTokenId),
148148
payoutNativeAmount: asString,
149-
payoutWalletId: asString,
149+
payoutWalletId: asOptional(asString),
150150
refundAddress: asOptional(asString)
151151
})
152152

@@ -190,7 +190,7 @@ export const asEdgeTxActionSwap = asObject<EdgeTxActionSwap>({
190190
canBePartial: asOptional(asBoolean),
191191
fromAsset: asEdgeAssetAmount,
192192
toAsset: asEdgeAssetAmount,
193-
payoutWalletId: asString,
193+
payoutWalletId: asOptional(asString),
194194
payoutAddress: asString,
195195
refundAddress: asOptional(asString)
196196
})

src/core/swap/swap-api.ts

Lines changed: 95 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@ import {
1414
EdgeSwapPlugin,
1515
EdgeSwapQuote,
1616
EdgeSwapRequest,
17-
EdgeSwapRequestOptions
17+
EdgeSwapRequestOptions,
18+
EdgeSwapToAddressInfo
1819
} from '../../types/types'
1920
import { fuzzyTimeout, timeout } from '../../util/promise'
21+
import { CurrencyConfig } from '../account/plugin-api'
2022
import { ApiInput } from '../root-pixie'
23+
import { makeSyntheticDestinationWallet } from './synthetic-wallet'
2124

2225
/**
2326
* Fetch quotes from all plugins, and sorts the best ones to the front.
@@ -41,12 +44,24 @@ export async function fetchSwapQuotes(
4144
const { swapSettings, userSettings } = account
4245
const swapPlugins = state.plugins.swap
4346

47+
// Resolve the destination. A normal swap provides `toWallet`; a
48+
// swap-to-address (private send) request provides `toAddressInfo` instead, and
49+
// the core builds a synthetic destination wallet from it so plugins receive an
50+
// `EdgeCurrencyWallet` unchanged.
51+
const swapRequest = resolveSwapRequest(ai, accountId, request)
52+
4453
log.warn(
4554
'Requesting swap quotes for: ',
4655
{
47-
...request,
48-
fromWallet: request.fromWallet.id,
49-
toWallet: request.toWallet.id
56+
...swapRequest,
57+
fromWallet: swapRequest.fromWallet.id,
58+
toWallet: swapRequest.toWallet?.id,
59+
// Never log the pasted destination address or its memos
60+
// (private-send privacy):
61+
toAddressInfo:
62+
swapRequest.toAddressInfo == null
63+
? undefined
64+
: redactToAddressInfo(swapRequest.toAddressInfo)
5065
},
5166
{ preferPluginId, promoCodes }
5267
)
@@ -63,14 +78,24 @@ export async function fetchSwapQuotes(
6378
pendingIds.add(pluginId)
6479
promises.push(
6580
swapPlugins[pluginId]
66-
.fetchSwapQuote(request, userSettings[pluginId], {
81+
.fetchSwapQuote(swapRequest, userSettings[pluginId], {
6782
infoPayload: state.infoCache.corePlugins?.[pluginId] ?? {},
6883
promoCode: promoCodes[pluginId]
6984
})
7085
.then(
7186
quote => {
7287
upgradeSwapQuote(quote)
73-
const { fromWallet, toWallet, ...request } = quote.request ?? {}
88+
const { fromWallet, toWallet, toAddressInfo, ...rest } =
89+
quote.request ?? {}
90+
// Never log the pasted destination address or its memos
91+
// (private-send privacy):
92+
const request =
93+
toAddressInfo == null
94+
? rest
95+
: {
96+
...rest,
97+
toAddressInfo: redactToAddressInfo(toAddressInfo)
98+
}
7499
const cleaned = { ...quote, request }
75100
pendingIds.delete(pluginId)
76101
log.warn(`${pluginId} gave swap quote:`, cleaned)
@@ -86,12 +111,12 @@ export async function fetchSwapQuotes(
86111
swapPluginId: pluginId,
87112
request: {
88113
// Stringify to include "null"
89-
fromToken: String(request.fromTokenId),
90-
fromWalletType: request.fromWallet.type,
114+
fromToken: String(swapRequest.fromTokenId),
115+
fromWalletType: swapRequest.fromWallet.type,
91116
// Stringify to include "null"
92-
toToken: String(request.toTokenId),
93-
toWalletType: request.toWallet.type,
94-
quoteFor: request.quoteFor
117+
toToken: String(swapRequest.toTokenId),
118+
toWalletType: swapRequest.toWallet?.type,
119+
quoteFor: swapRequest.quoteFor
95120
}
96121
})
97122
}
@@ -117,7 +142,7 @@ export async function fetchSwapQuotes(
117142
)
118143

119144
// Prepare quotes for the bridge:
120-
return quotes.map(quote => wrapQuote(swapPlugins, request, quote))
145+
return quotes.map(quote => wrapQuote(swapPlugins, swapRequest, quote))
121146
},
122147
(errors: unknown[]) => {
123148
log.warn(`All ${promises.length} swap quotes rejected.`)
@@ -129,6 +154,64 @@ export async function fetchSwapQuotes(
129154
return await timeout(promise, noResponseMs)
130155
}
131156

157+
/**
158+
* Strips the private pieces (destination address and memos) out of a
159+
* `toAddressInfo` descriptor so it can be logged.
160+
*/
161+
function redactToAddressInfo(
162+
toAddressInfo: EdgeSwapToAddressInfo
163+
): EdgeSwapToAddressInfo {
164+
const { toMemos } = toAddressInfo
165+
return {
166+
...toAddressInfo,
167+
toAddress: '[redacted]',
168+
toMemos:
169+
toMemos == null
170+
? undefined
171+
: toMemos.map(memo => ({ ...memo, value: '[redacted]' }))
172+
}
173+
}
174+
175+
/**
176+
* Validates the destination on a swap request and resolves it to a request that
177+
* always carries a `toWallet`. Exactly one of `toWallet` or `toAddressInfo` must
178+
* be present; when it is `toAddressInfo`, a synthetic destination wallet is built
179+
* core-side from the descriptor.
180+
*/
181+
function resolveSwapRequest(
182+
ai: ApiInput,
183+
accountId: string,
184+
request: EdgeSwapRequest
185+
): EdgeSwapRequest {
186+
const { toWallet, toAddressInfo } = request
187+
188+
if ((toWallet == null) === (toAddressInfo == null)) {
189+
throw new Error(
190+
'Swap request must include exactly one of `toWallet` or `toAddressInfo`'
191+
)
192+
}
193+
if (toAddressInfo == null) return request
194+
195+
const { toPluginId, toAddress, toMemos } = toAddressInfo
196+
const { toTokenId } = request
197+
if (ai.props.state.plugins.currency[toPluginId] == null) {
198+
throw new Error(
199+
`Cannot build swap destination: no currency plugin "${toPluginId}"`
200+
)
201+
}
202+
const currencyConfig = new CurrencyConfig(ai, accountId, toPluginId)
203+
if (toTokenId != null && currencyConfig.allTokens[toTokenId] == null) {
204+
throw new Error(
205+
`Cannot build swap destination: no token "${toTokenId}" on plugin "${toPluginId}"`
206+
)
207+
}
208+
209+
return {
210+
...request,
211+
toWallet: makeSyntheticDestinationWallet(currencyConfig, toAddress, toMemos)
212+
}
213+
}
214+
132215
function wrapQuote(
133216
swapPlugins: EdgePluginMap<EdgeSwapPlugin>,
134217
request: EdgeSwapRequest,

src/core/swap/synthetic-wallet.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { bridgifyObject } from 'yaob'
2+
3+
import {
4+
EdgeAddress,
5+
EdgeCurrencyConfig,
6+
EdgeCurrencyWallet,
7+
EdgeMemo,
8+
EdgeReceiveAddress
9+
} from '../../types/types'
10+
11+
/**
12+
* A prefix marking a synthetic destination wallet's `id`. A swap-to-address
13+
* destination has no real payout wallet, so this id does not resolve to one;
14+
* it only exists because plugins read `toWallet.id` for order metadata.
15+
*/
16+
export const SYNTHETIC_WALLET_ID_PREFIX = 'synthetic://'
17+
18+
/**
19+
* Build a synthetic, bridgified destination wallet for a swap-to-address
20+
* request. It is backed by the real `currencyConfig` core already holds, so
21+
* `currencyInfo` and `currencyConfig.allTokens` are authentic, while
22+
* `getAddresses` / `getReceiveAddress` return the pasted destination address.
23+
*
24+
* It is bridgified here (core-side) so swap-plugin method calls work unchanged
25+
* and so it survives the yaob wire format when it rides back to the GUI inside
26+
* `quote.request.toWallet`. A GUI-built fake cannot do this: its function
27+
* properties fail to cross the bridge (see the Phase 1 verdict).
28+
*/
29+
export function makeSyntheticDestinationWallet(
30+
currencyConfig: EdgeCurrencyConfig,
31+
toAddress: string,
32+
toMemos: EdgeMemo[] = []
33+
): EdgeCurrencyWallet {
34+
const { currencyInfo } = currencyConfig
35+
36+
const addresses: EdgeAddress[] = [
37+
{ addressType: 'publicAddress', publicAddress: toAddress }
38+
]
39+
const receiveAddress: EdgeReceiveAddress = {
40+
publicAddress: toAddress,
41+
metadata: {},
42+
nativeAmount: '0'
43+
}
44+
45+
const wallet = {
46+
id: `${SYNTHETIC_WALLET_ID_PREFIX}${currencyInfo.pluginId}`,
47+
type: currencyInfo.walletType,
48+
currencyConfig,
49+
currencyInfo,
50+
51+
async getAddresses(): Promise<EdgeAddress[]> {
52+
return addresses
53+
},
54+
55+
/**
56+
* Destination memos (e.g. an XRP destination tag) for the payout.
57+
* Not part of `EdgeCurrencyWallet` — see `EdgeSyntheticDestinationWallet`.
58+
* Plugins that support destination memos detect this method at runtime.
59+
*/
60+
async getMemos(): Promise<EdgeMemo[]> {
61+
return toMemos
62+
},
63+
64+
async getReceiveAddress(): Promise<EdgeReceiveAddress> {
65+
return receiveAddress
66+
}
67+
}
68+
bridgifyObject(wallet)
69+
70+
// The synthetic destination only implements the `EdgeCurrencyWallet` surface
71+
// that swap plugins read on `toWallet` (id, type, currencyInfo,
72+
// currencyConfig, getAddresses, getReceiveAddress), plus the synthetic-only
73+
// `getMemos` (see `EdgeSyntheticDestinationWallet`). It is never used as a
74+
// source wallet, so the spend/sign/sync methods are intentionally absent.
75+
return wallet as unknown as EdgeCurrencyWallet
76+
}

src/types/error.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,9 +309,13 @@ export class SwapCurrencyError extends Error {
309309
readonly toTokenId: EdgeTokenId
310310

311311
constructor(swapInfo: EdgeSwapInfo, request: EdgeSwapRequest) {
312-
const { fromWallet, toWallet, fromTokenId, toTokenId } = request
312+
const { fromWallet, toWallet, toAddressInfo, fromTokenId, toTokenId } =
313+
request
313314
const fromPluginId = fromWallet.currencyConfig.currencyInfo.pluginId
314-
const toPluginId = toWallet.currencyConfig.currencyInfo.pluginId
315+
const toPluginId =
316+
toWallet?.currencyConfig.currencyInfo.pluginId ??
317+
toAddressInfo?.toPluginId ??
318+
''
315319

316320
const fromString = `${fromPluginId}:${String(fromTokenId)}`
317321
const toString = `${toPluginId}:${String(toTokenId)}`

src/types/types.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,16 @@ export interface EdgeTxActionSwap {
284284
fromAsset: EdgeAssetAmount
285285
toAsset: EdgeAssetAmount
286286
payoutAddress: string
287-
payoutWalletId: string
287+
288+
/**
289+
* The wallet that received the payout, for a normal wallet-to-wallet swap.
290+
* Optional because a swap-to-address (private send) destination has no payout
291+
* wallet; `payoutAddress` carries the destination in that case. GUI
292+
* `savedAction` consumers that assume this is always present need a sweep
293+
* before relying on it.
294+
*/
295+
payoutWalletId?: string
296+
288297
refundAddress?: string
289298
}
290299

@@ -603,7 +612,14 @@ export interface EdgeTxSwap {
603612
payoutCurrencyCode: string
604613
payoutNativeAmount: string
605614
payoutTokenId?: EdgeTokenId
606-
payoutWalletId: string
615+
616+
/**
617+
* The wallet that received the payout, for a normal wallet-to-wallet swap.
618+
* Optional because a swap-to-address (private send) destination has no
619+
* payout wallet; `payoutAddress` carries the destination in that case.
620+
*/
621+
payoutWalletId?: string
622+
607623
refundAddress?: string
608624
}
609625

@@ -1507,10 +1523,55 @@ export interface EdgeSwapInfo {
15071523
readonly supportEmail: string
15081524
}
15091525

1526+
/**
1527+
* An address-only swap destination, used in place of a destination
1528+
* `toWallet` for "swap-to-address" (e.g. private send). The core builds a
1529+
* synthetic destination wallet from this descriptor, backed by the real
1530+
* `currencyConfig` for `toPluginId`, so swap plugins need no changes. The
1531+
* destination token is taken from the request's `toTokenId`, so it is not
1532+
* repeated here.
1533+
*/
1534+
export interface EdgeSwapToAddressInfo {
1535+
toPluginId: string
1536+
toAddress: string
1537+
1538+
/**
1539+
* Destination memos (e.g. an XRP destination tag) for memo-required payout
1540+
* chains. This descriptor field is only the GUI-to-core transport: swap
1541+
* plugins never read it. The core copies it onto the synthetic destination
1542+
* wallet, which exposes it through `getMemos` (see
1543+
* `EdgeSyntheticDestinationWallet`), so plugins consume destination memos
1544+
* through the wallet surface alone.
1545+
*/
1546+
toMemos?: EdgeMemo[]
1547+
}
1548+
1549+
/**
1550+
* The extra surface a core-built synthetic destination wallet exposes on top
1551+
* of the `EdgeCurrencyWallet` members swap plugins normally read. Real
1552+
* wallets do not implement `getMemos`; plugins that support destination
1553+
* memos should detect it at runtime, such as:
1554+
* `const { getMemos } = toWallet as Partial<EdgeSyntheticDestinationWallet>`
1555+
*/
1556+
export interface EdgeSyntheticDestinationWallet extends EdgeCurrencyWallet {
1557+
readonly getMemos: () => Promise<EdgeMemo[]>
1558+
}
1559+
15101560
export interface EdgeSwapRequest {
15111561
// Where?
15121562
fromWallet: EdgeCurrencyWallet
1513-
toWallet: EdgeCurrencyWallet
1563+
1564+
/**
1565+
* The destination wallet for a normal wallet-to-wallet swap.
1566+
* Provide exactly one of `toWallet` or `toAddressInfo`.
1567+
*/
1568+
toWallet?: EdgeCurrencyWallet
1569+
1570+
/**
1571+
* An address-only destination, as an alternative to `toWallet`.
1572+
* Provide exactly one of `toWallet` or `toAddressInfo`.
1573+
*/
1574+
toAddressInfo?: EdgeSwapToAddressInfo
15141575

15151576
// What?
15161577
fromTokenId: EdgeTokenId

0 commit comments

Comments
 (0)