diff --git a/CHANGELOG.md b/CHANGELOG.md index 4237f0d0..b4c1ed6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - added: `AGENTS.md` and `.cursor/BUGBOT.md`, recording the swap plugin conventions that recent provider reviews settled. - added: A test suite for the swap plugin template, covering the max-quote probe, limit handling, the trust boundary on provider amounts, and memo cleaning. A new integration can copy it alongside the template. +- added: StealthEX swap plugin, using fixed rates where the route offers them and falling back to floating rates otherwise. - changed: The swap plugin template now models the max-quote probe, the trust boundary on provider-returned amounts, integer native-amount rounding, and funds-safe memo cleaning, so a new integration starts from them rather than rediscovering them in review. - changed: `docs/CREATING_AN_EXCHANGE_PLUGIN.md` covers max quotes and amount rounding in depth, and ends with a pre-PR checklist drawn from findings on shipped integrations. diff --git a/src/index.ts b/src/index.ts index 70355588..90a5a937 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { makeLetsExchangePlugin } from './swap/central/letsexchange' import { makeNexchangePlugin } from './swap/central/nexchange' import { makeNymPlugin } from './swap/central/nym' import { makeSideshiftPlugin } from './swap/central/sideshift' +import { makeStealthexPlugin } from './swap/central/stealthex' import { makeSwapterPlugin } from './swap/central/swapter' import { makeSwapuzPlugin } from './swap/central/swapuz' import { makeXgramPlugin } from './swap/central/xgram' @@ -47,6 +48,7 @@ const plugins = { rango: makeRangoPlugin, sideshift: makeSideshiftPlugin, spookySwap: makeSpookySwapPlugin, + stealthex: makeStealthexPlugin, swapter: makeSwapterPlugin, swapuz: makeSwapuzPlugin, mayaprotocol: makeMayaProtocolPlugin, diff --git a/src/mappings/stealthex.ts b/src/mappings/stealthex.ts new file mode 100644 index 00000000..78e242f6 --- /dev/null +++ b/src/mappings/stealthex.ts @@ -0,0 +1,315 @@ +/** + * StealthEX Exchange Plugin Chain Mapping + * + * See https://api.stealthex.io/docs/ for the API reference. To list the + * supported assets: + * `curl -X GET 'https://api.stealthex.io/v4/currencies?limit=250&offset=0' -H 'Authorization: Bearer ' | jq .` + * + * StealthEX identifies every asset by a `symbol` plus a `network`. A chain's + * native asset usually sits on the `mainnet` network, but plenty of chains are + * listed under their own network instead (BNB on `bsc`, AVAX on `avax-c`, ETH + * on each L2's network), so both halves are spelled out per chain. Tokens use + * the chain's `tokenNetwork` together with the token's own symbol. + * + * This file maps EdgeCurrencyPluginId -> StealthEX chain identity (or null when + * StealthEX does not list the chain). + */ + +import { EdgeCurrencyPluginId } from '../util/edgeCurrencyPluginIds' + +export interface StealthexChain { + /** StealthEX symbol of the chain's native asset */ + mainnetSymbol: string + /** StealthEX network hosting the chain's native asset */ + mainnetNetwork: string + /** StealthEX network hosting the chain's tokens, or null when unsupported */ + tokenNetwork: string | null +} + +export const stealthex = new Map() +stealthex.set('abstract', null) +stealthex.set('algorand', { + mainnetSymbol: 'algo', + mainnetNetwork: 'mainnet', + tokenNetwork: 'algo' +}) +stealthex.set('amoy', null) +stealthex.set('arbitrum', { + mainnetSymbol: 'eth', + mainnetNetwork: 'arbitrum', + tokenNetwork: 'arbitrum' +}) +stealthex.set('avalanche', { + mainnetSymbol: 'avax', + mainnetNetwork: 'avax-c', + tokenNetwork: 'avax-c' +}) +stealthex.set('axelar', { + mainnetSymbol: 'axl', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('badcoin', null) +stealthex.set('base', { + mainnetSymbol: 'eth', + mainnetNetwork: 'base', + tokenNetwork: 'base' +}) +// StealthEX lists no BEP2 assets: +stealthex.set('binance', null) +stealthex.set('binancesmartchain', { + mainnetSymbol: 'bnb', + mainnetNetwork: 'bsc', + tokenNetwork: 'bsc' +}) +stealthex.set('bitcoin', { + mainnetSymbol: 'btc', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('bitcoincash', { + mainnetSymbol: 'bch', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('bitcoincashtestnet', null) +stealthex.set('bitcoingold', null) +stealthex.set('bitcoingoldtestnet', null) +stealthex.set('bitcoinsv', { + mainnetSymbol: 'bsv', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('bitcointestnet', null) +stealthex.set('bitcointestnet4', null) +stealthex.set('bobevm', null) +stealthex.set('botanix', null) +stealthex.set('calibration', null) +stealthex.set('cardano', { + mainnetSymbol: 'ada', + mainnetNetwork: 'mainnet', + tokenNetwork: 'ada' +}) +stealthex.set('cardanotestnet', null) +stealthex.set('celo', { + mainnetSymbol: 'celo', + mainnetNetwork: 'mainnet', + tokenNetwork: 'celo' +}) +stealthex.set('coreum', null) +stealthex.set('cosmoshub', { + mainnetSymbol: 'atom', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('dash', { + mainnetSymbol: 'dash', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('digibyte', { + mainnetSymbol: 'dgb', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('dogecoin', { + mainnetSymbol: 'doge', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('eboost', null) +stealthex.set('ecash', { + mainnetSymbol: 'xec', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +// EOS was rebranded to Vaulta, and StealthEX lists it under its new `a` symbol: +stealthex.set('eos', { + mainnetSymbol: 'a', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('ethDev', null) +stealthex.set('ethereum', { + mainnetSymbol: 'eth', + mainnetNetwork: 'mainnet', + tokenNetwork: 'eth' +}) +stealthex.set('ethereumclassic', { + mainnetSymbol: 'etc', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('ethereumpow', { + mainnetSymbol: 'ethw', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +// StealthEX delisted FTM when Fantom became Sonic: +stealthex.set('fantom', null) +stealthex.set('feathercoin', null) +stealthex.set('filecoin', { + mainnetSymbol: 'fil', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +// StealthEX only supports Filecoin's native address space: +stealthex.set('filecoinfevm', null) +stealthex.set('filecoinfevmcalibration', null) +stealthex.set('fio', { + mainnetSymbol: 'fio', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('groestlcoin', null) +stealthex.set('hedera', { + mainnetSymbol: 'hbar', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('holesky', null) +stealthex.set('hyperevm', { + mainnetSymbol: 'hype', + mainnetNetwork: 'hyperevm', + tokenNetwork: 'hyperevm' +}) +stealthex.set('liberland', null) +stealthex.set('liberlandtestnet', null) +stealthex.set('litecoin', { + mainnetSymbol: 'ltc', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('mayachain', null) +// StealthEX's `monad` listing carries no address format, so the address space it +// expects cannot be confirmed: +stealthex.set('monad', null) +stealthex.set('monero', { + mainnetSymbol: 'xmr', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +// StealthEX only lists NYM as an Ethereum token, not the Nyx chain: +stealthex.set('nym', null) +stealthex.set('opbnb', null) +stealthex.set('optimism', { + mainnetSymbol: 'eth', + mainnetNetwork: 'optimism', + tokenNetwork: 'optimism' +}) +stealthex.set('osmosis', { + mainnetSymbol: 'osmo', + mainnetNetwork: 'cosmos', + tokenNetwork: null +}) +stealthex.set('piratechain', { + mainnetSymbol: 'arrr', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('pivx', { + mainnetSymbol: 'pivx', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('polkadot', { + mainnetSymbol: 'dot', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('polygon', { + mainnetSymbol: 'pol', + mainnetNetwork: 'mainnet', + tokenNetwork: 'matic' +}) +stealthex.set('pulsechain', { + mainnetSymbol: 'pls', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('qtum', { + mainnetSymbol: 'qtum', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('ravencoin', null) +stealthex.set('ripple', { + mainnetSymbol: 'xrp', + mainnetNetwork: 'mainnet', + tokenNetwork: 'xrp' +}) +stealthex.set('rsk', null) +stealthex.set('sepolia', null) +stealthex.set('smartcash', null) +stealthex.set('solana', { + mainnetSymbol: 'sol', + mainnetNetwork: 'mainnet', + tokenNetwork: 'sol' +}) +stealthex.set('sonic', { + mainnetSymbol: 's', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('stellar', { + mainnetSymbol: 'xlm', + mainnetNetwork: 'mainnet', + tokenNetwork: 'xlm' +}) +stealthex.set('sui', { + mainnetSymbol: 'sui', + mainnetNetwork: 'mainnet', + tokenNetwork: 'sui' +}) +stealthex.set('suitestnet', null) +// StealthEX's `tlos` listing carries no address format, so whether it expects +// native Telos or Telos EVM addresses cannot be confirmed: +stealthex.set('telos', null) +stealthex.set('tezos', { + mainnetSymbol: 'xtz', + mainnetNetwork: 'mainnet', + tokenNetwork: 'xtz' +}) +stealthex.set('thorchainrune', { + mainnetSymbol: 'rune', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('thorchainrunestagenet', null) +stealthex.set('ton', { + mainnetSymbol: 'ton', + mainnetNetwork: 'mainnet', + tokenNetwork: 'ton' +}) +stealthex.set('tron', { + mainnetSymbol: 'trx', + mainnetNetwork: 'mainnet', + tokenNetwork: 'trx' +}) +stealthex.set('ufo', null) +stealthex.set('vertcoin', null) +stealthex.set('wax', { + mainnetSymbol: 'waxp', + mainnetNetwork: 'wax', + tokenNetwork: null +}) +stealthex.set('zano', { + mainnetSymbol: 'zano', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('zcash', { + mainnetSymbol: 'zec', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('zcoin', { + mainnetSymbol: 'firo', + mainnetNetwork: 'mainnet', + tokenNetwork: null +}) +stealthex.set('zksync', { + mainnetSymbol: 'eth', + mainnetNetwork: 'zksync', + tokenNetwork: 'zksync' +}) diff --git a/src/swap/central/stealthex.ts b/src/swap/central/stealthex.ts new file mode 100644 index 00000000..46838f00 --- /dev/null +++ b/src/swap/central/stealthex.ts @@ -0,0 +1,863 @@ +import { ceil, floor, gt, lt } from 'biggystring' +import { + asArray, + asDate, + asEither, + asJSON, + asMaybe, + asNull, + asNumber, + asObject, + asOptional, + asString +} from 'cleaners' +import { + EdgeCorePluginOptions, + EdgeCurrencyWallet, + EdgeMemo, + EdgeSpendInfo, + EdgeSwapInfo, + EdgeSwapPlugin, + EdgeSwapQuote, + EdgeSwapRequest, + EdgeToken, + EdgeTokenId, + SwapAboveLimitError, + SwapBelowLimitError, + SwapCurrencyError, + SwapPermissionError +} from 'edge-core-js/types' + +import { + stealthex as stealthexMapping, + StealthexChain +} from '../../mappings/stealthex' +import { EdgeCurrencyPluginId } from '../../util/edgeCurrencyPluginIds' +import { + checkInvalidTokenIds, + ensureInFuture, + getMaxSwappable, + InvalidTokenIds, + makeSwapPluginQuote, + SwapOrder +} from '../../util/swapHelpers' +import { + convertRequest, + denominationToNative, + getAddress, + memoType, + nativeToDenomination +} from '../../util/utils' +import { asNumberString, EdgeSwapRequestPlugin, StringMap } from '../types' + +const pluginId = 'stealthex' + +export const swapInfo: EdgeSwapInfo = { + pluginId, + isDex: false, + displayName: 'StealthEX', + supportEmail: 'support@stealthex.io' +} + +const asInitOptions = asObject({ + apiKey: asString +}) + +const API_BASE_URL = 'https://api.stealthex.io/v4' +const ORDER_BASE_URL = 'https://stealthex.io/exchange/?id=' + +const addressTypeMap: StringMap = { + zcash: 'transparentAddress' +} + +/** StealthEX identifies an asset by its symbol on a specific network */ +interface StealthexAsset { + symbol: string + network: string +} + +type StealthexRate = 'fixed' | 'floating' + +/** + * `reversed` estimation quotes by the amount the user receives, and StealthEX + * only offers it on fixed rate routes. + */ +type StealthexEstimation = 'direct' | 'reversed' + +/** + * One asset in StealthEX's catalog. `rates` is cleaned as plain strings so a + * rate type StealthEX adds later cannot invalidate every listing. + */ +const asStealthexCurrency = asObject({ + symbol: asString, + network: asString, + rates: asArray(asString), + contract_address: asEither(asString, asNull) +}) +type StealthexCurrency = ReturnType + +/** A malformed listing drops itself rather than the whole catalog page */ +const asStealthexCurrencies = asArray(asMaybe(asStealthexCurrency)) + +/** The body both `/rates/estimated-amount` and `/exchanges` are built on */ +interface StealthexEstimateBody { + route: { from: StealthexAsset; to: StealthexAsset } + estimation: StealthexEstimation + rate: StealthexRate + amount: number +} + +const asStealthexRange = asObject({ + min_amount: asNumberString, + max_amount: asOptional(asNumberString) +}) + +const asStealthexEstimate = asObject({ + estimated_amount: asNumberString, + // Only fixed rate estimates come with a rate to lock in: + rate: asOptional(asObject({ id: asString })) +}) + +/** + * A deposit memo / destination tag. StealthEX documents it as a string, but a + * numeric tag (XRP, and other tag chains) would arrive as a JSON number, and a + * cleaner that only accepts strings would fail the whole order response. An + * absent or blank value means the chain takes no memo. + */ +const asStealthexExtraId = asOptional(asEither(asString, asNumber, asNull)) + +const asStealthexExchange = asObject({ + id: asString, + deposit: asObject({ + expected_amount: asNumberString, + address: asString, + extra_id: asStealthexExtraId + }), + withdrawal: asObject({ + expected_amount: asNumberString + }), + created_at: asDate, + expires_at: asOptional(asDate) +}) + +/** + * Every StealthEX failure comes back as `{err: {kind, details}}`, and the kind + * is what distinguishes a bad pair from a bad amount. + */ +const asStealthexError = asMaybe( + asJSON(asObject({ err: asObject({ kind: asString, details: asString }) })) +) + +/** Error kinds that mean StealthEX cannot swap this pair at all */ +const NO_ROUTE_KINDS = [ + 'NoPair', + 'NoExchangeRoute', + 'RouteIsDisabled', + 'MarketUnavailable' +] + +const INVALID_TOKEN_IDS: InvalidTokenIds = { + from: {}, + to: {} +} + +/** Fixed rate orders expire on their own, so a floating order needs a window */ +const FLOATING_EXPIRATION_MS = 1000 * 60 * 20 + +/** `/currencies` pages at 250 listings, and the catalog runs about 1000 */ +const CATALOG_PAGE_SIZE = 250 +const CATALOG_MAX_PAGES = 40 +/** Pages fetched at once after the first, so a quote is not stuck serially */ +const CATALOG_PAGE_BATCH = 4 +/** Rebuilds of a token index racing a catalog refresh before serving uncached */ +const TOKEN_INDEX_ATTEMPTS = 3 +const CATALOG_CACHE_MS = 1000 * 60 * 60 + +export function makeStealthexPlugin( + opts: EdgeCorePluginOptions +): EdgeSwapPlugin { + const { io, log } = opts + // StealthEX blocks browser-origin requests via CORS, and swap plugins run + // inside a WebView: + const { fetchCors = io.fetch } = io + const { apiKey } = asInitOptions(opts.initOptions) + + const headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${apiKey}` + } + + /** + * StealthEX's whole asset catalog, indexed by provider network. It carries + * each listing's symbol, contract address and supported rate types, which is + * everything a quote needs to identify an asset. + */ + let catalog = new Map() + let catalogUpdated = 0 + let catalogErrorKind: string | undefined + let catalogFetch: Promise | undefined + + /** + * Edge tokenId to catalog listing, per `pluginId:network`, derived from + * `catalog`. Canonicalizing a contract address costs a call into the currency + * plugin and one network carries hundreds of listings, so a derived index + * lives until the catalog it came from is replaced. + */ + const tokenIndexes = new Map< + string, + { stamp: number; byTokenId: Map } + >() + + /** + * Returns the parsed JSON body, or the StealthEX error kind when the request + * failed with a recognizable error envelope. + */ + const fetchStealthex = async ( + path: string, + body?: unknown + ): Promise<{ json?: unknown; errorKind?: string }> => { + const response = await fetchCors(`${API_BASE_URL}${path}`, { + headers, + method: body == null ? 'GET' : 'POST', + body: body == null ? undefined : JSON.stringify(body) + }) + const text = await response.text() + + if (!response.ok) { + log.warn(`StealthEX ${path} returned ${response.status}: ${text}`) + const error = asStealthexError(text) + if (error != null) return { errorKind: error.err.kind } + throw new Error(`StealthEX returned error code ${response.status}`) + } + + try { + return { json: JSON.parse(text) } + } catch (error: unknown) { + throw new Error(`StealthEX returned invalid JSON: ${text}`) + } + } + + /** Turns an unexpected error kind into an error worth logging */ + const unexpectedError = (path: string, errorKind: string): Error => + new Error(`StealthEX ${path} failed with ${errorKind}`) + + /** + * Maps a StealthEX error kind onto the Edge error it actually means. Every + * call site shares this so the same kind cannot mean an unsupported pair on + * one path and a generic plugin failure on another. + */ + const stealthexError = ( + path: string, + errorKind: string, + request: EdgeSwapRequestPlugin + ): Error => { + if (NO_ROUTE_KINDS.includes(errorKind)) { + return new SwapCurrencyError(swapInfo, request) + } + // StealthEX does not say what it is refusing, so do not claim a reason: + if (errorKind === 'NotAllowed') return new SwapPermissionError(swapInfo) + return unexpectedError(path, errorKind) + } + + /** + * Refreshes the catalog once its TTL has lapsed. StealthEX renames listings, + * delists assets, corrects contract addresses and changes which pairs support + * fixed rates, so a catalog cached for the life of the app eventually quotes + * assets that no longer exist. + * + * The new catalog replaces the old one only after every page arrived and the + * result is non-empty. A failed or empty refresh keeps the previous good + * catalog and leaves the stamp expired, so the next quote retries instead of + * caching an outage for the full TTL. + */ + const refreshCatalog = async (): Promise => { + catalogErrorKind = undefined + + const fetchPage = async ( + page: number + ): Promise<{ listings: StealthexCurrency[]; full: boolean }> => { + const offset = page * CATALOG_PAGE_SIZE + const path = `/currencies?limit=${CATALOG_PAGE_SIZE}&offset=${offset}` + const { json, errorKind } = await fetchStealthex(path) + if (errorKind != null || json == null) { + catalogErrorKind = errorKind + throw unexpectedError(path, errorKind ?? 'no response body') + } + // A malformed listing drops itself rather than the whole page, so the + // raw count, not the kept count, says whether more pages follow: + const cleaned = asStealthexCurrencies(json) + return { + listings: cleaned.filter( + (currency): currency is StealthexCurrency => currency != null + ), + full: cleaned.length === CATALOG_PAGE_SIZE + } + } + + const out = new Map() + let count = 0 + const add = (listings: StealthexCurrency[]): void => { + for (const currency of listings) { + const network = out.get(currency.network) ?? [] + network.push(currency) + out.set(currency.network, network) + ++count + } + } + + try { + // The first page reports whether there is anything to page through, and + // the rest go out together: the catalog runs to about a thousand + // listings, and fetching five pages one after another makes the first + // quote of the hour wait on five round trips. + let more = await fetchPage(0) + add(more.listings) + let page = 1 + while (more.full && page < CATALOG_MAX_PAGES) { + const batch = [] + for ( + let i = 0; + i < CATALOG_PAGE_BATCH && page + i < CATALOG_MAX_PAGES; + ++i + ) { + batch.push(fetchPage(page + i)) + } + const pages = await Promise.all(batch) + for (const fetched of pages) add(fetched.listings) + page += pages.length + more = pages[pages.length - 1] + } + } catch (error: unknown) { + log.warn('StealthEX: could not update the currency catalog', error) + return + } + + if (count === 0) { + log.warn('StealthEX: the currency catalog came back empty') + return + } + catalog = out + catalogUpdated = Date.now() + } + + /** Refreshes the catalog on a lapsed TTL, one refresh per set of callers */ + const updateCatalog = async (): Promise => { + if (catalogUpdated > Date.now() - CATALOG_CACHE_MS) return + if (catalogFetch == null) { + catalogFetch = refreshCatalog().finally(() => { + catalogFetch = undefined + }) + } + await catalogFetch + } + + /** Canonicalizes one catalog snapshot's contracts into an Edge tokenId map */ + const buildTokenIndex = async ( + wallet: EdgeCurrencyWallet, + listings: StealthexCurrency[] + ): Promise> => { + const byTokenId = new Map() + for (const currency of listings) { + const contractAddress = currency.contract_address + if (contractAddress == null) continue + + const token: EdgeToken = { + currencyCode: 'FAKE', + denominations: [{ name: 'FAKE', multiplier: '1' }], + displayName: 'FAKE', + networkLocation: { contractAddress } + } + try { + const tokenId = await wallet.currencyConfig.getTokenId(token) + if (tokenId != null) byTokenId.set(tokenId, currency) + } catch (error: unknown) { + // Not an address this chain recognizes, so not this chain's token + } + } + return byTokenId + } + + /** + * Builds, or reuses, the `tokenId` to listing index for one wallet's chain on + * one StealthEX network. Provider contract addresses are canonicalized + * through the wallet's own currency plugin, so casing and formatting + * differences cannot cause a false mismatch, and a listing whose + * `contract_address` is not a real address on this chain drops out. + */ + const getTokenIndex = async ( + wallet: EdgeCurrencyWallet, + network: string + ): Promise> => { + const key = `${wallet.currencyInfo.pluginId}:${network}` + + // Each pass builds from ONE catalog snapshot and only caches the result if + // that snapshot is still current: canonicalizing a contract awaits the + // currency plugin, and a refresh landing during those awaits would + // otherwise stamp an index built from the old catalog as if it came from + // the new one, freezing stale symbol/network rows in for the whole TTL. + for (let attempt = 0; attempt < TOKEN_INDEX_ATTEMPTS; ++attempt) { + const cached = tokenIndexes.get(key) + if (cached != null && cached.stamp === catalogUpdated) { + return cached.byTokenId + } + + const stamp = catalogUpdated + const listings = catalog.get(network) ?? [] + const byTokenId = await buildTokenIndex(wallet, listings) + + if (catalogUpdated !== stamp) continue + tokenIndexes.set(key, { stamp, byTokenId }) + return byTokenId + } + + // The catalog kept moving underneath, so serve this quote from the current + // snapshot without caching an index that may already be behind: + return await buildTokenIndex(wallet, catalog.get(network) ?? []) + } + + /** + * The catalog listing for a wallet's asset, resolved by contract address + * rather than by ticker. Symbols are not unique across listings and StealthEX + * does not always spell a token the way Edge does, so a ticker match is both + * unsafe and needlessly narrow. Returns undefined when StealthEX does not + * list the asset. + */ + const resolveListing = async ( + wallet: EdgeCurrencyWallet, + tokenId: EdgeTokenId, + chain: StealthexChain + ): Promise => { + if (tokenId == null) { + // A native asset, so the listing must carry no contract address either: + return catalog + .get(chain.mainnetNetwork) + ?.find( + currency => + currency.symbol === chain.mainnetSymbol && + currency.contract_address == null + ) + } + + const { tokenNetwork } = chain + if (tokenNetwork == null) return undefined + const byTokenId = await getTokenIndex(wallet, tokenNetwork) + return byTokenId.get(tokenId) + } + + /** + * Everything a swap needs BEFORE an order exists: the route, the rate type, + * the amount check and the estimate, plus both wallets' addresses. It creates + * nothing, which is what lets the max probe run it against the raw balance. + * + * `enforceMax` is false ONLY for the probe. That call deliberately quotes the + * whole pre-fee balance, so an above-limit balance must not abort it: a max + * swap that fits once the network fee is subtracted would never get priced. + * See `checkLimits` for what happens instead. + */ + const fetchQuote = async ( + request: EdgeSwapRequestPlugin, + enforceMax: boolean + ): Promise<{ + rate: StealthexRate + estimate: ReturnType + estimateBody: StealthexEstimateBody + fromAddress: string + toAddress: string + }> => { + const { fromWallet, toWallet, fromTokenId, toTokenId, quoteFor } = request + + const fromChain = stealthexMapping.get( + fromWallet.currencyInfo.pluginId as EdgeCurrencyPluginId + ) + const toChain = stealthexMapping.get( + toWallet.currencyInfo.pluginId as EdgeCurrencyPluginId + ) + if (fromChain == null || toChain == null) { + throw new SwapCurrencyError(swapInfo, request) + } + + await updateCatalog() + if (catalog.size === 0) { + // A catalog outage is a provider failure, not an unsupported pair. Saying + // otherwise would drop StealthEX out of the quote race silently and tell + // the user the pair does not exist. + if (catalogErrorKind === 'NotAllowed') + throw new SwapPermissionError(swapInfo) + throw new Error( + `StealthEX currency catalog unavailable: ${ + catalogErrorKind ?? 'no catalog' + }` + ) + } + + const [fromCurrency, toCurrency] = await Promise.all([ + resolveListing(fromWallet, fromTokenId, fromChain), + resolveListing(toWallet, toTokenId, toChain) + ]) + if (fromCurrency == null || toCurrency == null) { + throw new SwapCurrencyError(swapInfo, request) + } + const route: StealthexEstimateBody['route'] = { + from: { symbol: fromCurrency.symbol, network: fromCurrency.network }, + to: { symbol: toCurrency.symbol, network: toCurrency.network } + } + + // A `to` quote asks StealthEX to work backwards from the payout amount, + // which it only does on fixed rate routes: + const estimation: StealthexEstimation = + quoteFor === 'to' ? 'reversed' : 'direct' + const fixedSupported = + fromCurrency.rates.includes('fixed') && toCurrency.rates.includes('fixed') + if (estimation === 'reversed' && !fixedSupported) { + throw new SwapCurrencyError(swapInfo, request) + } + + const getRange = async ( + rate: StealthexRate + ): Promise | undefined> => { + const { json, errorKind } = await fetchStealthex('/rates/range', { + route, + estimation, + rate + }) + if (errorKind != null || json == null) { + // A missing route is the caller's cue to try the other rate type: + if (errorKind != null && NO_ROUTE_KINDS.includes(errorKind)) { + return undefined + } + throw stealthexError('/rates/range', errorKind ?? 'unknown', request) + } + return asStealthexRange(json) + } + + // The amount is denominated in whichever asset the user pinned down: + const quoteWallet = quoteFor === 'to' ? toWallet : fromWallet + const quoteTokenId = quoteFor === 'to' ? toTokenId : fromTokenId + const quoteAmount = nativeToDenomination( + quoteWallet, + request.nativeAmount, + quoteTokenId + ) + const limitDirection = quoteFor === 'to' ? 'to' : 'from' + + /** + * Checks the amount against the pair's range and returns the amount to + * quote. Bounds round INWARD, minimums up and maximums down, so a rounded + * limit can never sit outside what StealthEX actually accepts and send the + * user back with an amount that fails again. + * + * Below the minimum always throws. Above the maximum throws only when + * `enforceMax` is set: the max probe quotes the whole pre-fee balance, and + * StealthEX rejects an out-of-range amount outright, so the probe quotes + * the maximum instead. That keeps a fee estimate available, and the real + * quote that follows raises the typed above-limit error if the balance is + * still too large once the fee is subtracted. + */ + const checkLimits = ( + range: ReturnType + ): string => { + if (lt(quoteAmount, range.min_amount)) { + throw new SwapBelowLimitError( + swapInfo, + ceil( + denominationToNative(quoteWallet, range.min_amount, quoteTokenId), + 0 + ), + limitDirection + ) + } + if (range.max_amount != null && gt(quoteAmount, range.max_amount)) { + if (!enforceMax) return range.max_amount + throw new SwapAboveLimitError( + swapInfo, + floor( + denominationToNative(quoteWallet, range.max_amount, quoteTokenId), + 0 + ), + limitDirection + ) + } + return quoteAmount + } + + /** + * Runs one rate type end to end: the pair's range, the amount check, and + * the estimate. Returns undefined ONLY when this rate type has no route, + * which is the caller's cue to try the other one. Every other failure, + * limit errors included, describes the pair rather than the rate type and + * is thrown, so the fallback never retries something the other rate type + * would reject the same way. + */ + const attemptRate = async ( + rate: StealthexRate + ): Promise< + | { + rate: StealthexRate + estimate: ReturnType + body: StealthexEstimateBody + } + | undefined + > => { + const range = await getRange(rate) + if (range == null) return undefined + const amount = checkLimits(range) + + // StealthEX rejects a string amount outright ("expected number, + // received string"), so this is the one place a JS number is + // unavoidable. A from-amount past float precision would be rounded here, + // and the trust boundary in `fetchSwapQuoteInner` rejects the order if + // that ever rounds the deposit UP past what the user requested. + const body: StealthexEstimateBody = { + route, + estimation, + rate, + amount: Number(amount) + } + const { json, errorKind } = await fetchStealthex( + '/rates/estimated-amount', + body + ) + if (errorKind != null || json == null) { + if (errorKind != null && NO_ROUTE_KINDS.includes(errorKind)) { + return undefined + } + throw stealthexError( + '/rates/estimated-amount', + errorKind ?? 'unknown', + request + ) + } + const estimate = asStealthexEstimate(json) + + // A fixed order is only fixed if StealthEX returned a rate to lock in. + // Creating the order without one leaves the payout floating while the + // quote claims a guaranteed amount, so treat it as no fixed route: + if (rate === 'fixed' && estimate.rate == null) { + log.warn('StealthEX returned a fixed estimate carrying no rate id') + return undefined + } + return { rate, estimate, body } + } + + // StealthEX publishes rate types PER ASSET but offers no way to ask which + // types a given ROUTE supports, and both assets listing `fixed` does not + // mean the pair has a fixed route (BTC to ARRR was exactly that). The + // fallback compensates for that one missing capability and nothing else: + // it runs only when the fixed route is absent, never on a limit or + // unsupported-pair failure, and no order exists yet at this point, so + // neither attempt can create a second one. Drop it once StealthEX reports + // per-route rate types (see the ask in docs/API_REQUIREMENTS.md). + // A reversed estimate has no floating equivalent, so it never falls back. + const attempt = + (fixedSupported ? await attemptRate('fixed') : undefined) ?? + (estimation === 'direct' ? await attemptRate('floating') : undefined) + if (attempt == null) { + throw new SwapCurrencyError(swapInfo, request) + } + const { rate, estimate, body: estimateBody } = attempt + + const [fromAddress, toAddress] = await Promise.all([ + getAddress(fromWallet, addressTypeMap[fromWallet.currencyInfo.pluginId]), + getAddress(toWallet, addressTypeMap[toWallet.currencyInfo.pluginId]) + ]) + + return { rate, estimate, estimateBody, fromAddress, toAddress } + } + + /** + * `getMaxSwappable` probe: build a SwapOrder from the quote ALONE, so + * `getMaxSpendable` can price the network fee without a StealthEX order + * existing. Every max swap runs the quote function twice, so a probe that + * created an order would leave the first one abandoned, holding a deposit + * address and a locked fixed rate nobody will ever pay into. + */ + const fetchProbeOrder = async ( + request: EdgeSwapRequestPlugin + ): Promise => { + const { fromAddress } = await fetchQuote(request, false) + + const spendInfo: EdgeSpendInfo = { + tokenId: request.fromTokenId, + spendTargets: [ + { + // `getMaxSwappable` strips this amount before pricing the fee; it is + // the address that has to be real, and the user's own from-chain + // address stands in for the deposit address that does not exist yet. + nativeAmount: request.nativeAmount, + publicAddress: fromAddress + } + ], + networkFeeOption: 'high', + // This spend is never broadcast. Its target is the user's own address, + // which engines that compare the target against their own public key + // reject with `SpendToSelfError` (every EVM chain, where the public key + // IS the address). That error escapes `getMaxSwappable` and fails every + // max swap from an EVM wallet. The real order below keeps all checks. + skipChecks: true, + assetAction: { + assetActionType: 'swap' + } + } + + return { + request, + spendInfo, + swapInfo, + fromNativeAmount: request.nativeAmount, + expirationDate: ensureInFuture( + new Date(Date.now() + FLOATING_EXPIRATION_MS) + ) + } + } + + /** + * The ONLY call that creates an order, and it runs once per swap: the max + * probe above has already settled the final amount by the time it does. + */ + const fetchSwapQuoteInner = async ( + request: EdgeSwapRequestPlugin + ): Promise => { + const { fromWallet, toWallet, fromTokenId, toTokenId } = request + const { + rate, + estimate, + estimateBody, + fromAddress, + toAddress + } = await fetchQuote(request, true) + + const exchangeReply = await fetchStealthex('/exchanges', { + ...estimateBody, + // `attemptRate` guarantees a fixed estimate carries one, so the order is + // locked at exactly the rate the quote showed: + ...(estimate.rate == null ? {} : { rate_id: estimate.rate.id }), + address: toAddress, + refund_address: fromAddress + }) + if (exchangeReply.errorKind != null || exchangeReply.json == null) { + throw stealthexError( + '/exchanges', + exchangeReply.errorKind ?? 'unknown', + request + ) + } + const exchange = asStealthexExchange(exchangeReply.json) + + // StealthEX quotes more decimal places than a token may support, and native + // amounts have to be whole atomic units: + const fromNativeAmount = floor( + denominationToNative( + fromWallet, + exchange.deposit.expected_amount, + fromTokenId + ), + 0 + ) + const toNativeAmount = floor( + denominationToNative( + toWallet, + exchange.withdrawal.expected_amount, + toTokenId + ), + 0 + ) + + // TRUST BOUNDARY. `fromNativeAmount` comes out of StealthEX's response and + // is about to become a SIGNED SPEND, so bound it by what the user asked + // for: a malformed or compromised response must not be able to move more of + // the source asset than the quote showed. Only a 'from' quote pins the + // source amount locally (a max quote arrives here as one). On a reversed + // quote the user pinned the RECEIVE amount, so the source side is + // StealthEX's to determine and there is nothing local to bound it against. + if ( + request.quoteFor === 'from' && + gt(fromNativeAmount, request.nativeAmount) + ) { + throw new Error( + 'StealthEX returned a deposit amount above the requested amount' + ) + } + + // A numeric tag arrives as a JSON number, and an empty string means the + // chain takes no memo, so neither may become an EdgeMemo as-is: + const extraId = exchange.deposit.extra_id + const memoValue = extraId == null ? '' : String(extraId) + const memos: EdgeMemo[] = + memoValue === '' + ? [] + : [ + { + type: memoType(fromWallet.currencyInfo.pluginId), + value: memoValue + } + ] + + const spendInfo: EdgeSpendInfo = { + tokenId: fromTokenId, + spendTargets: [ + { + nativeAmount: fromNativeAmount, + publicAddress: exchange.deposit.address + } + ], + memos, + networkFeeOption: 'high', + assetAction: { + assetActionType: 'swap' + }, + savedAction: { + actionType: 'swap', + swapInfo, + orderId: exchange.id, + orderUri: ORDER_BASE_URL + exchange.id, + isEstimate: rate === 'floating', + toAsset: { + pluginId: toWallet.currencyInfo.pluginId, + tokenId: toTokenId, + nativeAmount: toNativeAmount + }, + fromAsset: { + pluginId: fromWallet.currencyInfo.pluginId, + tokenId: fromTokenId, + nativeAmount: fromNativeAmount + }, + payoutAddress: toAddress, + payoutWalletId: toWallet.id, + refundAddress: fromAddress + } + } + + // Floating rate orders never expire, so give the user the same window a + // fixed rate order gets: + const expirationDate = + exchange.expires_at ?? + new Date(exchange.created_at.getTime() + FLOATING_EXPIRATION_MS) + + return { + request, + spendInfo, + swapInfo, + fromNativeAmount, + expirationDate: ensureInFuture(expirationDate) + } + } + + const out: EdgeSwapPlugin = { + swapInfo, + + async fetchSwapQuote(req: EdgeSwapRequest): Promise { + const request = convertRequest(req) + + checkInvalidTokenIds(INVALID_TOKEN_IDS, request, swapInfo) + + // The probe quotes without creating an order; `fetchSwapQuoteInner` then + // creates exactly one, for the amount the probe settled on: + const newRequest = await getMaxSwappable(fetchProbeOrder, request) + const swapOrder = await fetchSwapQuoteInner(newRequest) + return await makeSwapPluginQuote(swapOrder) + } + } + + return out +}