Skip to content

Commit 0a8c8b9

Browse files
committed
Add core-side synthetic destination for swap-to-address
Let account.fetchSwapQuotes accept an optional toAddressInfo descriptor (toPluginId, toTokenId, toAddress) as an alternative to a destination toWallet. The core validates exactly one of the two, then builds a synthetic, bridgified EdgeCurrencyWallet from the descriptor, backed by the real currencyConfig it already holds. getAddresses and getReceiveAddress return the pasted address; currencyInfo and currencyConfig.allTokens come from the real plugin config. Swap plugins receive an EdgeCurrencyWallet unchanged. Make EdgeTxActionSwap.payoutWalletId optional, since a swap-to-address destination has no payout wallet. A bridge-crossing proof test stands up the production yaob wire format (makeLocalBridge with a JSON cloneMessage): the GUI passes only the descriptor, the core builds the synthetic destination, and a plugin-faithful consumer reads a working address, token, and currency info through the bridge. This inverts the Phase 1 failure where a GUI-built fake wallet could not cross the bridge.
1 parent 9cff03e commit 0a8c8b9

6 files changed

Lines changed: 323 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
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+
- changed: Make `EdgeTxActionSwap.payoutWalletId` optional, since a swap-to-address destination has no payout wallet (`payoutAddress` carries the destination).
7+
58
## 2.44.0 (2026-04-06)
69

710
- added: Expose engine otherMethods on EdgeMemoryWallet so memory wallets support custom methods like makeMaxSpend.

src/core/swap/swap-api.ts

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import {
1717
EdgeSwapRequestOptions
1818
} from '../../types/types'
1919
import { fuzzyTimeout, timeout } from '../../util/promise'
20+
import { CurrencyConfig } from '../account/plugin-api'
2021
import { ApiInput } from '../root-pixie'
22+
import { makeSyntheticDestinationWallet } from './synthetic-wallet'
2123

2224
/**
2325
* Fetch quotes from all plugins, and sorts the best ones to the front.
@@ -41,12 +43,23 @@ export async function fetchSwapQuotes(
4143
const { swapSettings, userSettings } = account
4244
const swapPlugins = state.plugins.swap
4345

46+
// Resolve the destination. A normal swap provides `toWallet`; a
47+
// swap-to-address (private send) request provides `toAddressInfo` instead, and
48+
// the core builds a synthetic destination wallet from it so plugins receive an
49+
// `EdgeCurrencyWallet` unchanged.
50+
const swapRequest = resolveSwapRequest(ai, accountId, request)
51+
4452
log.warn(
4553
'Requesting swap quotes for: ',
4654
{
47-
...request,
48-
fromWallet: request.fromWallet.id,
49-
toWallet: request.toWallet.id
55+
...swapRequest,
56+
fromWallet: swapRequest.fromWallet.id,
57+
toWallet: swapRequest.toWallet?.id,
58+
// Never log the pasted destination address (private-send privacy):
59+
toAddressInfo:
60+
swapRequest.toAddressInfo == null
61+
? undefined
62+
: { ...swapRequest.toAddressInfo, toAddress: '[redacted]' }
5063
},
5164
{ preferPluginId, promoCodes }
5265
)
@@ -63,7 +76,7 @@ export async function fetchSwapQuotes(
6376
pendingIds.add(pluginId)
6477
promises.push(
6578
swapPlugins[pluginId]
66-
.fetchSwapQuote(request, userSettings[pluginId], {
79+
.fetchSwapQuote(swapRequest, userSettings[pluginId], {
6780
infoPayload: state.infoCache.corePlugins?.[pluginId] ?? {},
6881
promoCode: promoCodes[pluginId]
6982
})
@@ -86,12 +99,12 @@ export async function fetchSwapQuotes(
8699
swapPluginId: pluginId,
87100
request: {
88101
// Stringify to include "null"
89-
fromToken: String(request.fromTokenId),
90-
fromWalletType: request.fromWallet.type,
102+
fromToken: String(swapRequest.fromTokenId),
103+
fromWalletType: swapRequest.fromWallet.type,
91104
// Stringify to include "null"
92-
toToken: String(request.toTokenId),
93-
toWalletType: request.toWallet.type,
94-
quoteFor: request.quoteFor
105+
toToken: String(swapRequest.toTokenId),
106+
toWalletType: swapRequest.toWallet?.type,
107+
quoteFor: swapRequest.quoteFor
95108
}
96109
})
97110
}
@@ -117,7 +130,7 @@ export async function fetchSwapQuotes(
117130
)
118131

119132
// Prepare quotes for the bridge:
120-
return quotes.map(quote => wrapQuote(swapPlugins, request, quote))
133+
return quotes.map(quote => wrapQuote(swapPlugins, swapRequest, quote))
121134
},
122135
(errors: unknown[]) => {
123136
log.warn(`All ${promises.length} swap quotes rejected.`)
@@ -129,6 +142,50 @@ export async function fetchSwapQuotes(
129142
return await timeout(promise, noResponseMs)
130143
}
131144

145+
/**
146+
* Validates the destination on a swap request and resolves it to a request that
147+
* always carries a `toWallet`. Exactly one of `toWallet` or `toAddressInfo` must
148+
* be present; when it is `toAddressInfo`, a synthetic destination wallet is built
149+
* core-side from the descriptor.
150+
*/
151+
function resolveSwapRequest(
152+
ai: ApiInput,
153+
accountId: string,
154+
request: EdgeSwapRequest
155+
): EdgeSwapRequest {
156+
const { toWallet, toAddressInfo } = request
157+
158+
if ((toWallet == null) === (toAddressInfo == null)) {
159+
throw new Error(
160+
'Swap request must include exactly one of `toWallet` or `toAddressInfo`'
161+
)
162+
}
163+
if (toAddressInfo == null) return request
164+
165+
const { toPluginId, toTokenId, toAddress } = toAddressInfo
166+
if (toTokenId !== request.toTokenId) {
167+
throw new Error(
168+
'`toAddressInfo.toTokenId` must match the request `toTokenId`'
169+
)
170+
}
171+
if (ai.props.state.plugins.currency[toPluginId] == null) {
172+
throw new Error(
173+
`Cannot build swap destination: no currency plugin "${toPluginId}"`
174+
)
175+
}
176+
const currencyConfig = new CurrencyConfig(ai, accountId, toPluginId)
177+
if (toTokenId != null && currencyConfig.allTokens[toTokenId] == null) {
178+
throw new Error(
179+
`Cannot build swap destination: no token "${toTokenId}" on plugin "${toPluginId}"`
180+
)
181+
}
182+
183+
return {
184+
...request,
185+
toWallet: makeSyntheticDestinationWallet(currencyConfig, toAddress)
186+
}
187+
}
188+
132189
function wrapQuote(
133190
swapPlugins: EdgePluginMap<EdgeSwapPlugin>,
134191
request: EdgeSwapRequest,

src/core/swap/synthetic-wallet.ts

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

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: 34 additions & 2 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

@@ -1499,10 +1508,33 @@ export interface EdgeSwapInfo {
14991508
readonly supportEmail: string
15001509
}
15011510

1511+
/**
1512+
* An address-only swap destination, used in place of a destination
1513+
* `toWallet` for "swap-to-address" (e.g. private send). The core builds a
1514+
* synthetic destination wallet from this descriptor, backed by the real
1515+
* `currencyConfig` for `toPluginId`, so swap plugins need no changes.
1516+
*/
1517+
export interface EdgeSwapToAddressInfo {
1518+
toPluginId: string
1519+
toTokenId: EdgeTokenId
1520+
toAddress: string
1521+
}
1522+
15021523
export interface EdgeSwapRequest {
15031524
// Where?
15041525
fromWallet: EdgeCurrencyWallet
1505-
toWallet: EdgeCurrencyWallet
1526+
1527+
/**
1528+
* The destination wallet for a normal wallet-to-wallet swap.
1529+
* Provide exactly one of `toWallet` or `toAddressInfo`.
1530+
*/
1531+
toWallet?: EdgeCurrencyWallet
1532+
1533+
/**
1534+
* An address-only destination, as an alternative to `toWallet`.
1535+
* Provide exactly one of `toWallet` or `toAddressInfo`.
1536+
*/
1537+
toAddressInfo?: EdgeSwapToAddressInfo
15061538

15071539
// What?
15081540
fromTokenId: EdgeTokenId

0 commit comments

Comments
 (0)