From 9630153841455f3b3a6a7075cdcc815b32d829b2 Mon Sep 17 00:00:00 2001 From: Andrey Kr Date: Tue, 4 Aug 2026 12:20:47 +0300 Subject: [PATCH 1/4] Add SimpleSwap swap support --- CHANGELOG.md | 2 + scripts/allSynchronizers.ts | 2 + scripts/mapctlConfig.ts | 1 + scripts/mappings/simpleswapMappings.ts | 1622 +++++++++++++++++ .../simpleswap/simpleswapSynchronizer.ts | 78 + .../simpleswap/simpleswapTypes.ts | 10 + src/index.ts | 2 + src/mappings/simpleswap.ts | 101 + src/swap/central/simpleswap.ts | 397 ++++ test/simpleswap.test.ts | 362 ++++ 10 files changed, 2577 insertions(+) create mode 100644 scripts/mappings/simpleswapMappings.ts create mode 100644 scripts/synchronizers/simpleswap/simpleswapSynchronizer.ts create mode 100644 scripts/synchronizers/simpleswap/simpleswapTypes.ts create mode 100644 src/mappings/simpleswap.ts create mode 100644 src/swap/central/simpleswap.ts create mode 100644 test/simpleswap.test.ts 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..52d81796 --- /dev/null +++ b/src/swap/central/simpleswap.ts @@ -0,0 +1,397 @@ +import { 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, + 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, + extraIdFrom: asOptional(asString), + amountFrom: asNumberString, + amountTo: asNumberString +}) +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) + + const limitDirection = reverse ? 'to' : 'from' + const nativeMin = denominationToNative( + amountWallet, + range.min, + amountTokenId + ) + if (lt(request.nativeAmount, nativeMin)) { + throw new SwapBelowLimitError(swapInfo, nativeMin, limitDirection) + } + if (range.max != null) { + const nativeMax = denominationToNative( + amountWallet, + range.max, + amountTokenId + ) + 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 + } + }) + const { result: exchange } = asCreatedExchangeReply(createJson) + + const fromNativeAmount = denominationToNative( + fromWallet, + exchange.amountFrom, + request.fromTokenId + ) + const toNativeAmount = denominationToNative( + toWallet, + exchange.amountTo, + request.toTokenId + ) + + const memos: EdgeMemo[] = + exchange.extraIdFrom == null + ? [] + : [ + { + 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 + ? new Date(estimate.validUntil) + : new Date(Date.now() + expirationMs) + + return { + request, + spendInfo, + swapInfo, + fromNativeAmount, + expirationDate + } + } + + // edge-core-js error classes are transpiled, `instanceof SwapCurrencyError` + // is unreliable — match by name + const getQuote = async ( + request: EdgeSwapRequestPlugin + ): Promise => { + try { + return await getQuoteForFlow(request, true) + } catch (error) { + if (error instanceof Error && error.name === 'SwapCurrencyError') { + return await getQuoteForFlow(request, false) + } + throw 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..9784a108 --- /dev/null +++ b/test/simpleswap.test.ts @@ -0,0 +1,362 @@ +// 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('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) + 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, '10000000') // from create response + 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('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') + }) +}) From bc672dedc2894d8359cb9d31c73e9fbf2b215a99 Mon Sep 17 00:00:00 2001 From: Andrey Kr Date: Tue, 4 Aug 2026 12:54:48 +0300 Subject: [PATCH 2/4] Fall back to a float rate on fixed-rate limit errors A fixed-rate quote that failed the range check never tried the float flow, whose limits can be wider, so users were blocked from a possible swap. Float is now attempted after any currency or limit error from the fixed flow; if the float flow lacks the pair entirely, the more actionable fixed-rate limit error is surfaced instead. Also types the catch clauses as unknown. --- src/swap/central/simpleswap.ts | 26 +++++++++++++++++++--- test/simpleswap.test.ts | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/swap/central/simpleswap.ts b/src/swap/central/simpleswap.ts index 52d81796..8942c1f5 100644 --- a/src/swap/central/simpleswap.ts +++ b/src/swap/central/simpleswap.ts @@ -323,14 +323,34 @@ export function makeSimpleSwapPlugin( // edge-core-js error classes are transpiled, `instanceof SwapCurrencyError` // is unreliable — match by name + const isFallbackWorthy = (error: unknown): boolean => + error instanceof Error && + (error.name === 'SwapCurrencyError' || + error.name === 'SwapBelowLimitError' || + error.name === 'SwapAboveLimitError') + const getQuote = async ( request: EdgeSwapRequestPlugin ): Promise => { + let fixedError: unknown try { return await getQuoteForFlow(request, true) - } catch (error) { - if (error instanceof Error && error.name === 'SwapCurrencyError') { - return await getQuoteForFlow(request, false) + } catch (error: unknown) { + if (!isFallbackWorthy(error)) throw error + fixedError = error + } + try { + return await getQuoteForFlow(request, false) + } catch (error: unknown) { + // A fixed-rate limit error is more actionable than "pair unsupported" + // from the float flow, which merely lacks the pair. + if ( + error instanceof Error && + error.name === 'SwapCurrencyError' && + fixedError instanceof Error && + fixedError.name !== 'SwapCurrencyError' + ) { + throw fixedError } throw error } diff --git a/test/simpleswap.test.ts b/test/simpleswap.test.ts index 9784a108..3cfd6ff9 100644 --- a/test/simpleswap.test.ts +++ b/test/simpleswap.test.ts @@ -267,6 +267,46 @@ describe('makeSimpleSwapPlugin.fetchSwapQuote', function () { assert.equal(capturedSpendInfo.savedAction.isEstimate, true) }) + 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)) From 3ed8a3ac33fdc6d81f4f7d9dc9fcb2ec3d75c931 Mon Sep 17 00:00:00 2001 From: Andrey Kr Date: Tue, 4 Aug 2026 13:20:35 +0300 Subject: [PATCH 3/4] Clamp quote expiration into the future Pass the estimate's validUntil through ensureInFuture so a stale or clock-skewed timestamp can't produce an already-expired quote. --- src/swap/central/simpleswap.ts | 3 ++- test/simpleswap.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/swap/central/simpleswap.ts b/src/swap/central/simpleswap.ts index 8942c1f5..72330000 100644 --- a/src/swap/central/simpleswap.ts +++ b/src/swap/central/simpleswap.ts @@ -21,6 +21,7 @@ import { checkWhitelistedMainnetCodes, CurrencyCodeTranscriptionMap, CurrencyPluginIdSwapChainCodeMap, + ensureInFuture, getCodesWithTranscription, getMaxSwappable, InvalidTokenIds, @@ -309,7 +310,7 @@ export function makeSimpleSwapPlugin( const expirationDate = estimate.validUntil != null - ? new Date(estimate.validUntil) + ? ensureInFuture(new Date(estimate.validUntil)) : new Date(Date.now() + expirationMs) return { diff --git a/test/simpleswap.test.ts b/test/simpleswap.test.ts index 3cfd6ff9..24a17602 100644 --- a/test/simpleswap.test.ts +++ b/test/simpleswap.test.ts @@ -267,6 +267,34 @@ describe('makeSimpleSwapPlugin.fetchSwapQuote', function () { 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')) { From f3c53158302480abdb795bcc4df6d3173e7304b2 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 19 Aug 2026 15:04:25 -0700 Subject: [PATCH 4/4] Harden the SimpleSwap plugin against provider-side failures Four defects found by driving the plugin against SimpleSwap's live API: - Fixed-rate order creation returns HTTP 500 "check extra_id" for any destination chain whose currency record carries hasExtraId (XRP, XLM and every other tag chain). Edge pays out to the user's own non-custodial wallet and has no destination tag to send, and an empty or null extraIdTo is rejected the same way, so the float flow is the only one those pairs can use. The retry now covers any fixed-flow failure rather than swap errors alone. - A server error from the float flow masked the fixed flow's clean SwapCurrencyError, so an unsupported pair surfaced as "SimpleSwap returned error code 500". Both errors are now ranked and the more actionable one is thrown. - denominationToNative results reached the core unrounded, so a provider amount carrying more decimals than the asset's denomination produced a fractional native amount. Minimums round up, maximums and receive amounts round down. - The provider-echoed amountFrom drove the spend with no bound against the requested amount. It is now bounded, and the failure is fatal rather than retryable so the created order is never abandoned for a second one. --- src/swap/central/simpleswap.ts | 236 ++++++++++++++++++------------ test/simpleswap.test.ts | 252 ++++++++++++++++++++++++++++++++- 2 files changed, 395 insertions(+), 93 deletions(-) diff --git a/src/swap/central/simpleswap.ts b/src/swap/central/simpleswap.ts index 72330000..22249b02 100644 --- a/src/swap/central/simpleswap.ts +++ b/src/swap/central/simpleswap.ts @@ -1,4 +1,4 @@ -import { gt, lt } from 'biggystring' +import { ceil, eq, floor, gt, lt } from 'biggystring' import { asEither, asNull, asObject, asOptional, asString } from 'cleaners' import { EdgeCorePluginOptions, @@ -90,10 +90,21 @@ const asEstimate = asObject({ const asCreatedExchange = asObject({ id: asString, addressFrom: asString, - extraIdFrom: asOptional(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 }) @@ -211,20 +222,20 @@ export function makeSimpleSwapPlugin( 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 = denominationToNative( - amountWallet, - range.min, - amountTokenId + 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 = denominationToNative( - amountWallet, - range.max, - amountTokenId + const nativeMax = floor( + denominationToNative(amountWallet, range.max, amountTokenId), + 0 ) if (gt(request.nativeAmount, nativeMax)) { throw new SwapAboveLimitError(swapInfo, nativeMax, limitDirection) @@ -250,110 +261,151 @@ export function makeSimpleSwapPlugin( rateId: estimate.rateId } }) - const { result: exchange } = asCreatedExchangeReply(createJson) - - const fromNativeAmount = denominationToNative( - fromWallet, - exchange.amountFrom, - request.fromTokenId - ) - const toNativeAmount = denominationToNative( - toWallet, - exchange.amountTo, - request.toTokenId - ) + // 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 + ) - const memos: EdgeMemo[] = - exchange.extraIdFrom == null - ? [] - : [ - { - type: memoType(fromWallet.currencyInfo.pluginId), - value: exchange.extraIdFrom - } - ] + // 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 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 + 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' }, - payoutAddress: toAddress, - payoutWalletId: toWallet.id, - refundAddress: fromAddress + 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) + const expirationDate = + estimate.validUntil != null + ? ensureInFuture(new Date(estimate.validUntil)) + : new Date(Date.now() + expirationMs) - return { - request, - spendInfo, - swapInfo, - fromNativeAmount, - expirationDate + 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 + )}` + ) } } - // edge-core-js error classes are transpiled, `instanceof SwapCurrencyError` - // is unreliable — match by name - const isFallbackWorthy = (error: unknown): boolean => - error instanceof Error && - (error.name === 'SwapCurrencyError' || - error.name === 'SwapBelowLimitError' || - error.name === 'SwapAboveLimitError') + // 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 (!isFallbackWorthy(error)) throw error + if (error instanceof Error && error.name === FATAL_ERROR_NAME) throw error fixedError = error } try { return await getQuoteForFlow(request, false) } catch (error: unknown) { - // A fixed-rate limit error is more actionable than "pair unsupported" - // from the float flow, which merely lacks the pair. - if ( - error instanceof Error && - error.name === 'SwapCurrencyError' && - fixedError instanceof Error && - fixedError.name !== 'SwapCurrencyError' - ) { - throw fixedError - } - throw error + throw errorRank(fixedError) >= errorRank(error) ? fixedError : error } } diff --git a/test/simpleswap.test.ts b/test/simpleswap.test.ts index 24a17602..895553eb 100644 --- a/test/simpleswap.test.ts +++ b/test/simpleswap.test.ts @@ -379,6 +379,19 @@ describe('makeSimpleSwapPlugin.fetchSwapQuote', 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() @@ -397,13 +410,250 @@ describe('makeSimpleSwapPlugin.fetchSwapQuote', function () { infoPayload: {} } as any) - assert.equal(quote.fromNativeAmount, '10000000') // from create response + 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 => {