diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b545636..6eb34183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- added: SimpleSwap centralized swap integration + ## 2.53.0 (2026-08-18) - added: Swapter swap provider diff --git a/scripts/allSynchronizers.ts b/scripts/allSynchronizers.ts index 4d30e944..876c0bd6 100644 --- a/scripts/allSynchronizers.ts +++ b/scripts/allSynchronizers.ts @@ -16,6 +16,7 @@ import { makeLifiSynchronizer } from './synchronizers/lifi/lifiSynchronizer' import { makeMayaProtocolSynchronizer } from './synchronizers/mayaprotocol/mayaprotocolSynchronizer' import { makeRangoSynchronizer } from './synchronizers/rango/rangoSynchronizer' import { makeSideShiftSynchronizer } from './synchronizers/sideshift/sideshiftSynchronizer' +import { makeSimpleSwapSynchronizer } from './synchronizers/simpleswap/simpleswapSynchronizer' import { makeSwapKitSynchronizer } from './synchronizers/swapkit/swapkitSynchronizer' import { makeSwapuzSynchronizer } from './synchronizers/swapuz/swapuzSynchronizer' import { makeThorchainSynchronizer } from './synchronizers/thorchain/thorchainSynchronizer' @@ -32,6 +33,7 @@ export const synchronizers: SwapSynchronizer[] = [ makeMayaProtocolSynchronizer(config), makeRangoSynchronizer(config), makeSideShiftSynchronizer(config), + makeSimpleSwapSynchronizer(config), makeSwapKitSynchronizer(config), makeSwapuzSynchronizer(config), makeThorchainSynchronizer(config), diff --git a/scripts/mapctlConfig.ts b/scripts/mapctlConfig.ts index fdf0716b..740622ff 100644 --- a/scripts/mapctlConfig.ts +++ b/scripts/mapctlConfig.ts @@ -8,6 +8,7 @@ const asMapctlConfig = asObject({ EXOLIX_API_KEY: asOptional(asString, ''), LETSEXCHANGE_API_KEY: asOptional(asString, ''), RANGO_API_KEY: asOptional(asString, ''), + SIMPLESWAP_API_KEY: asOptional(asString, ''), SWAPUZ_API_KEY: asOptional(asString, ''), SWAPKIT_API_KEY: asOptional(asString, ''), XGRAM_API_KEY: asOptional(asString, '') diff --git a/scripts/mappings/simpleswapMappings.ts b/scripts/mappings/simpleswapMappings.ts new file mode 100644 index 00000000..65de3661 --- /dev/null +++ b/scripts/mappings/simpleswapMappings.ts @@ -0,0 +1,1622 @@ +import { EdgeCurrencyPluginId } from '../../src/util/edgeCurrencyPluginIds' + +export const simpleswap = new Map() +// Currency Count: 1 +// Display Name: 0g +simpleswap.set('0g', null) + +// Currency Count: 2 +// Display Name: ab +simpleswap.set('ab', null) + +// Currency Count: 1 +// Display Name: abbc +simpleswap.set('abbc', null) + +// Currency Count: 1 +// Display Name: aca +simpleswap.set('aca', null) + +// Currency Count: 1 +// Display Name: act +simpleswap.set('act', null) + +// Currency Count: 6 +// Display Name: ada +simpleswap.set('ada', 'cardano') + +// Currency Count: 1 +// Display Name: adi +simpleswap.set('adi', null) + +// Currency Count: 1 +// Display Name: ae +simpleswap.set('ae', null) + +// Currency Count: 1 +// Display Name: aed +simpleswap.set('aed', null) + +// Currency Count: 1 +// Display Name: ai3 +simpleswap.set('ai3', null) + +// Currency Count: 1 +// Display Name: aix +simpleswap.set('aix', null) + +// Currency Count: 1 +// Display Name: akt +simpleswap.set('akt', null) + +// Currency Count: 1 +// Display Name: aleo +simpleswap.set('aleo', null) + +// Currency Count: 4 +// Display Name: algo +simpleswap.set('algo', 'algorand') + +// Currency Count: 1 +// Display Name: algousdt +simpleswap.set('algousdt', null) + +// Currency Count: 1 +// Display Name: allora +simpleswap.set('allora', null) + +// Currency Count: 1 +// Display Name: alph +simpleswap.set('alph', null) + +// Currency Count: 1 +// Display Name: amb +simpleswap.set('amb', null) + +// Currency Count: 1 +// Display Name: amd +simpleswap.set('amd', null) + +// Currency Count: 1 +// Display Name: analog +simpleswap.set('analog', null) + +// Currency Count: 1 +// Display Name: ao +simpleswap.set('ao', null) + +// Currency Count: 13 +// Display Name: apt +simpleswap.set('apt', null) + +// Currency Count: 1 +// Display Name: aptos +simpleswap.set('aptos', null) + +// Currency Count: 1 +// Display Name: ar +simpleswap.set('ar', null) + +// Currency Count: 3 +// Display Name: arbevm +simpleswap.set('arbevm', null) + +// Currency Count: 82 +// Display Name: arbitrum +simpleswap.set('arbitrum', 'arbitrum') + +// Currency Count: 2 +// Display Name: arbnova +simpleswap.set('arbnova', null) + +// Currency Count: 1 +// Display Name: arch +simpleswap.set('arch', null) + +// Currency Count: 1 +// Display Name: ardr +simpleswap.set('ardr', null) + +// Currency Count: 1 +// Display Name: ark +simpleswap.set('ark', null) + +// Currency Count: 1 +// Display Name: arrr +simpleswap.set('arrr', 'piratechain') + +// Currency Count: 1 +// Display Name: ars +simpleswap.set('ars', null) + +// Currency Count: 1 +// Display Name: astr +simpleswap.set('astr', null) + +// Currency Count: 1 +// Display Name: atom +simpleswap.set('atom', 'cosmoshub') + +// Currency Count: 1 +// Display Name: aud +simpleswap.set('aud', null) + +// Currency Count: 1 +// Display Name: auran +simpleswap.set('auran', null) + +// Currency Count: 1 +// Display Name: avail +simpleswap.set('avail', null) + +// Currency Count: 1 +// Display Name: avax +simpleswap.set('avax', null) + +// Currency Count: 27 +// Display Name: avaxc +simpleswap.set('avaxc', 'avalanche') + +// Currency Count: 1 +// Display Name: azero +simpleswap.set('azero', null) + +// Currency Count: 1 +// Display Name: baby +simpleswap.set('baby', null) + +// Currency Count: 1 +// Display Name: baju +simpleswap.set('baju', null) + +// Currency Count: 1 +// Display Name: band +simpleswap.set('band', null) + +// Currency Count: 120 +// Display Name: base +simpleswap.set('base', 'base') + +// Currency Count: 1 +// Display Name: baseevm +simpleswap.set('baseevm', null) + +// Currency Count: 1 +// Display Name: bb +simpleswap.set('bb', null) + +// Currency Count: 1 +// Display Name: bch +simpleswap.set('bch', 'bitcoincash') + +// Currency Count: 1 +// Display Name: bdx +simpleswap.set('bdx', null) + +// Currency Count: 1 +// Display Name: beam +simpleswap.set('beam', null) + +// Currency Count: 4 +// Display Name: bera +simpleswap.set('bera', null) + +// Currency Count: 1 +// Display Name: bfc +simpleswap.set('bfc', null) + +// Currency Count: 1 +// Display Name: bgn +simpleswap.set('bgn', null) + +// Currency Count: 2 +// Display Name: blast +simpleswap.set('blast', null) + +// Currency Count: 1 +// Display Name: bld +simpleswap.set('bld', null) + +// Currency Count: 1 +// Display Name: blocx +simpleswap.set('blocx', null) + +// Currency Count: 1 +// Display Name: brise +simpleswap.set('brise', null) + +// Currency Count: 1 +// Display Name: brl +simpleswap.set('brl', null) + +// Currency Count: 797 +// Display Name: bsc +simpleswap.set('bsc', 'binancesmartchain') + +// Currency Count: 1 +// Display Name: bsv +simpleswap.set('bsv', 'bitcoinsv') + +// Currency Count: 37 +// Display Name: btc +simpleswap.set('btc', 'bitcoin') + +// Currency Count: 1 +// Display Name: btg +simpleswap.set('btg', 'bitcoingold') + +// Currency Count: 1 +// Display Name: bts +simpleswap.set('bts', null) + +// Currency Count: 1 +// Display Name: cad +simpleswap.set('cad', null) + +// Currency Count: 1 +// Display Name: canto +simpleswap.set('canto', null) + +// Currency Count: 1 +// Display Name: canton +simpleswap.set('canton', null) + +// Currency Count: 1 +// Display Name: ccd +simpleswap.set('ccd', null) + +// Currency Count: 4 +// Display Name: celo +simpleswap.set('celo', 'celo') + +// Currency Count: 1 +// Display Name: cfx +simpleswap.set('cfx', null) + +// Currency Count: 1 +// Display Name: cfxcore +simpleswap.set('cfxcore', null) + +// Currency Count: 1 +// Display Name: chf +simpleswap.set('chf', null) + +// Currency Count: 1 +// Display Name: chromia +simpleswap.set('chromia', null) + +// Currency Count: 10 +// Display Name: chz2 +simpleswap.set('chz2', null) + +// Currency Count: 1 +// Display Name: citrea +simpleswap.set('citrea', null) + +// Currency Count: 1 +// Display Name: ckb +simpleswap.set('ckb', null) + +// Currency Count: 1 +// Display Name: cmt +simpleswap.set('cmt', null) + +// Currency Count: 1 +// Display Name: cop +simpleswap.set('cop', null) + +// Currency Count: 1 +// Display Name: core +simpleswap.set('core', null) + +// Currency Count: 1 +// Display Name: coreum +simpleswap.set('coreum', 'coreum') + +// Currency Count: 1 +// Display Name: corn +simpleswap.set('corn', null) + +// Currency Count: 1 +// Display Name: coti +simpleswap.set('coti', null) + +// Currency Count: 3 +// Display Name: cro +simpleswap.set('cro', null) + +// Currency Count: 1 +// Display Name: croevm +simpleswap.set('croevm', null) + +// Currency Count: 2 +// Display Name: cronos +simpleswap.set('cronos', null) + +// Currency Count: 1 +// Display Name: cspr +simpleswap.set('cspr', null) + +// Currency Count: 1 +// Display Name: ctk +simpleswap.set('ctk', null) + +// Currency Count: 1 +// Display Name: ctxc +simpleswap.set('ctxc', null) + +// Currency Count: 1 +// Display Name: cyber +simpleswap.set('cyber', null) + +// Currency Count: 1 +// Display Name: czk +simpleswap.set('czk', null) + +// Currency Count: 1 +// Display Name: dag +simpleswap.set('dag', null) + +// Currency Count: 1 +// Display Name: dash +simpleswap.set('dash', 'dash') + +// Currency Count: 1 +// Display Name: dcr +simpleswap.set('dcr', null) + +// Currency Count: 1 +// Display Name: deso +simpleswap.set('deso', null) + +// Currency Count: 1 +// Display Name: dfi +simpleswap.set('dfi', null) + +// Currency Count: 1 +// Display Name: dgb +simpleswap.set('dgb', 'digibyte') + +// Currency Count: 1 +// Display Name: dione +simpleswap.set('dione', null) + +// Currency Count: 1 +// Display Name: divi +simpleswap.set('divi', null) + +// Currency Count: 1 +// Display Name: dkk +simpleswap.set('dkk', null) + +// Currency Count: 2 +// Display Name: doge +simpleswap.set('doge', 'dogecoin') + +// Currency Count: 1 +// Display Name: dogechain +simpleswap.set('dogechain', null) + +// Currency Count: 1 +// Display Name: dop +simpleswap.set('dop', null) + +// Currency Count: 1 +// Display Name: dora +simpleswap.set('dora', null) + +// Currency Count: 2 +// Display Name: dvpn +simpleswap.set('dvpn', null) + +// Currency Count: 1 +// Display Name: dydx +simpleswap.set('dydx', null) + +// Currency Count: 1 +// Display Name: dym +simpleswap.set('dym', null) + +// Currency Count: 4 +// Display Name: egld +simpleswap.set('egld', null) + +// Currency Count: 1 +// Display Name: ela +simpleswap.set('ela', null) + +// Currency Count: 1 +// Display Name: endurance +simpleswap.set('endurance', null) + +// Currency Count: 1 +// Display Name: enj +simpleswap.set('enj', null) + +// Currency Count: 1 +// Display Name: eos +simpleswap.set('eos', 'eos') + +// Currency Count: 1 +// Display Name: erg +simpleswap.set('erg', null) + +// Currency Count: 1 +// Display Name: es +simpleswap.set('es', null) + +// Currency Count: 1 +// Display Name: etc +simpleswap.set('etc', 'ethereumclassic') + +// Currency Count: 997 +// Display Name: eth +simpleswap.set('eth', 'ethereum') + +// Currency Count: 1 +// Display Name: etherlink +simpleswap.set('etherlink', null) + +// Currency Count: 1 +// Display Name: ethw +simpleswap.set('ethw', 'ethereumpow') + +// Currency Count: 1 +// Display Name: etn +simpleswap.set('etn', null) + +// Currency Count: 1 +// Display Name: eur +simpleswap.set('eur', null) + +// Currency Count: 1 +// Display Name: evmos +simpleswap.set('evmos', null) + +// Currency Count: 1 +// Display Name: ewt +simpleswap.set('ewt', null) + +// Currency Count: 1 +// Display Name: exp +simpleswap.set('exp', null) + +// Currency Count: 2 +// Display Name: fantom +simpleswap.set('fantom', 'fantom') + +// Currency Count: 1 +// Display Name: fb +simpleswap.set('fb', null) + +// Currency Count: 1 +// Display Name: fet +simpleswap.set('fet', null) + +// Currency Count: 1 +// Display Name: fil +simpleswap.set('fil', 'filecoin') + +// Currency Count: 1 +// Display Name: filevm +simpleswap.set('filevm', 'filecoinfevm') + +// Currency Count: 1 +// Display Name: fio +simpleswap.set('fio', 'fio') + +// Currency Count: 1 +// Display Name: firo +simpleswap.set('firo', 'zcoin') + +// Currency Count: 2 +// Display Name: fitfi +simpleswap.set('fitfi', null) + +// Currency Count: 1 +// Display Name: flow +simpleswap.set('flow', null) + +// Currency Count: 2 +// Display Name: flr +simpleswap.set('flr', null) + +// Currency Count: 1 +// Display Name: fluent +simpleswap.set('fluent', null) + +// Currency Count: 1 +// Display Name: flux +simpleswap.set('flux', null) + +// Currency Count: 1 +// Display Name: fogo +simpleswap.set('fogo', null) + +// Currency Count: 1 +// Display Name: fra +simpleswap.set('fra', null) + +// Currency Count: 1 +// Display Name: fraevm +simpleswap.set('fraevm', null) + +// Currency Count: 1 +// Display Name: frax +simpleswap.set('frax', null) + +// Currency Count: 1 +// Display Name: fsn +simpleswap.set('fsn', null) + +// Currency Count: 1 +// Display Name: ftn +simpleswap.set('ftn', null) + +// Currency Count: 1 +// Display Name: gas +simpleswap.set('gas', null) + +// Currency Count: 1 +// Display Name: gbp +simpleswap.set('gbp', null) + +// Currency Count: 1 +// Display Name: gensyn +simpleswap.set('gensyn', null) + +// Currency Count: 1 +// Display Name: ghs +simpleswap.set('ghs', null) + +// Currency Count: 2 +// Display Name: glmr +simpleswap.set('glmr', null) + +// Currency Count: 1 +// Display Name: glq +simpleswap.set('glq', null) + +// Currency Count: 1 +// Display Name: gmmt +simpleswap.set('gmmt', null) + +// Currency Count: 1 +// Display Name: gravity +simpleswap.set('gravity', null) + +// Currency Count: 1 +// Display Name: grs +simpleswap.set('grs', 'groestlcoin') + +// Currency Count: 1 +// Display Name: gt +simpleswap.set('gt', null) + +// Currency Count: 1 +// Display Name: gtevm +simpleswap.set('gtevm', null) + +// Currency Count: 1 +// Display Name: gun +simpleswap.set('gun', null) + +// Currency Count: 1 +// Display Name: haqq +simpleswap.set('haqq', null) + +// Currency Count: 5 +// Display Name: hbar +simpleswap.set('hbar', 'hedera') + +// Currency Count: 5 +// Display Name: heco +simpleswap.set('heco', null) + +// Currency Count: 1 +// Display Name: hemi +simpleswap.set('hemi', null) + +// Currency Count: 1 +// Display Name: hive +simpleswap.set('hive', null) + +// Currency Count: 1 +// Display Name: hkd +simpleswap.set('hkd', null) + +// Currency Count: 1 +// Display Name: hmnd +simpleswap.set('hmnd', null) + +// Currency Count: 1 +// Display Name: hpp +simpleswap.set('hpp', null) + +// Currency Count: 1 +// Display Name: hsk +simpleswap.set('hsk', null) + +// Currency Count: 1 +// Display Name: htr +simpleswap.set('htr', null) + +// Currency Count: 1 +// Display Name: huf +simpleswap.set('huf', null) + +// Currency Count: 1 +// Display Name: hype +simpleswap.set('hype', null) + +// Currency Count: 2 +// Display Name: hyperevm +simpleswap.set('hyperevm', 'hyperevm') + +// Currency Count: 1 +// Display Name: icp +simpleswap.set('icp', null) + +// Currency Count: 1 +// Display Name: icx +simpleswap.set('icx', null) + +// Currency Count: 1 +// Display Name: ils +simpleswap.set('ils', null) + +// Currency Count: 1 +// Display Name: init +simpleswap.set('init', null) + +// Currency Count: 1 +// Display Name: inj +simpleswap.set('inj', null) + +// Currency Count: 1 +// Display Name: inr +simpleswap.set('inr', null) + +// Currency Count: 1 +// Display Name: iost +simpleswap.set('iost', null) + +// Currency Count: 1 +// Display Name: iota +simpleswap.set('iota', null) + +// Currency Count: 1 +// Display Name: iotx +simpleswap.set('iotx', null) + +// Currency Count: 1 +// Display Name: iris +simpleswap.set('iris', null) + +// Currency Count: 1 +// Display Name: iron +simpleswap.set('iron', null) + +// Currency Count: 1 +// Display Name: isk +simpleswap.set('isk', null) + +// Currency Count: 1 +// Display Name: jod +simpleswap.set('jod', null) + +// Currency Count: 1 +// Display Name: joystream +simpleswap.set('joystream', null) + +// Currency Count: 1 +// Display Name: jpy +simpleswap.set('jpy', null) + +// Currency Count: 1 +// Display Name: kai +simpleswap.set('kai', null) + +// Currency Count: 3 +// Display Name: kaia +simpleswap.set('kaia', null) + +// Currency Count: 1 +// Display Name: kar +simpleswap.set('kar', null) + +// Currency Count: 1 +// Display Name: kas +simpleswap.set('kas', null) + +// Currency Count: 1 +// Display Name: katana +simpleswap.set('katana', null) + +// Currency Count: 2 +// Display Name: kava +simpleswap.set('kava', null) + +// Currency Count: 3 +// Display Name: kavaevm +simpleswap.set('kavaevm', null) + +// Currency Count: 10 +// Display Name: kcc +simpleswap.set('kcc', null) + +// Currency Count: 1 +// Display Name: kda +simpleswap.set('kda', null) + +// Currency Count: 6 +// Display Name: klay +simpleswap.set('klay', null) + +// Currency Count: 1 +// Display Name: klv +simpleswap.set('klv', null) + +// Currency Count: 1 +// Display Name: kma +simpleswap.set('kma', null) + +// Currency Count: 1 +// Display Name: kmd +simpleswap.set('kmd', null) + +// Currency Count: 1 +// Display Name: konet +simpleswap.set('konet', null) + +// Currency Count: 1 +// Display Name: krw +simpleswap.set('krw', null) + +// Currency Count: 1 +// Display Name: ksmsm +simpleswap.set('ksmsm', null) + +// Currency Count: 1 +// Display Name: kyve +simpleswap.set('kyve', null) + +// Currency Count: 1 +// Display Name: kzt +simpleswap.set('kzt', null) + +// Currency Count: 1 +// Display Name: lava +simpleswap.set('lava', null) + +// Currency Count: 1 +// Display Name: lbc +simpleswap.set('lbc', null) + +// Currency Count: 1 +// Display Name: lightning +simpleswap.set('lightning', null) + +// Currency Count: 4 +// Display Name: linea +simpleswap.set('linea', null) + +// Currency Count: 3 +// Display Name: lineaeth +simpleswap.set('lineaeth', null) + +// Currency Count: 1 +// Display Name: lkr +simpleswap.set('lkr', null) + +// Currency Count: 1 +// Display Name: ltc +simpleswap.set('ltc', 'litecoin') + +// Currency Count: 1 +// Display Name: lto +simpleswap.set('lto', null) + +// Currency Count: 1 +// Display Name: lukso +simpleswap.set('lukso', null) + +// Currency Count: 1 +// Display Name: lumia +simpleswap.set('lumia', null) + +// Currency Count: 1 +// Display Name: luna +simpleswap.set('luna', null) + +// Currency Count: 3 +// Display Name: lunc +simpleswap.set('lunc', null) + +// Currency Count: 3 +// Display Name: manta +simpleswap.set('manta', null) + +// Currency Count: 2 +// Display Name: mantra +simpleswap.set('mantra', null) + +// Currency Count: 1 +// Display Name: mapo +simpleswap.set('mapo', null) + +// Currency Count: 2 +// Display Name: maro +simpleswap.set('maro', null) + +// Currency Count: 1 +// Display Name: mat +simpleswap.set('mat', null) + +// Currency Count: 80 +// Display Name: matic +simpleswap.set('matic', 'polygon') + +// Currency Count: 1 +// Display Name: megaeth +simpleswap.set('megaeth', null) + +// Currency Count: 1 +// Display Name: memecore +simpleswap.set('memecore', null) + +// Currency Count: 1 +// Display Name: merlin +simpleswap.set('merlin', null) + +// Currency Count: 2 +// Display Name: metis +simpleswap.set('metis', null) + +// Currency Count: 1 +// Display Name: mezo +simpleswap.set('mezo', null) + +// Currency Count: 1 +// Display Name: milk +simpleswap.set('milk', null) + +// Currency Count: 1 +// Display Name: mina +simpleswap.set('mina', null) + +// Currency Count: 1 +// Display Name: mito +simpleswap.set('mito', null) + +// Currency Count: 8 +// Display Name: mnt +simpleswap.set('mnt', null) + +// Currency Count: 1 +// Display Name: mob +simpleswap.set('mob', null) + +// Currency Count: 2 +// Display Name: mode +simpleswap.set('mode', null) + +// Currency Count: 2 +// Display Name: monad +simpleswap.set('monad', 'monad') + +// Currency Count: 1 +// Display Name: movr +simpleswap.set('movr', null) + +// Currency Count: 1 +// Display Name: mtl +simpleswap.set('mtl', null) + +// Currency Count: 1 +// Display Name: mvrk +simpleswap.set('mvrk', null) + +// Currency Count: 1 +// Display Name: mxc +simpleswap.set('mxc', null) + +// Currency Count: 1 +// Display Name: mxn +simpleswap.set('mxn', null) + +// Currency Count: 1 +// Display Name: nac +simpleswap.set('nac', null) + +// Currency Count: 1 +// Display Name: nano +simpleswap.set('nano', null) + +// Currency Count: 1 +// Display Name: nbs +simpleswap.set('nbs', null) + +// Currency Count: 7 +// Display Name: near +simpleswap.set('near', null) + +// Currency Count: 1 +// Display Name: neer +simpleswap.set('neer', null) + +// Currency Count: 2 +// Display Name: neo +simpleswap.set('neo', null) + +// Currency Count: 3 +// Display Name: neo3 +simpleswap.set('neo3', null) + +// Currency Count: 1 +// Display Name: neon +simpleswap.set('neon', null) + +// Currency Count: 1 +// Display Name: nibi +simpleswap.set('nibi', null) + +// Currency Count: 1 +// Display Name: nil +simpleswap.set('nil', null) + +// Currency Count: 1 +// Display Name: nimpos +simpleswap.set('nimpos', null) + +// Currency Count: 1 +// Display Name: noble +simpleswap.set('noble', null) + +// Currency Count: 1 +// Display Name: nok +simpleswap.set('nok', null) + +// Currency Count: 1 +// Display Name: nom +simpleswap.set('nom', null) + +// Currency Count: 1 +// Display Name: ntrn +simpleswap.set('ntrn', null) + +// Currency Count: 1 +// Display Name: nuls +simpleswap.set('nuls', null) + +// Currency Count: 1 +// Display Name: num +simpleswap.set('num', null) + +// Currency Count: 1 +// Display Name: nzd +simpleswap.set('nzd', null) + +// Currency Count: 1 +// Display Name: oas +simpleswap.set('oas', null) + +// Currency Count: 2 +// Display Name: okt +simpleswap.set('okt', null) + +// Currency Count: 1 +// Display Name: olt +simpleswap.set('olt', null) + +// Currency Count: 1 +// Display Name: omn +simpleswap.set('omn', null) + +// Currency Count: 1 +// Display Name: one +simpleswap.set('one', null) + +// Currency Count: 2 +// Display Name: ong +simpleswap.set('ong', null) + +// Currency Count: 2 +// Display Name: ont +simpleswap.set('ont', null) + +// Currency Count: 1 +// Display Name: oort +simpleswap.set('oort', null) + +// Currency Count: 3 +// Display Name: opbnb +simpleswap.set('opbnb', 'opbnb') + +// Currency Count: 20 +// Display Name: optimism +simpleswap.set('optimism', 'optimism') + +// Currency Count: 1 +// Display Name: orai +simpleswap.set('orai', null) + +// Currency Count: 2 +// Display Name: osmo +simpleswap.set('osmo', 'osmosis') + +// Currency Count: 1 +// Display Name: ozone +simpleswap.set('ozone', null) + +// Currency Count: 1 +// Display Name: partisia +simpleswap.set('partisia', null) + +// Currency Count: 1 +// Display Name: pdex +simpleswap.set('pdex', null) + +// Currency Count: 2 +// Display Name: peaq +simpleswap.set('peaq', null) + +// Currency Count: 1 +// Display Name: pen +simpleswap.set('pen', null) + +// Currency Count: 1 +// Display Name: pepecoin +simpleswap.set('pepecoin', null) + +// Currency Count: 1 +// Display Name: phantasma +simpleswap.set('phantasma', null) + +// Currency Count: 1 +// Display Name: pharos +simpleswap.set('pharos', null) + +// Currency Count: 1 +// Display Name: php +simpleswap.set('php', null) + +// Currency Count: 1 +// Display Name: pi +simpleswap.set('pi', null) + +// Currency Count: 1 +// Display Name: pica +simpleswap.set('pica', null) + +// Currency Count: 1 +// Display Name: pivx +simpleswap.set('pivx', 'pivx') + +// Currency Count: 1 +// Display Name: pix +simpleswap.set('pix', null) + +// Currency Count: 2 +// Display Name: plasma +simpleswap.set('plasma', null) + +// Currency Count: 1 +// Display Name: PlatON +simpleswap.set('PlatON', null) + +// Currency Count: 1 +// Display Name: plcu +simpleswap.set('plcu', null) + +// Currency Count: 1 +// Display Name: pln +simpleswap.set('pln', null) + +// Currency Count: 1 +// Display Name: plume +simpleswap.set('plume', null) + +// Currency Count: 1 +// Display Name: pokt +simpleswap.set('pokt', null) + +// Currency Count: 1 +// Display Name: polyx +simpleswap.set('polyx', null) + +// Currency Count: 1 +// Display Name: psl +simpleswap.set('psl', null) + +// Currency Count: 1 +// Display Name: pulse +simpleswap.set('pulse', 'pulsechain') + +// Currency Count: 1 +// Display Name: qar +simpleswap.set('qar', null) + +// Currency Count: 1 +// Display Name: qie +simpleswap.set('qie', null) + +// Currency Count: 1 +// Display Name: qtum +simpleswap.set('qtum', 'qtum') + +// Currency Count: 1 +// Display Name: qubic +simpleswap.set('qubic', null) + +// Currency Count: 1 +// Display Name: rbtc +simpleswap.set('rbtc', null) + +// Currency Count: 1 +// Display Name: react +simpleswap.set('react', null) + +// Currency Count: 1 +// Display Name: reef +simpleswap.set('reef', null) + +// Currency Count: 1 +// Display Name: rei +simpleswap.set('rei', null) + +// Currency Count: 1 +// Display Name: rol +simpleswap.set('rol', null) + +// Currency Count: 6 +// Display Name: ron +simpleswap.set('ron', null) + +// Currency Count: 1 +// Display Name: rook +simpleswap.set('rook', null) + +// Currency Count: 1 +// Display Name: root +simpleswap.set('root', null) + +// Currency Count: 1 +// Display Name: rootstock +simpleswap.set('rootstock', null) + +// Currency Count: 1 +// Display Name: rose +simpleswap.set('rose', null) + +// Currency Count: 1 +// Display Name: route +simpleswap.set('route', null) + +// Currency Count: 1 +// Display Name: rune +simpleswap.set('rune', 'thorchainrune') + +// Currency Count: 1 +// Display Name: rvn +simpleswap.set('rvn', 'ravencoin') + +// Currency Count: 1 +// Display Name: rxd +simpleswap.set('rxd', null) + +// Currency Count: 1 +// Display Name: saga +simpleswap.set('saga', null) + +// Currency Count: 1 +// Display Name: sc +simpleswap.set('sc', null) + +// Currency Count: 2 +// Display Name: scroll +simpleswap.set('scroll', null) + +// Currency Count: 1 +// Display Name: scrt +simpleswap.set('scrt', null) + +// Currency Count: 1 +// Display Name: sdn +simpleswap.set('sdn', null) + +// Currency Count: 1 +// Display Name: sei +simpleswap.set('sei', null) + +// Currency Count: 1 +// Display Name: seievm +simpleswap.set('seievm', null) + +// Currency Count: 1 +// Display Name: sek +simpleswap.set('sek', null) + +// Currency Count: 1 +// Display Name: sgb +simpleswap.set('sgb', null) + +// Currency Count: 1 +// Display Name: sgd +simpleswap.set('sgd', null) + +// Currency Count: 1 +// Display Name: slf +simpleswap.set('slf', null) + +// Currency Count: 2 +// Display Name: smart +simpleswap.set('smart', 'smartcash') + +// Currency Count: 1 +// Display Name: smartchain +simpleswap.set('smartchain', null) + +// Currency Count: 1 +// Display Name: smh +simpleswap.set('smh', null) + +// Currency Count: 273 +// Display Name: sol +simpleswap.set('sol', 'solana') + +// Currency Count: 1 +// Display Name: sol +simpleswap.set('sol ', null) + +// Currency Count: 1 +// Display Name: somnia +simpleswap.set('somnia', null) + +// Currency Count: 2 +// Display Name: sonic +simpleswap.set('sonic', 'sonic') + +// Currency Count: 1 +// Display Name: soph +simpleswap.set('soph', null) + +// Currency Count: 1 +// Display Name: soso +simpleswap.set('soso', null) + +// Currency Count: 1 +// Display Name: space +simpleswap.set('space', null) + +// Currency Count: 1 +// Display Name: stable +simpleswap.set('stable', null) + +// Currency Count: 3 +// Display Name: starknet +simpleswap.set('starknet', null) + +// Currency Count: 4 +// Display Name: statemint +simpleswap.set('statemint', null) + +// Currency Count: 2 +// Display Name: steem +simpleswap.set('steem', null) + +// Currency Count: 1 +// Display Name: story +simpleswap.set('story', null) + +// Currency Count: 1 +// Display Name: strax +simpleswap.set('strax', null) + +// Currency Count: 1 +// Display Name: stt +simpleswap.set('stt', null) + +// Currency Count: 2 +// Display Name: stx +simpleswap.set('stx', null) + +// Currency Count: 34 +// Display Name: sui +simpleswap.set('sui', 'sui') + +// Currency Count: 1 +// Display Name: supra +simpleswap.set('supra', null) + +// Currency Count: 1 +// Display Name: sxp +simpleswap.set('sxp', null) + +// Currency Count: 1 +// Display Name: sys +simpleswap.set('sys', null) + +// Currency Count: 1 +// Display Name: sysevm +simpleswap.set('sysevm', null) + +// Currency Count: 1 +// Display Name: tac +simpleswap.set('tac', null) + +// Currency Count: 1 +// Display Name: taiko +simpleswap.set('taiko', null) + +// Currency Count: 1 +// Display Name: tao +simpleswap.set('tao', null) + +// Currency Count: 1 +// Display Name: tara +simpleswap.set('tara', null) + +// Currency Count: 1 +// Display Name: tbc +simpleswap.set('tbc', null) + +// Currency Count: 1 +// Display Name: tenet +simpleswap.set('tenet', null) + +// Currency Count: 1 +// Display Name: thb +simpleswap.set('thb', null) + +// Currency Count: 4 +// Display Name: theta +simpleswap.set('theta', null) + +// Currency Count: 1 +// Display Name: tia +simpleswap.set('tia', null) + +// Currency Count: 1 +// Display Name: tlos +simpleswap.set('tlos', 'telos') + +// Currency Count: 1 +// Display Name: tlosevm +simpleswap.set('tlosevm', null) + +// Currency Count: 34 +// Display Name: ton +simpleswap.set('ton', 'ton') + +// Currency Count: 27 +// Display Name: trx +simpleswap.set('trx', 'tron') + +// Currency Count: 1 +// Display Name: try +simpleswap.set('try', null) + +// Currency Count: 1 +// Display Name: tt +simpleswap.set('tt', null) + +// Currency Count: 1 +// Display Name: twd +simpleswap.set('twd', null) + +// Currency Count: 1 +// Display Name: u2u +simpleswap.set('u2u', null) + +// Currency Count: 1 +// Display Name: unichain +simpleswap.set('unichain', null) + +// Currency Count: 1 +// Display Name: usd +simpleswap.set('usd', null) + +// Currency Count: 1 +// Display Name: uyu +simpleswap.set('uyu', null) + +// Currency Count: 1 +// Display Name: vana +simpleswap.set('vana', null) + +// Currency Count: 1 +// Display Name: vara +simpleswap.set('vara', null) + +// Currency Count: 1 +// Display Name: venom +simpleswap.set('venom', null) + +// Currency Count: 3 +// Display Name: vet +simpleswap.set('vet', null) + +// Currency Count: 1 +// Display Name: vic +simpleswap.set('vic', null) + +// Currency Count: 1 +// Display Name: vite +simpleswap.set('vite', null) + +// Currency Count: 1 +// Display Name: vlx +simpleswap.set('vlx', null) + +// Currency Count: 1 +// Display Name: vnd +simpleswap.set('vnd', null) + +// Currency Count: 1 +// Display Name: vsc +simpleswap.set('vsc', null) + +// Currency Count: 1 +// Display Name: vsys +simpleswap.set('vsys', null) + +// Currency Count: 1 +// Display Name: vts +simpleswap.set('vts', null) + +// Currency Count: 1 +// Display Name: wan +simpleswap.set('wan', null) + +// Currency Count: 1 +// Display Name: warden +simpleswap.set('warden', null) + +// Currency Count: 1 +// Display Name: water +simpleswap.set('water', null) + +// Currency Count: 2 +// Display Name: waves +simpleswap.set('waves', null) + +// Currency Count: 1 +// Display Name: wax +simpleswap.set('wax', 'wax') + +// Currency Count: 1 +// Display Name: waxl +simpleswap.set('waxl', null) + +// Currency Count: 1 +// Display Name: wemix +simpleswap.set('wemix', null) + +// Currency Count: 1 +// Display Name: wit +simpleswap.set('wit', null) + +// Currency Count: 1 +// Display Name: wld +simpleswap.set('wld', null) + +// Currency Count: 1 +// Display Name: xai +simpleswap.set('xai', null) + +// Currency Count: 1 +// Display Name: xch +simpleswap.set('xch', null) + +// Currency Count: 1 +// Display Name: xdb +simpleswap.set('xdb', null) + +// Currency Count: 2 +// Display Name: xdc +simpleswap.set('xdc', null) + +// Currency Count: 1 +// Display Name: xec +simpleswap.set('xec', 'ecash') + +// Currency Count: 1 +// Display Name: xem +simpleswap.set('xem', null) + +// Currency Count: 1 +// Display Name: xfi +simpleswap.set('xfi', null) + +// Currency Count: 1 +// Display Name: xion +simpleswap.set('xion', null) + +// Currency Count: 6 +// Display Name: xlm +simpleswap.set('xlm', 'stellar') + +// Currency Count: 1 +// Display Name: xmr +simpleswap.set('xmr', 'monero') + +// Currency Count: 1 +// Display Name: xna +simpleswap.set('xna', null) + +// Currency Count: 1 +// Display Name: xpll +simpleswap.set('xpll', null) + +// Currency Count: 1 +// Display Name: xpr +simpleswap.set('xpr', null) + +// Currency Count: 1 +// Display Name: xprt +simpleswap.set('xprt', null) + +// Currency Count: 1 +// Display Name: xrd +simpleswap.set('xrd', null) + +// Currency Count: 2 +// Display Name: xrp +simpleswap.set('xrp', 'ripple') + +// Currency Count: 2 +// Display Name: xtz +simpleswap.set('xtz', 'tezos') + +// Currency Count: 1 +// Display Name: xvg +simpleswap.set('xvg', null) + +// Currency Count: 1 +// Display Name: xym +simpleswap.set('xym', null) + +// Currency Count: 1 +// Display Name: zano +simpleswap.set('zano', 'zano') + +// Currency Count: 1 +// Display Name: zar +simpleswap.set('zar', null) + +// Currency Count: 1 +// Display Name: zec +simpleswap.set('zec', 'zcash') + +// Currency Count: 1 +// Display Name: zeta +simpleswap.set('zeta', null) + +// Currency Count: 1 +// Display Name: zetaevm +simpleswap.set('zetaevm', null) + +// Currency Count: 1 +// Display Name: zig +simpleswap.set('zig', null) + +// Currency Count: 2 +// Display Name: zil +simpleswap.set('zil', null) + +// Currency Count: 1 +// Display Name: zkl +simpleswap.set('zkl', null) + +// Currency Count: 10 +// Display Name: zksera +simpleswap.set('zksera', 'zksync') + +// Currency Count: 1 +// Display Name: zkverify +simpleswap.set('zkverify', null) + +// Currency Count: 1 +// Display Name: zrc +simpleswap.set('zrc', null) diff --git a/scripts/synchronizers/simpleswap/simpleswapSynchronizer.ts b/scripts/synchronizers/simpleswap/simpleswapSynchronizer.ts new file mode 100644 index 00000000..a399fc1a --- /dev/null +++ b/scripts/synchronizers/simpleswap/simpleswapSynchronizer.ts @@ -0,0 +1,78 @@ +import fetch from 'node-fetch' + +import { MapctlConfig } from '../../mapctlConfig' +import { FetchChainCodeResult, SwapSynchronizer } from '../../types' +import { getMappingFilePath, loadMappingFile } from '../../util/loadMappingFile' +import { asSimpleSwapCurrenciesResponse } from './simpleswapTypes' + +const NAME = 'simpleswap' + +export const makeSimpleSwapSynchronizer = ( + config: MapctlConfig +): SwapSynchronizer => { + const apiKey = config.SIMPLESWAP_API_KEY + if (apiKey == null || apiKey === '') { + throw new Error('Missing SIMPLESWAP_API_KEY in environment variables') + } + + return { + name: NAME, + get map() { + return loadMappingFile(NAME) + }, + mappingFilePath: getMappingFilePath(NAME), + fetchChainCodes: async (): Promise => { + const response = await fetch('https://api.simpleswap.io/v3/currencies', { + headers: { + 'x-api-key': apiKey + } + }) + + if (!response.ok) { + throw new Error( + `Failed to fetch SimpleSwap currencies: ${response.statusText}` + ) + } + + const data = await response.json() + const { result: currencies } = asSimpleSwapCurrenciesResponse(data) + + if (currencies.length === 0) { + throw new Error( + 'SimpleSwap API returned 0 currencies. This likely indicates an API error.' + ) + } + + // Extract unique network values with metadata + const networkMap = new Map() + currencies.forEach(currency => { + if (currency.network !== '') { + const existing = networkMap.get(currency.network) + if (existing != null) { + existing.count++ + } else { + networkMap.set(currency.network, { count: 1 }) + } + } + }) + + const results = Array.from(networkMap.entries()).map( + ([network, info]) => ({ + chainCode: network, + metadata: { + 'Display Name': network, + 'Currency Count': String(info.count) + } + }) + ) + + if (results.length === 0) { + throw new Error( + 'SimpleSwap API returned currencies but no valid networks were extracted.' + ) + } + + return results + } + } +} diff --git a/scripts/synchronizers/simpleswap/simpleswapTypes.ts b/scripts/synchronizers/simpleswap/simpleswapTypes.ts new file mode 100644 index 00000000..c52a3149 --- /dev/null +++ b/scripts/synchronizers/simpleswap/simpleswapTypes.ts @@ -0,0 +1,10 @@ +import { asArray, asObject, asString } from 'cleaners' + +export const asSimpleSwapCurrency = asObject({ + ticker: asString, + network: asString +}) + +export const asSimpleSwapCurrenciesResponse = asObject({ + result: asArray(asSimpleSwapCurrency) +}) diff --git a/src/index.ts b/src/index.ts index 70355588..06e1327a 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 { makeSimpleSwapPlugin } from './swap/central/simpleswap' import { makeSwapterPlugin } from './swap/central/swapter' import { makeSwapuzPlugin } from './swap/central/swapuz' import { makeXgramPlugin } from './swap/central/xgram' @@ -46,6 +47,7 @@ const plugins = { nymswap: makeNymPlugin, rango: makeRangoPlugin, sideshift: makeSideshiftPlugin, + simpleswap: makeSimpleSwapPlugin, spookySwap: makeSpookySwapPlugin, swapter: makeSwapterPlugin, swapuz: makeSwapuzPlugin, diff --git a/src/mappings/simpleswap.ts b/src/mappings/simpleswap.ts new file mode 100644 index 00000000..f1560c8f --- /dev/null +++ b/src/mappings/simpleswap.ts @@ -0,0 +1,101 @@ +/** + * ⚠️ AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY ⚠️ + * + * This file is automatically generated from scripts/mappings/simpleswapMappings.ts + * To regenerate this file, run: yarn mapctl update-mappings + * + * To edit mappings: + * 1. Edit scripts/mappings/simpleswapMappings.ts + * 2. Run: yarn mapctl update-mappings + * + * This file maps EdgeCurrencyPluginId -> synchronizer network identifier (or null) + */ + +import { EdgeCurrencyPluginId } from '../util/edgeCurrencyPluginIds' + +export const simpleswap = new Map() +simpleswap.set('abstract', null) +simpleswap.set('algorand', 'algo') +simpleswap.set('amoy', null) +simpleswap.set('arbitrum', 'arbitrum') +simpleswap.set('avalanche', 'avaxc') +simpleswap.set('axelar', null) +simpleswap.set('badcoin', null) +simpleswap.set('base', 'base') +simpleswap.set('binance', null) +simpleswap.set('binancesmartchain', 'bsc') +simpleswap.set('bitcoin', 'btc') +simpleswap.set('bitcoincash', 'bch') +simpleswap.set('bitcoincashtestnet', null) +simpleswap.set('bitcoingold', 'btg') +simpleswap.set('bitcoingoldtestnet', null) +simpleswap.set('bitcoinsv', 'bsv') +simpleswap.set('bitcointestnet', null) +simpleswap.set('bitcointestnet4', null) +simpleswap.set('bobevm', null) +simpleswap.set('botanix', null) +simpleswap.set('calibration', null) +simpleswap.set('cardano', 'ada') +simpleswap.set('cardanotestnet', null) +simpleswap.set('celo', 'celo') +simpleswap.set('coreum', 'coreum') +simpleswap.set('cosmoshub', 'atom') +simpleswap.set('dash', 'dash') +simpleswap.set('digibyte', 'dgb') +simpleswap.set('dogecoin', 'doge') +simpleswap.set('eboost', null) +simpleswap.set('ecash', 'xec') +simpleswap.set('eos', 'eos') +simpleswap.set('ethDev', null) +simpleswap.set('ethereum', 'eth') +simpleswap.set('ethereumclassic', 'etc') +simpleswap.set('ethereumpow', 'ethw') +simpleswap.set('fantom', 'fantom') +simpleswap.set('feathercoin', null) +simpleswap.set('filecoin', 'fil') +simpleswap.set('filecoinfevm', 'filevm') +simpleswap.set('filecoinfevmcalibration', null) +simpleswap.set('fio', 'fio') +simpleswap.set('groestlcoin', 'grs') +simpleswap.set('hedera', 'hbar') +simpleswap.set('holesky', null) +simpleswap.set('hyperevm', 'hyperevm') +simpleswap.set('liberland', null) +simpleswap.set('liberlandtestnet', null) +simpleswap.set('litecoin', 'ltc') +simpleswap.set('mayachain', null) +simpleswap.set('monad', 'monad') +simpleswap.set('monero', 'xmr') +simpleswap.set('nym', null) +simpleswap.set('opbnb', 'opbnb') +simpleswap.set('optimism', 'optimism') +simpleswap.set('osmosis', 'osmo') +simpleswap.set('piratechain', 'arrr') +simpleswap.set('pivx', 'pivx') +simpleswap.set('polkadot', null) +simpleswap.set('polygon', 'matic') +simpleswap.set('pulsechain', 'pulse') +simpleswap.set('qtum', 'qtum') +simpleswap.set('ravencoin', 'rvn') +simpleswap.set('ripple', 'xrp') +simpleswap.set('rsk', null) +simpleswap.set('sepolia', null) +simpleswap.set('smartcash', 'smart') +simpleswap.set('solana', 'sol') +simpleswap.set('sonic', 'sonic') +simpleswap.set('stellar', 'xlm') +simpleswap.set('sui', 'sui') +simpleswap.set('suitestnet', null) +simpleswap.set('telos', 'tlos') +simpleswap.set('tezos', 'xtz') +simpleswap.set('thorchainrune', 'rune') +simpleswap.set('thorchainrunestagenet', null) +simpleswap.set('ton', 'ton') +simpleswap.set('tron', 'trx') +simpleswap.set('ufo', null) +simpleswap.set('vertcoin', null) +simpleswap.set('wax', 'wax') +simpleswap.set('zano', 'zano') +simpleswap.set('zcash', 'zec') +simpleswap.set('zcoin', 'firo') +simpleswap.set('zksync', 'zksera') diff --git a/src/swap/central/simpleswap.ts b/src/swap/central/simpleswap.ts new file mode 100644 index 00000000..22249b02 --- /dev/null +++ b/src/swap/central/simpleswap.ts @@ -0,0 +1,470 @@ +import { ceil, eq, floor, gt, lt } from 'biggystring' +import { asEither, asNull, asObject, asOptional, asString } from 'cleaners' +import { + EdgeCorePluginOptions, + EdgeFetchResponse, + EdgeMemo, + EdgeSpendInfo, + EdgeSwapInfo, + EdgeSwapPlugin, + EdgeSwapQuote, + EdgeSwapRequest, + SwapAboveLimitError, + SwapBelowLimitError, + SwapCurrencyError, + SwapPermissionError +} from 'edge-core-js/types' + +import { simpleswap as simpleswapMapping } from '../../mappings/simpleswap' +import { + checkInvalidTokenIds, + checkWhitelistedMainnetCodes, + CurrencyCodeTranscriptionMap, + CurrencyPluginIdSwapChainCodeMap, + ensureInFuture, + getCodesWithTranscription, + getMaxSwappable, + InvalidTokenIds, + makeSwapPluginQuote, + mapToRecord, + SwapOrder +} from '../../util/swapHelpers' +import { + convertRequest, + denominationToNative, + getAddress, + memoType, + nativeToDenomination +} from '../../util/utils' +import { asNumberString, EdgeSwapRequestPlugin, StringMap } from '../types' + +const pluginId = 'simpleswap' + +const swapInfo: EdgeSwapInfo = { + pluginId, + isDex: false, + displayName: 'SimpleSwap', + supportEmail: 'support@simpleswap.io' +} + +const asInitOptions = asObject({ + apiKey: asString +}) + +const orderUri = 'https://simpleswap.io/exchange?id=' +const uri = 'https://api.simpleswap.io/v3/' + +const expirationMs = 1000 * 60 + +const INVALID_TOKEN_IDS: InvalidTokenIds = { from: {}, to: {} } + +const addressTypeMap: StringMap = { + digibyte: 'publicAddress', + zcash: 'transparentAddress' +} + +const MAINNET_CODE_TRANSCRIPTION: CurrencyPluginIdSwapChainCodeMap = mapToRecord( + simpleswapMapping +) + +// Edge native currencyCode -> SimpleSwap ticker, where they differ +// (verified against the live GET /v3/currencies list) +const CURRENCY_CODE_TRANSCRIPTION: CurrencyCodeTranscriptionMap = { + avalanche: { AVAX: 'avaxc' }, + binancesmartchain: { BNB: 'bnb-bsc' }, + monad: { MON: 'monad' }, + smartcash: { SMART: 'smart0' }, + telos: { TLOS: 'tlosmain' }, + wax: { WAX: 'waxp' } +} + +const asRange = asObject({ + min: asNumberString, + max: asEither(asNumberString, asNull) +}) +const asEstimate = asObject({ + estimatedAmount: asNumberString, + rateId: asOptional(asString), + validUntil: asOptional(asString) +}) +const asCreatedExchange = asObject({ + id: asString, + addressFrom: asString, + // SimpleSwap sends a destination tag as a JSON number on some chains, and a + // tag of 0 is a real tag, so this must survive both. + extraIdFrom: asOptional(asEither(asNumberString, asNull)), + amountFrom: asNumberString, + amountTo: asNumberString +}) +// Raised once an order already exists, so `getQuote` knows not to retry: the +// other flow would abandon that order and hit the same problem again. +const FATAL_ERROR_NAME = 'SimpleSwapFatalError' +const makeFatalError = (message: string): Error => { + const error = new Error(message) + error.name = FATAL_ERROR_NAME + return error +} + +const asRangeReply = asObject({ result: asRange }) +const asEstimateReply = asObject({ result: asEstimate }) +const asCreatedExchangeReply = asObject({ result: asCreatedExchange }) + +export function makeSimpleSwapPlugin( + opts: EdgeCorePluginOptions +): EdgeSwapPlugin { + const { io, log } = opts + const { fetchCors = io.fetch } = io + const { apiKey } = asInitOptions(opts.initOptions) + if (apiKey === '') { + throw new Error('SimpleSwap: missing apiKey in initOptions') + } + + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-api-key': apiKey + } + + const resolveNetworks = ( + request: EdgeSwapRequestPlugin + ): { fromNetwork: string; toNetwork: string } => { + const fromNetwork = + MAINNET_CODE_TRANSCRIPTION[ + request.fromWallet.currencyInfo + .pluginId as keyof CurrencyPluginIdSwapChainCodeMap + ] + const toNetwork = + MAINNET_CODE_TRANSCRIPTION[ + request.toWallet.currencyInfo + .pluginId as keyof CurrencyPluginIdSwapChainCodeMap + ] + if (fromNetwork == null || toNetwork == null) { + throw new SwapCurrencyError(swapInfo, request) + } + return { fromNetwork, toNetwork } + } + + const getQuoteForFlow = async ( + request: EdgeSwapRequestPlugin, + fixed: boolean + ): Promise => { + const { fromWallet, toWallet, quoteFor } = request + const reverse = quoteFor === 'to' + + const call = async ( + method: 'GET' | 'POST', + route: string, + params: { query?: StringMap; body?: unknown } + ): Promise => { + let response: EdgeFetchResponse + if (method === 'POST') { + response = await fetchCors(uri + route, { + method, + headers, + body: JSON.stringify(params.body) + }) + } else { + const query = new URLSearchParams(params.query).toString() + response = await fetchCors(`${uri}${route}?${query}`, { + method, + headers + }) + } + + if (!response.ok) { + const text = await response.text() + log.warn(`SimpleSwap ${route} error ${response.status}: ${text}`) + switch (response.status) { + case 403: + throw new SwapPermissionError(swapInfo, 'noVerification') + // 422 = out of range, but limits are enforced by the /ranges check + // before estimate/create, so treat it as an unavailable pair here + case 404: + case 422: + throw new SwapCurrencyError(swapInfo, request) + default: + throw new Error(`SimpleSwap returned error code ${response.status}`) + } + } + return await response.json() + } + + const { fromNetwork, toNetwork } = resolveNetworks(request) + + const { fromCurrencyCode, toCurrencyCode } = getCodesWithTranscription( + request, + MAINNET_CODE_TRANSCRIPTION, + CURRENCY_CODE_TRANSCRIPTION + ) + + const [fromAddress, toAddress] = await Promise.all([ + getAddress(fromWallet, addressTypeMap[fromWallet.currencyInfo.pluginId]), + getAddress(toWallet, addressTypeMap[toWallet.currencyInfo.pluginId]) + ]) + + const amountWallet = reverse ? toWallet : fromWallet + const amountTokenId = reverse ? request.toTokenId : request.fromTokenId + const amount = nativeToDenomination( + amountWallet, + request.nativeAmount, + amountTokenId + ) + + const pairQuery: StringMap = { + tickerFrom: fromCurrencyCode.toLowerCase(), + networkFrom: fromNetwork, + tickerTo: toCurrencyCode.toLowerCase(), + networkTo: toNetwork, + fixed: String(fixed), + reverse: String(reverse) + } + + const rangeJson = await call('GET', 'ranges', { query: pairQuery }) + const { result: range } = asRangeReply(rangeJson) + + // Native amounts are whole atomic units. Round a minimum up and a maximum + // down, so rounding never widens the range SimpleSwap actually accepts. + const limitDirection = reverse ? 'to' : 'from' + const nativeMin = ceil( + denominationToNative(amountWallet, range.min, amountTokenId), + 0 + ) + if (lt(request.nativeAmount, nativeMin)) { + throw new SwapBelowLimitError(swapInfo, nativeMin, limitDirection) + } + if (range.max != null) { + const nativeMax = floor( + denominationToNative(amountWallet, range.max, amountTokenId), + 0 + ) + if (gt(request.nativeAmount, nativeMax)) { + throw new SwapAboveLimitError(swapInfo, nativeMax, limitDirection) + } + } + + const estimateJson = await call('GET', 'estimates', { + query: { ...pairQuery, amount } + }) + const { result: estimate } = asEstimateReply(estimateJson) + + const createJson = await call('POST', 'exchanges', { + body: { + tickerFrom: pairQuery.tickerFrom, + networkFrom: pairQuery.networkFrom, + tickerTo: pairQuery.tickerTo, + networkTo: pairQuery.networkTo, + amount, + fixed, + reverse, + addressTo: toAddress, + userRefundAddress: fromAddress, + rateId: estimate.rateId + } + }) + // The order exists from here on, so EVERY failure below is fatal and + // `getQuote` must not retry the other flow: that would abandon this + // deposit and open a second live order. The boundary covers the response + // cleaner, the amount arithmetic and the spend construction alike, since a + // malformed amount makes biggystring throw an ordinary Error. + try { + const { result: exchange } = asCreatedExchangeReply(createJson) + + const fromNativeAmount = floor( + denominationToNative( + fromWallet, + exchange.amountFrom, + request.fromTokenId + ), + 0 + ) + const toNativeAmount = floor( + denominationToNative(toWallet, exchange.amountTo, request.toTokenId), + 0 + ) + + // Both amounts are echoed by SimpleSwap: `amountFrom` becomes the signed + // spend, `amountTo` is what the user is promised. The order was created + // from the locally pinned amount, so the echoed side must match it. Above + // it overspends; below it underpays a live order and the deposit never + // completes. + if (!reverse && !eq(fromNativeAmount, request.nativeAmount)) { + throw makeFatalError( + 'SimpleSwap returned a source amount that does not match the requested amount' + ) + } + if (reverse && lt(toNativeAmount, request.nativeAmount)) { + throw makeFatalError( + 'SimpleSwap returned a destination amount below the requested amount' + ) + } + + const memos: EdgeMemo[] = + exchange.extraIdFrom == null || exchange.extraIdFrom === '' + ? [] + : [ + { + type: memoType(fromWallet.currencyInfo.pluginId), + value: exchange.extraIdFrom + } + ] + + const spendInfo: EdgeSpendInfo = { + tokenId: request.fromTokenId, + spendTargets: [ + { + nativeAmount: fromNativeAmount, + publicAddress: exchange.addressFrom + } + ], + memos, + networkFeeOption: 'high', + assetAction: { + assetActionType: 'swap' + }, + savedAction: { + actionType: 'swap', + swapInfo, + orderId: exchange.id, + orderUri: orderUri + exchange.id, + isEstimate: !fixed, + toAsset: { + pluginId: toWallet.currencyInfo.pluginId, + tokenId: request.toTokenId, + nativeAmount: toNativeAmount + }, + fromAsset: { + pluginId: fromWallet.currencyInfo.pluginId, + tokenId: request.fromTokenId, + nativeAmount: fromNativeAmount + }, + payoutAddress: toAddress, + payoutWalletId: toWallet.id, + refundAddress: fromAddress + } + } + + const expirationDate = + estimate.validUntil != null + ? ensureInFuture(new Date(estimate.validUntil)) + : new Date(Date.now() + expirationMs) + + return { + request, + spendInfo, + swapInfo, + fromNativeAmount, + expirationDate + } + } catch (error: unknown) { + throw error instanceof Error && error.name === FATAL_ERROR_NAME + ? error + : makeFatalError( + `SimpleSwap created an order this quote could not use: ${String( + error + )}` + ) + } + } + + // How actionable an error is for the user, highest first: a limit error names + // the amount to retry with, a permission error names an account problem, and + // "unsupported pair" is the least specific. A transport or server error ranks + // below all of them, so one flow's outage never masks the other's real answer. + // edge-core-js error classes are transpiled, so `instanceof SwapCurrencyError` + // is unreliable — match by name. + const errorRank = (error: unknown): number => { + if (!(error instanceof Error)) return 0 + switch (error.name) { + case 'SwapBelowLimitError': + case 'SwapAboveLimitError': + return 3 + case 'SwapPermissionError': + return 2 + case 'SwapCurrencyError': + return 1 + default: + return 0 + } + } + + const getQuote = async ( + request: EdgeSwapRequestPlugin + ): Promise => { + // SimpleSwap rejects fixed-rate orders paying out to a memo/tag chain with + // a 500 that only the float flow gets past, so the retry is not limited to + // swap errors. Anything that fails BEFORE the order is created is safe to + // retry; once it exists every failure is fatal, so the retry can never + // abandon a live deposit and open a second one. + let fixedError: unknown + try { + return await getQuoteForFlow(request, true) + } catch (error: unknown) { + if (error instanceof Error && error.name === FATAL_ERROR_NAME) throw error + fixedError = error + } + try { + return await getQuoteForFlow(request, false) + } catch (error: unknown) { + throw errorRank(fixedError) >= errorRank(error) ? fixedError : error + } + } + + // `getMaxSwappable` probe: build a SwapOrder locally, targeting the user's + // own from-chain address, so `getMaxSpendable` can estimate fees WITHOUT + // creating (and abandoning) a live SimpleSwap order. The trimmed amount is + // then run through the real `getQuote`, which creates exactly one order. + const fetchProbeOrder = async ( + request: EdgeSwapRequestPlugin + ): Promise => { + resolveNetworks(request) + const fromAddress = await getAddress( + request.fromWallet, + addressTypeMap[request.fromWallet.currencyInfo.pluginId] + ) + const spendInfo: EdgeSpendInfo = { + tokenId: request.fromTokenId, + spendTargets: [ + { + nativeAmount: request.nativeAmount, + publicAddress: fromAddress + } + ], + networkFeeOption: 'high', + // Never broadcast; targeting the user's own address trips + // `SpendToSelfError` on engines whose public key is the address + // (every EVM chain). The real order below keeps all checks. + skipChecks: true, + assetAction: { + assetActionType: 'swap' + } + } + return { + request, + spendInfo, + swapInfo, + fromNativeAmount: request.nativeAmount, + expirationDate: new Date(Date.now() + expirationMs) + } + } + + const out: EdgeSwapPlugin = { + swapInfo, + + async fetchSwapQuote(req: EdgeSwapRequest): Promise { + const request = convertRequest(req) + + checkInvalidTokenIds(INVALID_TOKEN_IDS, request, swapInfo) + checkWhitelistedMainnetCodes( + MAINNET_CODE_TRANSCRIPTION, + request, + swapInfo + ) + + const newRequest = await getMaxSwappable(fetchProbeOrder, request) + const swapOrder = await getQuote(newRequest) + return await makeSwapPluginQuote(swapOrder) + } + } + + return out +} diff --git a/test/simpleswap.test.ts b/test/simpleswap.test.ts new file mode 100644 index 00000000..895553eb --- /dev/null +++ b/test/simpleswap.test.ts @@ -0,0 +1,680 @@ +// Run: mocha -r sucrase/register --extension ts test/simpleswap.test.ts +import { assert } from 'chai' +import { beforeEach, describe, it } from 'mocha' + +import { simpleswap } from '../src/mappings/simpleswap' +import { makeSimpleSwapPlugin } from '../src/swap/central/simpleswap' +import { edgeCurrencyPluginIds } from '../src/util/edgeCurrencyPluginIds' + +const BTC_MULTIPLIER = '100000000' // 1e8 +const ETH_MULTIPLIER = '1000000000000000000' // 1e18 + +interface FakeReply { + status: number + body: unknown +} +type FetchHandler = (url: string, method: string) => FakeReply + +// Records the spendInfo the plugin hands to makeSpend so tests can inspect the +// deposit address, memos and savedAction without running the on-chain approve. +let capturedSpendInfo: any + +const makeWallet = ( + pluginId: string, + currencyCode: string, + multiplier: string, + publicAddress: string, + id: string +): any => ({ + id, + currencyInfo: { + pluginId, + currencyCode, + denominations: [{ name: currencyCode, multiplier }] + }, + // Edge's SwapCurrencyError reads currencyConfig.currencyInfo.pluginId + currencyConfig: { currencyInfo: { pluginId }, allTokens: {} }, + balanceMap: new Map(), + getAddresses: async () => [{ addressType: 'publicAddress', publicAddress }], + makeSpend: async (spendInfo: any) => { + capturedSpendInfo = spendInfo + return { + savedAction: spendInfo.savedAction, + networkFee: '1000', + currencyCode, + txid: 'signed-txid' + } + } +}) + +const makeOpts = (handler: FetchHandler, apiKey = 'test-key'): any => ({ + initOptions: { apiKey }, + log: Object.assign(() => {}, { warn: () => {} }), + io: { + fetch: async (url: string, opts?: { method?: string }) => { + const method = opts?.method ?? 'GET' + const { status, body } = handler(url, method) + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body) + } + } + } +}) + +const okReplies = { + ranges: { result: { min: '0.001', max: '10' } }, + estimates: { + result: { + estimatedAmount: '0.05', + rateId: 'rate-1', + validUntil: '2030-01-01T00:00:00.000Z' + } + }, + exchanges: { + result: { + id: 'order-1', + addressFrom: 'deposit-addr', + amountFrom: '0.1', + amountTo: '0.05' + } + } +} + +const routeOf = (url: string): 'ranges' | 'estimates' | 'exchanges' => { + if (url.includes('/ranges')) return 'ranges' + if (url.includes('/estimates')) return 'estimates' + return 'exchanges' +} + +// Default handler: every hop succeeds. +const happyHandler: FetchHandler = url => ({ + status: 200, + body: okReplies[routeOf(url)] +}) + +const btcWallet = (): any => + makeWallet('bitcoin', 'BTC', BTC_MULTIPLIER, 'btc-refund-addr', 'btc-wallet') +const ethWallet = (): any => + makeWallet('ethereum', 'ETH', ETH_MULTIPLIER, 'eth-payout-addr', 'eth-wallet') + +const fromRequest = (): any => ({ + fromWallet: btcWallet(), + toWallet: ethWallet(), + fromTokenId: null, + toTokenId: null, + nativeAmount: '10000000', // 0.1 BTC + quoteFor: 'from' +}) + +// Assert on error.name rather than instanceof: mocha may load this test as ESM +// while the plugin is required as CJS, giving edge-core-js two class identities. +const expectError = async ( + promise: Promise, + expectedName: string +): Promise => { + try { + await promise + assert.fail(`Expected ${expectedName} to be thrown`) + } catch (error: any) { + const name = error?.name ?? error?.constructor?.name + assert.equal(name, expectedName) + } +} + +describe('simpleswap mapping', function () { + it('maps majors to SimpleSwap network codes', function () { + assert.equal(simpleswap.get('bitcoin'), 'btc') + assert.equal(simpleswap.get('ethereum'), 'eth') + assert.equal(simpleswap.get('binancesmartchain'), 'bsc') + assert.equal(simpleswap.get('solana'), 'sol') + assert.equal(simpleswap.get('litecoin'), 'ltc') + assert.equal(simpleswap.get('tron'), 'trx') + }) + + it('maps unsupported chains and testnets to null (fail-loud, not mis-routed)', function () { + assert.isNull(simpleswap.get('rsk')) + assert.isNull(simpleswap.get('mayachain')) + assert.isNull(simpleswap.get('bitcointestnet')) + assert.isNull(simpleswap.get('sepolia')) + }) + + it('covers every EdgeCurrencyPluginId', function () { + const unmappedPluginIds = edgeCurrencyPluginIds.filter( + pluginId => !simpleswap.has(pluginId) + ) + assert.deepEqual(unmappedPluginIds, []) + }) +}) + +describe('makeSimpleSwapPlugin.fetchSwapQuote', function () { + beforeEach(function () { + capturedSpendInfo = undefined + }) + + it('quotes a fixed-rate "from" swap and builds the spend', async function () { + const plugin = makeSimpleSwapPlugin(makeOpts(happyHandler)) + const quote = await plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any) + + assert.equal(quote.pluginId, 'simpleswap') + assert.equal(quote.fromNativeAmount, '10000000') // 0.1 BTC + assert.equal(quote.toNativeAmount, '50000000000000000') // 0.05 ETH + assert.equal(quote.isEstimate, false) // fixed-rate is locked + + const target = capturedSpendInfo.spendTargets[0] + assert.equal(target.publicAddress, 'deposit-addr') + assert.equal(target.nativeAmount, '10000000') + + const action = capturedSpendInfo.savedAction + assert.equal(action.orderId, 'order-1') + assert.equal(action.payoutAddress, 'eth-payout-addr') + assert.equal(action.refundAddress, 'btc-refund-addr') + assert.equal(action.isEstimate, false) + }) + + it('quotes a reverse "to" swap', async function () { + const request = fromRequest() + request.quoteFor = 'to' + request.nativeAmount = '50000000000000000' // 0.05 ETH + + const plugin = makeSimpleSwapPlugin(makeOpts(happyHandler)) + const quote = await plugin.fetchSwapQuote(request, undefined, { + infoPayload: {} + } as any) + + assert.equal(quote.fromNativeAmount, '10000000') + assert.equal(quote.toNativeAmount, '50000000000000000') + }) + + it('attaches a memo when the deposit address needs an extra id', async function () { + const handler: FetchHandler = url => { + if (routeOf(url) === 'exchanges') { + return { + status: 200, + body: { + result: { + id: 'order-2', + addressFrom: 'xrp-deposit', + extraIdFrom: 'tag-123', + amountFrom: '0.1', + amountTo: '0.05' + } + } + } + } + return { status: 200, body: okReplies[routeOf(url)] } + } + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + await plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any) + + assert.deepEqual(capturedSpendInfo.memos, [ + { type: 'text', value: 'tag-123' } + ]) + }) + + it('throws SwapBelowLimitError under the range minimum', async function () { + const handler: FetchHandler = url => + routeOf(url) === 'ranges' + ? { status: 200, body: { result: { min: '1000', max: '10000' } } } + : { status: 200, body: okReplies[routeOf(url)] } + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + await expectError( + plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any), + 'SwapBelowLimitError' + ) + }) + + it('throws SwapAboveLimitError over the range maximum', async function () { + const handler: FetchHandler = url => + routeOf(url) === 'ranges' + ? { status: 200, body: { result: { min: '0.0001', max: '0.01' } } } + : { status: 200, body: okReplies[routeOf(url)] } + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + await expectError( + plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any), + 'SwapAboveLimitError' + ) + }) + + it('falls back to floating when the pair is unavailable fixed', async function () { + const handler: FetchHandler = url => { + if (url.includes('fixed=true')) return { status: 404, body: {} } + if (routeOf(url) === 'estimates') { + // Floating estimate carries no rateId + return { + status: 200, + body: { result: { estimatedAmount: '0.05' } } + } + } + return { status: 200, body: okReplies[routeOf(url)] } + } + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + const quote = await plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any) + + assert.equal(quote.isEstimate, true) // floating is an estimate + assert.equal(capturedSpendInfo.savedAction.isEstimate, true) + }) + + it('clamps a stale validUntil into the future', async function () { + const handler: FetchHandler = url => { + if (routeOf(url) === 'estimates') { + return { + status: 200, + body: { + result: { + estimatedAmount: '0.05', + rateId: 'rate-1', + validUntil: '2020-01-01T00:00:00.000Z' // already expired + } + } + } + } + return { status: 200, body: okReplies[routeOf(url)] } + } + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + const quote = await plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any) + + assert.isDefined(quote.expirationDate) + assert.isAtLeast( + quote.expirationDate?.valueOf() ?? 0, + Date.now() + 25 * 1000 + ) + }) + + it('falls back to floating when the amount is below the fixed-rate minimum', async function () { + const handler: FetchHandler = url => { + if (routeOf(url) === 'ranges' && url.includes('fixed=true')) { + // Fixed-rate minimum above the requested 0.1 BTC + return { status: 200, body: { result: { min: '5', max: '10' } } } + } + if (routeOf(url) === 'estimates') { + return { status: 200, body: { result: { estimatedAmount: '0.05' } } } + } + return { status: 200, body: okReplies[routeOf(url)] } + } + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + const quote = await plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any) + + assert.equal(quote.isEstimate, true) // served by the floating flow + }) + + it('surfaces the fixed-rate limit error when floating lacks the pair', async function () { + const handler: FetchHandler = url => { + if (routeOf(url) === 'ranges') { + if (url.includes('fixed=true')) { + return { status: 200, body: { result: { min: '5', max: '10' } } } + } + return { status: 404, body: {} } + } + return { status: 200, body: okReplies[routeOf(url)] } + } + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + try { + await plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any) + assert.fail('expected a limit error') + } catch (error: unknown) { + assert.equal((error as Error).name, 'SwapBelowLimitError') + } + }) + + it('maps a 403 to SwapPermissionError (unverified key)', async function () { + const handler: FetchHandler = () => ({ status: 403, body: {} }) + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + await expectError( + plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any), + 'SwapPermissionError' + ) + }) + + it('throws SwapCurrencyError for an unmapped chain before any request', async function () { + let fetched = false + const handler: FetchHandler = () => { + fetched = true + return { status: 200, body: {} } + } + const request = fromRequest() + request.fromWallet = makeWallet( + 'rsk', + 'RBTC', + ETH_MULTIPLIER, + 'rsk-addr', + 'rsk-wallet' + ) + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + await expectError( + plugin.fetchSwapQuote(request, undefined, { infoPayload: {} } as any), + 'SwapCurrencyError' + ) + assert.equal(fetched, false) + }) + + it('throws at factory time when apiKey is empty', function () { + assert.throws( + () => makeSimpleSwapPlugin(makeOpts(happyHandler, '')), + 'missing apiKey' + ) + }) + + it('creates exactly one order for a "max" quote (probe is quote-only)', async function () { + const exchangeCalls: string[] = [] + const handler: FetchHandler = (url, method) => { + if (method === 'POST') exchangeCalls.push(url) + if (routeOf(url) === 'exchanges') { + return { + status: 200, + body: { + result: { + id: 'order-max', + addressFrom: 'deposit-addr', + amountFrom: '0.19', // the trimmed max the plugin sent + amountTo: '0.05' + } + } + } + } + return { status: 200, body: okReplies[routeOf(url)] } + } + const request = fromRequest() + request.quoteFor = 'max' + request.fromWallet.balanceMap.set(null, '20000000') // 0.2 BTC on-chain + let probeSpendInfo: any + request.fromWallet.getMaxSpendable = async ( + spendInfo: any + ): Promise => { + probeSpendInfo = spendInfo + return '19000000' // balance minus fees + } + + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + const quote = await plugin.fetchSwapQuote(request, undefined, { + infoPayload: {} + } as any) + + assert.equal(quote.fromNativeAmount, '19000000') // the trimmed max + assert.equal(exchangeCalls.length, 1) + // The probe targets the user's own address and is never broadcast; + // without skipChecks EVM engines reject it with SpendToSelfError. + assert.equal(probeSpendInfo.skipChecks, true) + }) + + it('falls back to floating when the fixed flow fails on a server error', async function () { + // SimpleSwap rejects a fixed-rate order paying out to a memo/tag chain + // with `500 check extra_id`; only the floating flow gets past it. + const handler: FetchHandler = url => { + if (url.includes('fixed=true') && routeOf(url) !== 'ranges') { + return { status: 500, body: { message: 'check extra_id' } } + } + if (routeOf(url) === 'estimates') { + return { status: 200, body: { result: { estimatedAmount: '0.05' } } } + } + return { status: 200, body: okReplies[routeOf(url)] } + } + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + const quote = await plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any) + + assert.equal(quote.isEstimate, true) // served by the floating flow + }) + + it('surfaces the fixed flow SwapCurrencyError over a floating server error', async function () { + const handler: FetchHandler = url => { + if (routeOf(url) === 'ranges') { + return url.includes('fixed=true') + ? { status: 404, body: {} } // pair unavailable fixed + : { status: 500, body: {} } // floating side is just broken + } + return { status: 200, body: okReplies[routeOf(url)] } + } + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + await expectError( + plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any), + 'SwapCurrencyError' + ) + }) + + it('rounds provider amounts to whole atomic units', async function () { + const handler: FetchHandler = url => + routeOf(url) === 'exchanges' + ? { + status: 200, + body: { + result: { + id: 'order-3', + addressFrom: 'deposit-addr', + amountFrom: '0.1', + // More decimals than ETH's denomination holds + amountTo: '0.0500000000000000005' + } + } + } + : { status: 200, body: okReplies[routeOf(url)] } + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + const quote = await plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any) + + assert.equal(quote.toNativeAmount, '50000000000000000') + }) + + it('rounds a range minimum up, never below what SimpleSwap accepts', async function () { + const handler: FetchHandler = url => + routeOf(url) === 'ranges' + ? { + status: 200, + // 0.100000001 BTC = 10000000.1 native, just above the request + body: { result: { min: '0.100000001', max: '10' } } + } + : { status: 200, body: okReplies[routeOf(url)] } + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + try { + await plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any) + assert.fail('expected SwapBelowLimitError') + } catch (error: unknown) { + assert.equal((error as Error).name, 'SwapBelowLimitError') + assert.equal((error as any).nativeMin, '10000001') + } + }) + + it('rejects a source amount above the requested amount', async function () { + const exchangeCalls: string[] = [] + const handler: FetchHandler = (url, method) => { + if (method === 'POST') exchangeCalls.push(url) + return routeOf(url) === 'exchanges' + ? { + status: 200, + body: { + result: { + id: 'order-4', + addressFrom: 'deposit-addr', + amountFrom: '0.2', // double what the request pinned + amountTo: '0.05' + } + } + } + : { status: 200, body: okReplies[routeOf(url)] } + } + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + try { + await plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any) + assert.fail('expected the source amount to be rejected') + } catch (error: unknown) { + assert.include( + (error as Error).message, + 'source amount that does not match the requested amount' + ) + } + // The fixed order already exists, so the float flow must not run and + // abandon it for a second one. + assert.equal(exchangeCalls.length, 1) + }) + + it('keeps a numeric deposit tag, including tag 0', async function () { + const makeHandler = (extraIdFrom: unknown): FetchHandler => url => + routeOf(url) === 'exchanges' + ? { + status: 200, + body: { + result: { + id: 'order-5', + addressFrom: 'xrp-deposit', + extraIdFrom, + amountFrom: '0.1', + amountTo: '0.05' + } + } + } + : { status: 200, body: okReplies[routeOf(url)] } + + // SimpleSwap sends the tag as a JSON number on some chains + let plugin = makeSimpleSwapPlugin(makeOpts(makeHandler(668522715))) + await plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any) + assert.deepEqual(capturedSpendInfo.memos, [ + { type: 'text', value: '668522715' } + ]) + + // Tag 0 is a real tag, not an absent one + plugin = makeSimpleSwapPlugin(makeOpts(makeHandler(0))) + await plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any) + assert.deepEqual(capturedSpendInfo.memos, [{ type: 'text', value: '0' }]) + + // An empty tag is absent, not an empty memo + plugin = makeSimpleSwapPlugin(makeOpts(makeHandler(''))) + await plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any) + assert.deepEqual(capturedSpendInfo.memos, []) + }) + + it('never opens a second order when the created one cannot be read', async function () { + const exchangeCalls: string[] = [] + const handler: FetchHandler = (url, method) => { + if (method === 'POST') exchangeCalls.push(url) + return routeOf(url) === 'exchanges' + ? { status: 200, body: { result: { id: 'order-6' } } } // missing fields + : { status: 200, body: okReplies[routeOf(url)] } + } + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + await expectError( + plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any), + 'SimpleSwapFatalError' + ) + assert.equal(exchangeCalls.length, 1) + }) + + it('never opens a second order when a created amount is unparseable', async function () { + const exchangeCalls: string[] = [] + const handler: FetchHandler = (url, method) => { + if (method === 'POST') exchangeCalls.push(url) + return routeOf(url) === 'exchanges' + ? { + status: 200, + body: { + result: { + id: 'order-8', + addressFrom: 'deposit-addr', + amountFrom: 'not-a-number', // passes asNumberString, breaks biggystring + amountTo: '0.05' + } + } + } + : { status: 200, body: okReplies[routeOf(url)] } + } + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + await expectError( + plugin.fetchSwapQuote(fromRequest(), undefined, { + infoPayload: {} + } as any), + 'SimpleSwapFatalError' + ) + assert.equal(exchangeCalls.length, 1) + }) + + it('rejects a destination amount below the requested amount on a reverse quote', async function () { + const request = fromRequest() + request.quoteFor = 'to' + request.nativeAmount = '50000000000000000' // 0.05 ETH pinned by the user + const handler: FetchHandler = url => + routeOf(url) === 'exchanges' + ? { + status: 200, + body: { + result: { + id: 'order-7', + addressFrom: 'deposit-addr', + amountFrom: '0.1', + amountTo: '0.04' // under-delivers the pinned receive amount + } + } + } + : { status: 200, body: okReplies[routeOf(url)] } + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + try { + await plugin.fetchSwapQuote(request, undefined, { + infoPayload: {} + } as any) + assert.fail('expected the destination amount to be rejected') + } catch (error: unknown) { + assert.include( + (error as Error).message, + 'destination amount below the requested amount' + ) + } + }) + + it('transcribes Edge currency codes to SimpleSwap tickers (BNB -> bnb-bsc)', async function () { + const requestedUrls: string[] = [] + const handler: FetchHandler = url => { + requestedUrls.push(url) + return { status: 200, body: okReplies[routeOf(url)] } + } + const request = fromRequest() + request.fromWallet = makeWallet( + 'binancesmartchain', + 'BNB', + ETH_MULTIPLIER, + 'bsc-refund-addr', + 'bsc-wallet' + ) + request.nativeAmount = '100000000000000000' // 0.1 BNB + const plugin = makeSimpleSwapPlugin(makeOpts(handler)) + await plugin.fetchSwapQuote(request, undefined, { infoPayload: {} } as any) + + const rangesUrl = requestedUrls.find(url => url.includes('/ranges')) + assert.include(rangesUrl, 'tickerFrom=bnb-bsc') + assert.include(rangesUrl, 'networkFrom=bsc') + assert.include(rangesUrl, 'tickerTo=eth') + }) +})