From 2afb0ac7d90ea5d3e28f267d44dadfadd43c2c02 Mon Sep 17 00:00:00 2001 From: William Swanson Date: Mon, 16 Oct 2023 12:24:26 -0700 Subject: [PATCH 1/2] Implement `fetchSwapQuotes` --- CHANGELOG.md | 6 +++- src/core/account/account-api.ts | 25 +++++++++++-- src/core/swap/swap-api.ts | 64 ++++++++++++++++----------------- src/types/types.ts | 4 +++ test/core/swap.test.ts | 25 +++++++------ 5 files changed, 78 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2d2db152..ee01258cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,12 @@ # edge-core-js +## Unreleased + +- added: `EdgeAccount.fetchSwapQuotes`, to return all relevant quotes, and not just the best one. + ## 1.10.0 (2023-10-10) -- added: EdgeTxAction types to tag known smart contract trasaction types (swap, stake, etc) +- added: `EdgeTransaction.action` to tag known smart contract transaction types (swap, stake, etc.). ## 1.9.0 (2023-10-10) diff --git a/src/core/account/account-api.ts b/src/core/account/account-api.ts index d394c4356..2eb954fee 100644 --- a/src/core/account/account-api.ts +++ b/src/core/account/account-api.ts @@ -56,7 +56,7 @@ import { import { ApiInput } from '../root-pixie' import { makeLocalDisklet } from '../storage/repo' import { makeStorageWalletApi } from '../storage/storage-api' -import { fetchSwapQuote } from '../swap/swap-api' +import { fetchSwapQuotes } from '../swap/swap-api' import { changeWalletStates } from './account-files' import { AccountState } from './account-reducer' import { makeDataStoreApi } from './data-store-api' @@ -625,7 +625,28 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { request: EdgeSwapRequest, opts?: EdgeSwapRequestOptions ): Promise { - return await fetchSwapQuote(ai, accountId, request, opts) + const [bestQuote, ...otherQuotes] = await fetchSwapQuotes( + ai, + accountId, + request, + opts + ) + + // Close unused quotes: + for (const otherQuote of otherQuotes) { + otherQuote.close().catch(() => undefined) + } + + // Return the front quote: + if (bestQuote == null) throw new Error('No swap providers enabled') + return bestQuote + }, + + async fetchSwapQuotes( + request: EdgeSwapRequest, + opts?: EdgeSwapRequestOptions + ): Promise { + return await fetchSwapQuotes(ai, accountId, request, opts) } } bridgifyObject(out) diff --git a/src/core/swap/swap-api.ts b/src/core/swap/swap-api.ts index 52b411b9f..c370aef25 100644 --- a/src/core/swap/swap-api.ts +++ b/src/core/swap/swap-api.ts @@ -17,14 +17,14 @@ import { fuzzyTimeout } from '../../util/promise' import { ApiInput } from '../root-pixie' /** - * Fetch quotes from all plugins, and pick the best one. + * Fetch quotes from all plugins, and sorts the best ones to the front. */ -export async function fetchSwapQuote( +export async function fetchSwapQuotes( ai: ApiInput, accountId: string, request: EdgeSwapRequest, opts: EdgeSwapRequestOptions = {} -): Promise { +): Promise { const { disabled = {}, preferPluginId, promoCodes = {} } = opts const { log } = ai.props @@ -94,7 +94,6 @@ export async function fetchSwapQuote( ) ) } - if (promises.length < 1) throw new Error('No swap providers enabled') // Wait for the results, with error handling: return fuzzyTimeout(promises, 20000).then( @@ -104,21 +103,22 @@ export async function fetchSwapQuote( } // Find the cheapest price: - const bestQuote = pickBestQuote(quotes, opts) + const sorted = sortQuotes(quotes, opts) log.warn( - `${promises.length} swap quotes requested, ${quotes.length} resolved, ${errors.length} failed, picked ${bestQuote.pluginId}.` + `${promises.length} swap quotes requested, ${quotes.length} resolved, ${ + errors.length + } failed, sorted ${sorted.map(quote => quote.pluginId).join(', ')}.` ) - // Close unused quotes: - for (const quote of quotes) { - if (quote !== bestQuote) quote.close().catch(() => undefined) - } - // @ts-expect-error - Here for backwards compatibility: - bestQuote.request = request - // @ts-expect-error - Here for backwards compatibility: - bestQuote.swapInfo = swapPlugins[bestQuote.pluginId].swapInfo + // Prepare quotes for the bridge: + return quotes.map(quote => { + // @ts-expect-error - Here for backwards compatibility: + quote.request = request + // @ts-expect-error - Here for backwards compatibility: + quote.swapInfo = swapPlugins[quote.pluginId].swapInfo - return bridgifyObject(bestQuote) + return bridgifyObject(quote) + }) }, (errors: unknown[]) => { log.warn(`All ${promises.length} swap quotes rejected.`) @@ -128,23 +128,21 @@ export async function fetchSwapQuote( } /** - * Picks the best quote out of the available choices. - * Exported so we can unit-test it. + * Sorts the best quotes first. */ - -export function pickBestQuote( +export function sortQuotes( quotes: EdgeSwapQuote[], opts: EdgeSwapRequestOptions -): EdgeSwapQuote { +): EdgeSwapQuote[] { const { preferPluginId, preferType, promoCodes } = opts - return quotes.reduce((a, b) => { + return quotes.sort((a, b) => { // Prioritize transfer plugin: - if (a.pluginId === 'transfer') return a - if (b.pluginId === 'transfer') return b + if (a.pluginId === 'transfer') return -1 + if (b.pluginId === 'transfer') return 1 // Always return quotes from the preferred provider: - if (a.pluginId === preferPluginId) return a - if (b.pluginId === preferPluginId) return b + if (a.pluginId === preferPluginId) return -1 + if (b.pluginId === preferPluginId) return 1 // Prefer based on plugin but always allow `transfer` plugins: if (preferType != null) { @@ -154,28 +152,30 @@ export function pickBestQuote( const bMatchesType = (b.swapInfo.isDex === true && preferType === 'DEX') || (b.swapInfo.isDex !== true && preferType === 'CEX') - if (aMatchesType && !bMatchesType) return a - if (!aMatchesType && bMatchesType) return b + if (aMatchesType && !bMatchesType) return -1 + if (!aMatchesType && bMatchesType) return 1 } // Prioritize providers with active promo codes: if (promoCodes != null) { const aHasPromo = promoCodes[a.pluginId] != null const bHasPromo = promoCodes[b.pluginId] != null - if (aHasPromo && !bHasPromo) return a - if (!aHasPromo && bHasPromo) return b + if (aHasPromo && !bHasPromo) return -1 + if (!aHasPromo && bHasPromo) return 1 } // Prioritize accurate quotes over estimates: const { isEstimate: aIsEstimate = true } = a const { isEstimate: bIsEstimate = true } = b - if (aIsEstimate && !bIsEstimate) return b - if (!aIsEstimate && bIsEstimate) return a + if (!aIsEstimate && bIsEstimate) return -1 + if (aIsEstimate && !bIsEstimate) return 1 // Prefer the best rate: const aRate = Number(a.toNativeAmount) / Number(a.fromNativeAmount) const bRate = Number(b.toNativeAmount) / Number(b.fromNativeAmount) - return bRate > aRate ? b : a + if (aRate > bRate) return -1 + if (bRate > aRate) return 1 + return 0 }) } diff --git a/src/types/types.ts b/src/types/types.ts index 1d3b066a6..51aa7db91 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1587,6 +1587,10 @@ export interface EdgeAccount { request: EdgeSwapRequest, opts?: EdgeSwapRequestOptions ) => Promise + readonly fetchSwapQuotes: ( + request: EdgeSwapRequest, + opts?: EdgeSwapRequestOptions + ) => Promise } // --------------------------------------------------------------------- diff --git a/test/core/swap.test.ts b/test/core/swap.test.ts index 6d081fdad..40929da24 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 { pickBestQuote } from '../../src/core/swap/swap-api' +import { sortQuotes } from '../../src/core/swap/swap-api' import { EdgeSwapInfo, EdgeSwapQuote, EdgeSwapRequest } from '../../src/index' const typeHack: any = {} @@ -77,32 +77,35 @@ const quotes: EdgeSwapQuote[] = [ ] describe('swap', function () { + const getIds = (quotes: EdgeSwapQuote[]): string => + quotes.map(quote => quote.pluginId).join(', ') + it('picks the best quote', function () { - const quote = pickBestQuote(quotes, {}) - expect(quote.pluginId).equals('changenow') + const sorted = sortQuotes(quotes, {}) + expect(getIds(sorted)).equals('changenow, godex, thorchain, switchain') }) it('picks the preferred swap provider', function () { - const quote = pickBestQuote(quotes, { preferPluginId: 'switchain' }) - expect(quote.pluginId).equals('switchain') + const sorted = sortQuotes(quotes, { preferPluginId: 'switchain' }) + expect(getIds(sorted)).equals('switchain, changenow, godex, thorchain') }) it('picks the preferred swap type DEX', function () { - const quote = pickBestQuote(quotes, { preferType: 'DEX' }) - expect(quote.pluginId).equals('thorchain') + const sorted = sortQuotes(quotes, { preferType: 'DEX' }) + expect(getIds(sorted)).equals('thorchain, changenow, godex, switchain') }) it('picks the preferred swap type CEX', function () { - const quote = pickBestQuote(quotes, { preferType: 'CEX' }) - expect(quote.pluginId).equals('changenow') + const sorted = sortQuotes(quotes, { preferType: 'CEX' }) + expect(getIds(sorted)).equals('changenow, godex, switchain, thorchain') }) it('picks the swap provider with an active promo code', function () { - const quote = pickBestQuote(quotes, { + const sorted = sortQuotes(quotes, { promoCodes: { switchain: 'deal10' } }) - expect(quote.pluginId).equals('switchain') + expect(getIds(sorted)).equals('switchain, changenow, godex, thorchain') }) }) From 3e4c3eafee3e1d660cb75626cb967ba1c0a18cd2 Mon Sep 17 00:00:00 2001 From: William Swanson Date: Mon, 16 Oct 2023 14:45:45 -0700 Subject: [PATCH 2/2] Correctly pass quotes over the bridge We weren't closing quotes correctly, so the objects would linger in the bridge. Also use `??` to avoid TypeScript errors. --- src/core/swap/swap-api.ts | 42 ++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/core/swap/swap-api.ts b/src/core/swap/swap-api.ts index c370aef25..f8de56558 100644 --- a/src/core/swap/swap-api.ts +++ b/src/core/swap/swap-api.ts @@ -1,5 +1,5 @@ import { gt, lt } from 'biggystring' -import { bridgifyObject } from 'yaob' +import { bridgifyObject, close } from 'yaob' import { upgradeCurrencyCode } from '../../types/type-helpers' import { @@ -9,6 +9,8 @@ import { asMaybeSwapBelowLimitError, asMaybeSwapCurrencyError, asMaybeSwapPermissionError, + EdgePluginMap, + EdgeSwapPlugin, EdgeSwapQuote, EdgeSwapRequest, EdgeSwapRequestOptions @@ -111,14 +113,7 @@ export async function fetchSwapQuotes( ) // Prepare quotes for the bridge: - return quotes.map(quote => { - // @ts-expect-error - Here for backwards compatibility: - quote.request = request - // @ts-expect-error - Here for backwards compatibility: - quote.swapInfo = swapPlugins[quote.pluginId].swapInfo - - return bridgifyObject(quote) - }) + return quotes.map(quote => wrapQuote(swapPlugins, request, quote)) }, (errors: unknown[]) => { log.warn(`All ${promises.length} swap quotes rejected.`) @@ -127,6 +122,35 @@ export async function fetchSwapQuotes( ) } +function wrapQuote( + swapPlugins: EdgePluginMap, + request: EdgeSwapRequest, + quote: EdgeSwapQuote +): EdgeSwapQuote { + const out = bridgifyObject({ + canBePartial: quote.canBePartial, + expirationDate: quote.expirationDate, + fromNativeAmount: quote.fromNativeAmount, + isEstimate: quote.isEstimate, + maxFulfillmentSeconds: quote.maxFulfillmentSeconds, + networkFee: quote.networkFee, + pluginId: quote.pluginId, + request: quote.request ?? request, + swapInfo: quote.swapInfo ?? swapPlugins[quote.pluginId].swapInfo, + toNativeAmount: quote.toNativeAmount, + + async approve(opts) { + return await quote.approve(opts) + }, + + async close() { + await quote.close() + close(out) + } + }) + return out +} + /** * Sorts the best quotes first. */