Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fetch swap quotes #570

Merged
merged 2 commits into from
Oct 18, 2023
Merged
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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
25 changes: 23 additions & 2 deletions src/core/account/account-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -625,7 +625,28 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount {
request: EdgeSwapRequest,
opts?: EdgeSwapRequestOptions
): Promise<EdgeSwapQuote> {
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<EdgeSwapQuote[]> {
return await fetchSwapQuotes(ai, accountId, request, opts)
}
}
bridgifyObject(out)
Expand Down
92 changes: 58 additions & 34 deletions src/core/swap/swap-api.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -9,6 +9,8 @@ import {
asMaybeSwapBelowLimitError,
asMaybeSwapCurrencyError,
asMaybeSwapPermissionError,
EdgePluginMap,
EdgeSwapPlugin,
EdgeSwapQuote,
EdgeSwapRequest,
EdgeSwapRequestOptions
Expand All @@ -17,14 +19,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<EdgeSwapQuote> {
): Promise<EdgeSwapQuote[]> {
const { disabled = {}, preferPluginId, promoCodes = {} } = opts
const { log } = ai.props

Expand Down Expand Up @@ -94,7 +96,6 @@ export async function fetchSwapQuote(
)
)
}
if (promises.length < 1) throw new Error('No swap providers enabled')

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this error removed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's moved to the outer fetchSwapQuote function. Now that fetchSwapQuotes returns an array, we return an empty array if there are no enabled plugins.

// Wait for the results, with error handling:
return fuzzyTimeout(promises, 20000).then(
Expand All @@ -104,21 +105,15 @@ 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

return bridgifyObject(bestQuote)
// Prepare quotes for the bridge:
return quotes.map(quote => wrapQuote(swapPlugins, request, quote))
},
(errors: unknown[]) => {
log.warn(`All ${promises.length} swap quotes rejected.`)
Expand All @@ -127,24 +122,51 @@ export async function fetchSwapQuote(
)
}

function wrapQuote(
swapPlugins: EdgePluginMap<EdgeSwapPlugin>,
request: EdgeSwapRequest,
quote: EdgeSwapQuote
): EdgeSwapQuote {
const out = bridgifyObject<EdgeSwapQuote>({
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
}

/**
* 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) {
Expand All @@ -154,28 +176,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
})
}

Expand Down
4 changes: 4 additions & 0 deletions src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1587,6 +1587,10 @@ export interface EdgeAccount {
request: EdgeSwapRequest,
opts?: EdgeSwapRequestOptions
) => Promise<EdgeSwapQuote>
readonly fetchSwapQuotes: (
request: EdgeSwapRequest,
opts?: EdgeSwapRequestOptions
) => Promise<EdgeSwapQuote[]>
}

// ---------------------------------------------------------------------
Expand Down
25 changes: 14 additions & 11 deletions test/core/swap.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {}
Expand Down Expand Up @@ -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')
})
})
Loading