diff --git a/CHANGELOG.md b/CHANGELOG.md index 308bb871e..aab8cda06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- added: `EdgeAccount.fetchSwapQuotes` accepts an `EdgeSwapSendRequest`, for swaps that pay out to an address. +- added: `EdgeTxActionSwapSend`, for sends that settle through a swap provider. +- added: `EdgeSwapRequest.privacy`, restricting quotes to sender-unlinkable routes. +- added: `EdgeSwapRequestOptions.forceEnabled`, for querying plugins the user switched off. +- added: `EdgeCurrencyConfig.parseUri`, for parsing a URI without a wallet. +- added: `EdgeParsedUri.addressTypes`, listing the address formats a parsed address matches. + ## 2.50.0 (2026-09-29) - added: `EdgeContextOptions.apiSigner`, for delegating API request signing to native code. diff --git a/src/core/account/account-api.ts b/src/core/account/account-api.ts index db756f55e..969f1bdd0 100644 --- a/src/core/account/account-api.ts +++ b/src/core/account/account-api.ts @@ -25,6 +25,7 @@ import { EdgeSwapQuote, EdgeSwapRequest, EdgeSwapRequestOptions, + EdgeSwapSendRequest, EdgeWalletInfo, EdgeWalletInfoFull, EdgeWalletStates @@ -871,7 +872,7 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { // ---------------------------------------------------------------- async fetchSwapQuote( - request: EdgeSwapRequest, + request: EdgeSwapRequest | EdgeSwapSendRequest, opts?: EdgeSwapRequestOptions ): Promise { const [bestQuote, ...otherQuotes] = await fetchSwapQuotes( @@ -892,7 +893,7 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { }, async fetchSwapQuotes( - request: EdgeSwapRequest, + request: EdgeSwapRequest | EdgeSwapSendRequest, opts?: EdgeSwapRequestOptions ): Promise { return await fetchSwapQuotes(ai, accountId, request, opts) diff --git a/src/core/account/custom-tokens.ts b/src/core/account/custom-tokens.ts index effc5b3d2..561a6d8f7 100644 --- a/src/core/account/custom-tokens.ts +++ b/src/core/account/custom-tokens.ts @@ -1,8 +1,10 @@ import { asMaybe, asObject, asString } from 'cleaners' +import { upgradeCurrencyCode } from '../../types/type-helpers' import { EdgeCurrencyEngine, EdgeMetaToken, + EdgeParsedUri, EdgePluginMap, EdgeToken, EdgeTokenInfo, @@ -118,6 +120,39 @@ export function makeMetaTokens(tokens: EdgeTokenMap = {}): EdgeMetaToken[] { return out } +/** + * Parses a URI with a plugin's currency tools on behalf of an account. + * The parser receives the account's custom tokens, and a result that + * names its asset only by the deprecated `currencyCode` gains a `tokenId`. + */ +export async function parseCurrencyUri( + ai: ApiInput, + accountId: string, + pluginId: string, + uri: string, + currencyCode?: string +): Promise { + const tools = await getCurrencyTools(ai, pluginId) + const { state } = ai.props + const { allTokens, customTokens } = state.accounts[accountId] + + const parsedUri = await tools.parseUri( + uri, + currencyCode, + makeMetaTokens(customTokens[pluginId]) + ) + + if (parsedUri.tokenId === undefined) { + const { tokenId = null } = upgradeCurrencyCode({ + allTokens: allTokens[pluginId], + currencyInfo: state.plugins.currency[pluginId].currencyInfo, + currencyCode: parsedUri.currencyCode ?? currencyCode + }) + parsedUri.tokenId = tokenId + } + return parsedUri +} + export function makeTokenInfo(token: EdgeToken): EdgeTokenInfo | undefined { const { currencyCode, displayName, denominations, networkLocation } = token const cleanLocation = asMaybeContractLocation(networkLocation) diff --git a/src/core/account/plugin-api.ts b/src/core/account/plugin-api.ts index 07845d345..da364898d 100644 --- a/src/core/account/plugin-api.ts +++ b/src/core/account/plugin-api.ts @@ -5,6 +5,7 @@ import { EdgeCurrencyInfo, EdgeGetTokenDetailsFilter, EdgeOtherMethods, + EdgeParsedUri, EdgeSwapConfig, EdgeSwapInfo, EdgeToken, @@ -14,7 +15,7 @@ import { uniqueStrings } from '../currency/wallet/enabled-tokens' import { getCurrencyTools } from '../plugins/plugins-selectors' import { ApiInput } from '../root-pixie' import { changePluginUserSettings, changeSwapSettings } from './account-files' -import { getTokenId } from './custom-tokens' +import { getTokenId, parseCurrencyUri } from './custom-tokens' const emptyTokens: EdgeTokenMap = {} const emptyTokenIds: string[] = [] @@ -223,6 +224,11 @@ export class CurrencyConfig const keys = await tools.importPrivateKey(userInput, opts.keyOptions) return { ...keys, imported: true } } + + async parseUri(uri: string, currencyCode?: string): Promise { + const { _accountId: accountId, _ai: ai, _pluginId: pluginId } = this + return await parseCurrencyUri(ai, accountId, pluginId, uri, currencyCode) + } } export class SwapConfig extends Bridgeable { diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index fb9b1a26b..257b8b464 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -46,7 +46,7 @@ import { JsonObject } from '../../../types/types' import { compare } from '../../../util/compare' -import { makeMetaTokens } from '../../account/custom-tokens' +import { makeMetaTokens, parseCurrencyUri } from '../../account/custom-tokens' import { splitWalletInfo } from '../../login/splitting' import { getCurrencyTools } from '../../plugins/plugins-selectors' import { RootProps, toApiInput } from '../../root-pixie' @@ -1033,24 +1033,7 @@ export function makeCurrencyWalletApi( ) }, async parseUri(uri: string, currencyCode?: string): Promise { - const tools = await getTools() - const parsedUri = await tools.parseUri( - uri, - currencyCode, - makeMetaTokens( - input.props.state.accounts[accountId].customTokens[pluginId] - ) - ) - - if (parsedUri.tokenId === undefined) { - const { tokenId = null } = upgradeCurrencyCode({ - allTokens: input.props.state.accounts[accountId].allTokens[pluginId], - currencyInfo: plugin.currencyInfo, - currencyCode: parsedUri.currencyCode ?? currencyCode - }) - parsedUri.tokenId = tokenId - } - return parsedUri + return await parseCurrencyUri(ai, accountId, pluginId, uri, currencyCode) }, // Generic: diff --git a/src/core/currency/wallet/currency-wallet-cleaners.ts b/src/core/currency/wallet/currency-wallet-cleaners.ts index c1c1b0db9..3f4fd167f 100644 --- a/src/core/currency/wallet/currency-wallet-cleaners.ts +++ b/src/core/currency/wallet/currency-wallet-cleaners.ts @@ -25,6 +25,7 @@ import { EdgeTxActionGiftCard, EdgeTxActionStake, EdgeTxActionSwap, + EdgeTxActionSwapSend, EdgeTxActionTokenApproval, EdgeTxSwap } from '../../../types/types' @@ -195,6 +196,19 @@ export const asEdgeTxActionSwap = asObject({ refundAddress: asOptional(asString) }) +export const asEdgeTxActionSwapSend = asObject({ + actionType: asValue('swapSend'), + swapInfo: asEdgeSwapInfo, + orderId: asOptional(asString), + orderUri: asOptional(asString), + isEstimate: asBoolean, + fromAsset: asEdgeAssetAmount, + toAsset: asEdgeAssetAmount, + payoutAddress: asString, + refundAddress: asOptional(asString), + privacy: asBoolean +}) + export const asEdgeTxActionStake = asObject({ actionType: asValue('stake'), pluginId: asString, @@ -257,6 +271,7 @@ export const asEdgeTxActionGiftCard = asObject({ export const asEdgeTxAction: Cleaner = asEither( asEdgeTxActionSwap, + asEdgeTxActionSwapSend, asEdgeTxActionStake, asEdgeTxActionFiat, asEdgeTxActionTokenApproval, diff --git a/src/core/swap/swap-api.ts b/src/core/swap/swap-api.ts index 87ccd20c3..6441096cb 100644 --- a/src/core/swap/swap-api.ts +++ b/src/core/swap/swap-api.ts @@ -14,10 +14,12 @@ import { EdgeSwapPlugin, EdgeSwapQuote, EdgeSwapRequest, - EdgeSwapRequestOptions + EdgeSwapRequestOptions, + EdgeSwapSendRequest } 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. @@ -25,11 +27,12 @@ import { ApiInput } from '../root-pixie' export async function fetchSwapQuotes( ai: ApiInput, accountId: string, - request: EdgeSwapRequest, + request: EdgeSwapRequest | EdgeSwapSendRequest, opts: EdgeSwapRequestOptions = {} ): Promise { const { disabled = {}, + forceEnabled = {}, noResponseMs, preferPluginId, promoCodes = {}, @@ -41,12 +44,36 @@ export async function fetchSwapQuotes( const { swapSettings, userSettings } = account const swapPlugins = state.plugins.swap + // Resolve the destination. A normal swap provides `toWallet`; a send + // request provides a destination address instead, and the core builds a + // synthetic destination wallet from it so plugins receive an + // `EdgeCurrencyWallet` unchanged. + const swapRequest = resolveSwapRequest(ai, accountId, request) + + // A send 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 send 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 = + 'toWallet' in request ? 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 }, { preferPluginId, promoCodes } ) @@ -57,13 +84,21 @@ 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] }) @@ -86,12 +121,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,10 +152,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) } ) @@ -129,11 +171,96 @@ 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 +} + +/** + * Resolves a swap request to the shape swap plugins take, which always carries + * a `toWallet`. A send request's destination address becomes a synthetic + * destination wallet, built core-side. + */ +function resolveSwapRequest( + ai: ApiInput, + accountId: string, + request: EdgeSwapRequest | EdgeSwapSendRequest +): EdgeSwapRequest { + if ('toWallet' in request) { + if ('toAddresses' in request) { + throw new Error('Swap request cannot have both toWallet and toAddresses') + } + return request + } + + const { toPluginId, toAddresses, toMemos, ...base } = request + const { toTokenId } = base + if (ai.props.state.plugins.currency[toPluginId] == null) { + throw new Error( + `Cannot build swap destination: no currency plugin "${toPluginId}"` + ) + } + if (toAddresses.length === 0) { + throw new Error('Cannot build swap destination: no address') + } + // 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 send 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}"` + ) + } + + return { + ...base, + toWallet: makeSyntheticDestinationWallet( + currencyConfig, + toAddresses, + toMemos + ) + } +} + +/** + * 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, 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({ canBePartial: quote.canBePartial, expirationDate: quote.expirationDate, @@ -152,7 +279,16 @@ function wrapQuote( }, async close() { - await quote.close() + // The release runs even when the plugin's own close throws, so a failed + // close cannot hold the shared destination open. + try { + await quote.close() + } finally { + if (!closed) { + closed = true + onClose() + } + } close(out) } }) diff --git a/src/core/swap/synthetic-wallet.ts b/src/core/swap/synthetic-wallet.ts new file mode 100644 index 000000000..a4098d19a --- /dev/null +++ b/src/core/swap/synthetic-wallet.ts @@ -0,0 +1,74 @@ +import { bridgifyObject } from 'yaob' + +import { + EdgeAddress, + EdgeCurrencyConfig, + EdgeCurrencyWallet, + EdgeMemo, + EdgeReceiveAddress +} from '../../types/types' + +/** + * A prefix marking a synthetic destination wallet's `id`. A send request's + * 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 send request. It is + * backed by the real `currencyConfig` core already holds, so `currencyInfo` + * and `currencyConfig.allTokens` are authentic, while `getAddresses` returns + * the caller's destination addresses verbatim and `getReceiveAddress` returns + * the first one (the payout 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, + toAddresses: EdgeAddress[], + toMemos: EdgeMemo[] = [] +): EdgeCurrencyWallet { + const { currencyInfo } = currencyConfig + + const receiveAddress: EdgeReceiveAddress = { + publicAddress: toAddresses[0].publicAddress, + metadata: {}, + nativeAmount: '0' + } + + const wallet = { + id: `${SYNTHETIC_WALLET_ID_PREFIX}${currencyInfo.pluginId}`, + type: currencyInfo.walletType, + currencyConfig, + currencyInfo, + + async getAddresses(): Promise { + return toAddresses + }, + + /** + * 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 { + return toMemos + }, + + 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), 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 +} diff --git a/src/types/types.ts b/src/types/types.ts index 0557724c3..399b539e1 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -305,6 +305,27 @@ export interface EdgeTxActionSwap { refundAddress?: string } +/** + * A send that settled through a swap provider: the payout went to an address + * the user entered, not to one of their own wallets. + */ +export interface EdgeTxActionSwapSend { + actionType: 'swapSend' + swapInfo: EdgeSwapInfo + orderId?: string + orderUri?: string + isEstimate: boolean + fromAsset: EdgeAssetAmount + toAsset: EdgeAssetAmount + + /** The recipient. */ + payoutAddress: string + refundAddress?: string + + /** Routed privately (a Stealth send). */ + privacy: boolean +} + export interface EdgeTxActionStake { actionType: 'stake' pluginId: string @@ -365,6 +386,7 @@ export interface EdgeTxActionGiftCard { export type EdgeTxAction = | EdgeTxActionSwap + | EdgeTxActionSwapSend | EdgeTxActionStake | EdgeTxActionFiat | EdgeTxActionTokenApproval @@ -864,6 +886,12 @@ export interface WalletConnect { } export interface EdgeParsedUri { + /** + * Every `getAddresses` label that applies to `publicAddress`, for chains + * with more than one address format. Absent means `publicAddress` only. + */ + addressTypes?: string[] + bitIDCallbackUri?: string bitIDDomain?: string bitidKycProvider?: string // Experimental @@ -1524,10 +1552,23 @@ export interface EdgeSwapInfo { readonly supportEmail: string } -export interface EdgeSwapRequest { +/** + * The extra surface a core-built synthetic destination wallet exposes on top + * of the `EdgeCurrencyWallet` members swap plugins normally read. Real + * wallets do not implement `getMemos`; plugins that support destination + * memos should detect it at runtime, such as: + * `const { getMemos } = toWallet as Partial` + */ +export interface EdgeSyntheticDestinationWallet extends EdgeCurrencyWallet { + readonly getMemos: () => Promise +} + +/** + * The fields every swap request shares, whatever its destination. + */ +export interface EdgeSwapRequestBase { // Where? fromWallet: EdgeCurrencyWallet - toWallet: EdgeCurrencyWallet // What? fromTokenId: EdgeTokenId @@ -1536,6 +1577,41 @@ export interface EdgeSwapRequest { // How much? nativeAmount: string quoteFor: 'from' | 'max' | 'to' + + /** + * Route privacy requirement. `'required'` means the quote must come from a + * route that keeps the sender unlinkable to the recipient. A plugin that + * cannot offer one must decline the request rather than answer with a + * transparent route, since a caller asking for privacy would otherwise get + * a downgrade it has no way to detect. Omitted means any route will do. + */ + privacy?: 'required' +} + +/** + * A swap between two wallets. This is also the resolved shape swap plugins + * receive: for an `EdgeSwapSendRequest`, `toWallet` is a synthetic destination + * wallet the core builds from the destination address. + */ +export interface EdgeSwapRequest extends EdgeSwapRequestBase { + toWallet: EdgeCurrencyWallet +} + +/** + * A swap that pays out to an address rather than one of the user's wallets. + */ +export interface EdgeSwapSendRequest extends EdgeSwapRequestBase { + toPluginId: string + + /** The destination addresses. The first entry is the payout address. */ + toAddresses: EdgeAddress[] + + /** + * Destination memos (e.g. an XRP destination tag) for memo-required payout + * chains. Swap plugins read them off the synthetic destination wallet's + * `getMemos` (see `EdgeSyntheticDestinationWallet`). + */ + toMemos?: EdgeMemo[] } /** @@ -1735,6 +1811,10 @@ export interface EdgeCurrencyConfig { userInput: string, opts?: { keyOptions?: JsonObject } ) => Promise + readonly parseUri: ( + uri: string, + currencyCode?: string + ) => Promise readonly otherMethods: EdgeOtherMethods } @@ -1761,6 +1841,15 @@ export interface EdgeSwapRequestOptions { disabled?: EdgePluginMap promoCodes?: EdgePluginMap + /** + * Plugins to query even when the user has switched them off in their swap + * settings. For a feature that is powered by one specific provider rather + * than by the swap aggregator, the provider toggle is not the user's answer + * about that feature, so the caller can opt out of it for a single request. + * `disabled` still wins: an explicitly disabled plugin stays disabled. + */ + forceEnabled?: EdgePluginMap + /** * If we have some quotes already, how long should we wait * for stragglers before we give up? Defaults to 20000ms. @@ -1944,11 +2033,11 @@ export interface EdgeAccount { // Swapping: readonly fetchSwapQuote: ( - request: EdgeSwapRequest, + request: EdgeSwapRequest | EdgeSwapSendRequest, opts?: EdgeSwapRequestOptions ) => Promise readonly fetchSwapQuotes: ( - request: EdgeSwapRequest, + request: EdgeSwapRequest | EdgeSwapSendRequest, opts?: EdgeSwapRequestOptions ) => Promise diff --git a/test/core/account/account.test.ts b/test/core/account/account.test.ts index e393fbd70..1ac7fd59d 100644 --- a/test/core/account/account.test.ts +++ b/test/core/account/account.test.ts @@ -4,6 +4,7 @@ import { describe, it } from 'mocha' import { EdgeAccount, + EdgeSwapSendRequest, EdgeWalletInfoFull, makeFakeEdgeWorld } from '../../../src/index' @@ -190,6 +191,37 @@ describe('account', function () { expect(await account.getDisplayPublicKey(walletId)).deep.equals('xpub') }) + it('parses a URI without a wallet', async function () { + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext(contextOptions) + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + const config = account.currencyConfig.fakecoin + + // The chain's own coin and a builtin token named by currency code: + expect(await config.parseUri('fakecoin:x')).deep.equals({ + publicAddress: 'x', + tokenId: null + }) + expect(await config.parseUri('fakecoin:x', 'TOKEN')).deep.equals({ + publicAddress: 'x', + tokenId: 'badf00d5' + }) + + // The parser sees the account's custom tokens: + const tokenId = await config.addCustomToken({ + currencyCode: 'CUSTOM', + displayName: 'Custom Token', + denominations: [{ multiplier: '1000', name: 'CUSTOM' }], + networkLocation: { contractAddress: '0xC0FFEE' } + }) + expect(await config.parseUri('fakecoin:x?token=CUSTOM')).deep.equals({ + publicAddress: 'x', + currencyCode: 'CUSTOM', + tokenId + }) + }) + it('change currency plugin settings', async function () { const world = await makeFakeEdgeWorld([fakeUser], quiet) const context = await world.makeEdgeContext(contextOptions) @@ -259,6 +291,39 @@ describe('account', function () { expect(config2.enabled).equals(true) }) + it('quotes a send request to an address', async function () { + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + ...contextOptions, + plugins: { ...plugins, fakeswap: true } + }) + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.swapConfig.fakeswap.changeUserSettings({ kycToken: 'x' }) + const fromWallet = await account.createCurrencyWallet('wallet:fakecoin') + + const request: EdgeSwapSendRequest = { + fromWallet, + fromTokenId: null, + toTokenId: null, + nativeAmount: '1', + quoteFor: 'from', + toPluginId: 'tulipcoin', + toAddresses: [{ addressType: 'publicAddress', publicAddress: 'there' }] + } + + // The fake plugin names the destination plugin it read off `toWallet`, + // so reaching it proves the core resolved the address to a wallet: + await expectRejection( + account.fetchSwapQuote(request), + 'SwapCurrencyError: Fake Swapper does not support fakecoin:null to tulipcoin:null' + ) + + await expectRejection( + account.fetchSwapQuote({ ...request, toWallet: fromWallet }), + 'Error: Swap request cannot have both toWallet and toAddresses' + ) + }) + it('change key state', async function () { const world = await makeFakeEdgeWorld([fakeUser], quiet) const context = await world.makeEdgeContext(contextOptions) diff --git a/test/core/currency/wallet/currency-wallet-cleaners.test.ts b/test/core/currency/wallet/currency-wallet-cleaners.test.ts new file mode 100644 index 000000000..13adc6ba9 --- /dev/null +++ b/test/core/currency/wallet/currency-wallet-cleaners.test.ts @@ -0,0 +1,49 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { asEdgeTxAction } from '../../../../src/core/currency/wallet/currency-wallet-cleaners' +import { EdgeTxActionSwapSend } from '../../../../src/types/types' + +describe('currency wallet cleaners', function () { + it('round-trips a swapSend action', function () { + const action: EdgeTxActionSwapSend = { + actionType: 'swapSend', + swapInfo: { + pluginId: 'fakeswap', + displayName: 'Fake Swap', + supportEmail: 'support@fakeswap.test' + }, + orderId: '1234', + isEstimate: true, + fromAsset: { pluginId: 'bitcoin', tokenId: null, nativeAmount: '1234' }, + toAsset: { pluginId: 'ethereum', tokenId: null, nativeAmount: '2345' }, + payoutAddress: '0x1234567890abcdef1234567890abcdef12345678', + privacy: true + } + + const clean = asEdgeTxAction(JSON.parse(JSON.stringify(action))) + expect(clean).deep.equals({ + orderUri: undefined, + refundAddress: undefined, + ...action, + swapInfo: { isDex: undefined, orderUri: undefined, ...action.swapInfo } + }) + }) + + it('rejects a swapSend action without a privacy flag', function () { + expect(() => + asEdgeTxAction({ + actionType: 'swapSend', + swapInfo: { + pluginId: 'fakeswap', + displayName: 'Fake Swap', + supportEmail: '' + }, + isEstimate: false, + fromAsset: { pluginId: 'bitcoin', tokenId: null }, + toAsset: { pluginId: 'ethereum', tokenId: null }, + payoutAddress: 'somewhere' + }) + ).to.throw() + }) +}) diff --git a/test/core/swap-quote-close.test.ts b/test/core/swap-quote-close.test.ts new file mode 100644 index 000000000..a553c1e60 --- /dev/null +++ b/test/core/swap-quote-close.test.ts @@ -0,0 +1,83 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { wrapQuote } from '../../src/core/swap/swap-api' +import { + EdgePluginMap, + EdgeSwapPlugin, + EdgeSwapQuote, + EdgeSwapRequest +} from '../../src/types/types' + +const swapInfo = { pluginId: 'fake', displayName: 'Fake', isDex: false } +const swapPlugins = { + fake: { swapInfo } +} as unknown as EdgePluginMap + +const makeQuote = (): EdgeSwapQuote => + ({ + pluginId: 'fake', + swapInfo, + fromNativeAmount: '1', + toNativeAmount: '1', + isEstimate: false, + async approve() { + throw new Error('not used') + }, + async close() {} + }) as unknown as EdgeSwapQuote + +const request = {} as unknown as EdgeSwapRequest + +describe('wrapQuote close', function () { + it('releases the shared destination once every quote is closed', async function () { + // The synthetic destination wallet is shared by every quote one + // `fetchSwapQuotes` call returns, so it may only be released after the + // last of them is closed. + let released = 0 + const release = (): void => { + released++ + } + const wrapped = [makeQuote(), makeQuote()].map(quote => + wrapQuote(swapPlugins, request, quote, release) + ) + + await wrapped[0].close() + expect(released).equals(1) + await wrapped[1].close() + expect(released).equals(2) + }) + + it('releases once per quote, however many times it is closed', async function () { + // The release is reference-counted, so a caller that closes the same quote + // twice must not decrement twice and free the shared wallet early. + let released = 0 + const wrapped = wrapQuote(swapPlugins, request, makeQuote(), () => { + released++ + }) + + await wrapped.close() + await wrapped.close() + expect(released).equals(1) + }) + + it('releases even when the plugin close throws', async function () { + let released = 0 + const quote: EdgeSwapQuote = { + ...makeQuote(), + async close() { + throw new Error('close failed') + } + } + const wrapped = wrapQuote(swapPlugins, request, quote, () => { + released++ + }) + + const error = await wrapped.close().then( + () => undefined, + (error: unknown) => error + ) + expect(error).instanceOf(Error) + expect(released).equals(1) + }) +}) diff --git a/test/core/swap.test.ts b/test/core/swap.test.ts index e7e02028e..0927faed8 100644 --- a/test/core/swap.test.ts +++ b/test/core/swap.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai' import { describe, it } from 'mocha' -import { sortQuotes } from '../../src/core/swap/swap-api' +import { isSwapPluginQueryable, sortQuotes } from '../../src/core/swap/swap-api' import { EdgeSwapInfo, EdgeSwapQuote, EdgeSwapRequest } from '../../src/index' const typeHack: any = {} @@ -109,3 +109,35 @@ describe('swap', function () { expect(getIds(sorted)).equals('switchain, changenow, godex, thorchain') }) }) + +describe('swap plugin selection', function () { + // The whole truth table, because the interesting cases are the corners: a + // caller must be able to reach a provider the user switched off, and must + // never be able to reach one it disabled itself in the same call. + const cases: Array<{ + enabled: boolean + forceEnabled: boolean + disabled: boolean + queryable: boolean + }> = [ + { enabled: true, forceEnabled: false, disabled: false, queryable: true }, + { enabled: true, forceEnabled: true, disabled: false, queryable: true }, + { enabled: false, forceEnabled: false, disabled: false, queryable: false }, + // The send scene's stealth path: Houdini powers the feature, so the swap + // setting does not get to switch the feature off. + { enabled: false, forceEnabled: true, disabled: false, queryable: true }, + { enabled: true, forceEnabled: false, disabled: true, queryable: false }, + // `disabled` beats `forceEnabled`. Stealth sends disable every other + // plugin and force-enable Houdini in the same call, so a bug here would + // let the aggregator answer a request that demanded one provider. + { enabled: true, forceEnabled: true, disabled: true, queryable: false }, + { enabled: false, forceEnabled: false, disabled: true, queryable: false }, + { enabled: false, forceEnabled: true, disabled: true, queryable: false } + ] + + for (const { queryable, ...flags } of cases) { + it(`${JSON.stringify(flags)} -> ${String(queryable)}`, function () { + expect(isSwapPluginQueryable(flags)).equals(queryable) + }) + } +}) diff --git a/test/core/synthetic-wallet.test.ts b/test/core/synthetic-wallet.test.ts new file mode 100644 index 000000000..004fac878 --- /dev/null +++ b/test/core/synthetic-wallet.test.ts @@ -0,0 +1,209 @@ +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 { + EdgeAddress, + EdgeCurrencyConfig, + EdgeCurrencyInfo, + EdgeCurrencyWallet, + EdgeMemo, + EdgeSwapSendRequest, + EdgeSyntheticDestinationWallet, + 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 PAYOUT_ADDRESSES: EdgeAddress[] = [ + { addressType: 'publicAddress', publicAddress: PAYOUT_ADDRESS } +] + +/** The destination fields of a send request, the only part the core reads. */ +type SendDestination = Pick< + EdgeSwapSendRequest, + 'toPluginId' | 'toAddresses' | 'toMemos' +> + +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 destination + * fields (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: ( + destination: SendDestination, + toTokenId: EdgeTokenId + ) => Promise +} + +/** + * Mirrors the core side of the production GUI<->core bridge: it receives the + * destination fields plus the request's `toTokenId`, builds the synthetic + * destination, and runs a consumer that makes exactly the reads swap plugins + * make on `toWallet`. + */ +class FakeCoreSwapApi extends Bridgeable implements CoreSwapApi { + async buildDestination( + destination: SendDestination, + toTokenId: EdgeTokenId + ): Promise { + const synthetic = makeSyntheticDestinationWallet( + currencyConfig, + destination.toAddresses, + destination.toMemos + ) + + // 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 an address across the bridge', async function () { + // The GUI passes ONLY the destination fields (plain data), the exact thing + // that crosses the bridge cleanly, unlike the Phase 1 GUI-built fake wallet. + const destination: SendDestination = { + toPluginId: 'ethereum', + toAddresses: PAYOUT_ADDRESSES + } + const proof = await guiApi.buildDestination(destination, 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', + toAddresses: PAYOUT_ADDRESSES + }, + null + ) + + expect(proof.consumer.address).equals(PAYOUT_ADDRESS) + expect(proof.consumer.tokenCurrencyCode).equals(undefined) + expect(proof.consumer.parentCurrencyCode).equals('ETH') + }) + + it('exposes destination memos through getMemos across the bridge', async function () { + const toMemos: EdgeMemo[] = [{ type: 'number', value: '8675309' }] + const proof = await guiApi.buildDestination( + { + toPluginId: 'ethereum', + toAddresses: PAYOUT_ADDRESSES, + toMemos + }, + null + ) + + // The synthetic exposes the request's memos to plugin-side readers, + // and the method still works across the yaob wire format: + const synthetic = proof.toWallet as EdgeSyntheticDestinationWallet + expect(await synthetic.getMemos()).deep.equals(toMemos) + }) + + it('returns no memos when the request has none', async function () { + const proof = await guiApi.buildDestination( + { toPluginId: 'ethereum', toAddresses: PAYOUT_ADDRESSES }, + null + ) + const synthetic = proof.toWallet as EdgeSyntheticDestinationWallet + expect(await synthetic.getMemos()).deep.equals([]) + }) + + it('returns every address verbatim and pays out to the first', async function () { + const toAddresses: EdgeAddress[] = [ + { addressType: 'segwitAddress', publicAddress: 'bc1qpayout' }, + { addressType: 'publicAddress', publicAddress: '1Payout' } + ] + const proof = await guiApi.buildDestination( + { toPluginId: 'ethereum', toAddresses }, + null + ) + + expect(proof.consumer.receiveAddress).equals('bc1qpayout') + expect(await proof.toWallet.getAddresses({ tokenId: null })).deep.equals( + toAddresses + ) + }) +}) diff --git a/test/fake/fake-currency-plugin.ts b/test/fake/fake-currency-plugin.ts index a4889f7d4..56b416bcf 100644 --- a/test/fake/fake-currency-plugin.ts +++ b/test/fake/fake-currency-plugin.ts @@ -12,6 +12,7 @@ import { EdgeFreshAddress, EdgeGetReceiveAddressOptions, EdgeGetTransactionsOptions, + EdgeMetaToken, EdgeParsedUri, EdgeSpendInfo, EdgeStakingStatus, @@ -530,8 +531,19 @@ class FakeCurrencyTools implements EdgeCurrencyTools { } // URI parsing: - parseUri(uri: string): Promise { - return Promise.resolve({}) + parseUri( + uri: string, + currencyCode?: string, + customTokens: EdgeMetaToken[] = [] + ): Promise { + // A `fakecoin:
?token=` URI names a custom token: + const [, publicAddress, tokenCode] = + /^fakecoin:([^?]*)(?:\?token=(.*))?$/.exec(uri) ?? [] + const token = customTokens.find(token => token.currencyCode === tokenCode) + return Promise.resolve({ + publicAddress, + ...(token == null ? {} : { currencyCode: token.currencyCode }) + }) } encodeUri(): Promise {