diff --git a/src/v3/providers/allProviders.ts b/src/v3/providers/allProviders.ts index 542cfab..46626aa 100644 --- a/src/v3/providers/allProviders.ts +++ b/src/v3/providers/allProviders.ts @@ -3,6 +3,7 @@ import { coingecko } from './coingecko/coingecko' import { coinmarketcap } from './coinmarketcap/coinmarketcap' // import { coinmonitor } from './coinmonitor' import { coinstore } from './coinstore' +import { constantRates } from './constantRates' import { couch } from './couch' import { currencyconverter } from './currencyconverter' import { edgerates } from './edgerates/edgerates' @@ -19,6 +20,7 @@ const looselyOrderedProviders: RateProvider[] = [ wazirx, coinmarketcap, coingecko, + constantRates, currencyconverter, couch, redis diff --git a/src/v3/providers/coingecko/coingecko.ts b/src/v3/providers/coingecko/coingecko.ts index 4968a37..71c3abb 100644 --- a/src/v3/providers/coingecko/coingecko.ts +++ b/src/v3/providers/coingecko/coingecko.ts @@ -16,11 +16,11 @@ import { type RateEngine, type RateProvider, type TokenMap, - type TokenTypeMap, wasCrossChainDoc, wasExistingMappings } from '../../types' import { + create30MinuteSyncInterval, createTokenId, expandReturnedCryptoRates, isCurrent, @@ -74,7 +74,7 @@ const asCoingeckoAssetResponse = asArray( const asGeckoBulkUsdResponse = asObject( asObject({ - usd: asNumber + usd: asMaybe(asNumber) }) ) @@ -111,15 +111,9 @@ const platformIdMappingSyncDoc = syncedDocument( 'coingecko:platforms', asStringNullMap ) -manualTokenMappingsSyncDoc.sync(dbSettings).catch(e => { - console.error('manualTokenMappingsSyncDoc sync error', e) -}) -automatedTokenMappingsSyncDoc.sync(dbSettings).catch(e => { - console.error('automatedTokenMappingsSyncDoc sync error', e) -}) -platformIdMappingSyncDoc.sync(dbSettings).catch(e => { - console.error('platformIdMappingSyncDoc sync error', e) -}) +create30MinuteSyncInterval(manualTokenMappingsSyncDoc, dbSettings) +create30MinuteSyncInterval(automatedTokenMappingsSyncDoc, dbSettings) +create30MinuteSyncInterval(platformIdMappingSyncDoc, dbSettings) manualTokenMappingsSyncDoc.onChange(manualMappings => { coingeckoTokenIdMap = { ...automatedTokenMappingsSyncDoc.doc, @@ -133,25 +127,40 @@ automatedTokenMappingsSyncDoc.onChange(automatedMappings => { } }) -const coingeckoToCrossChainMapping = async ( - coingeckoAssets: ReturnType, - tokenTypes: TokenTypeMap -): Promise => { +const tokenMapping: RateEngine = async () => { + const uidMapping: TokenMap = {} + const crossChainMapping: CrossChainMapping = {} + + // Add the mainnet currency mapping + for (const [key, value] of Object.entries(coingeckoMainnetCurrencyMapping)) { + if (value === null) continue + uidMapping[key] = { + id: value, + displayName: key + } + } + + const json = await fetchCoingecko( + `${config.providers.coingeckopro.uri}/api/v3/coins/list?include_platform=true` + ) + const tokenTypes = asCouchDoc(asTokenTypeMap)( + await dbSettings.get(TOKEN_TYPES_KEY) + ) + + const data = asCoingeckoAssetResponse(json) + const invertPlatformMapping: Record = {} for (const [key, value] of Object.entries(platformIdMappingSyncDoc.doc)) { if (value === null) continue invertPlatformMapping[value] = key } + const platformPriorityDoc = await dbSettings.get('platformPriority') const platformPriority = asCouchDoc(asNumberMap)(platformPriorityDoc).doc const getPriority = (k: string): number => platformPriority[k] ?? Number.MAX_SAFE_INTEGER - const out: CrossChainMapping = {} - - for (const asset of coingeckoAssets) { - let destAsset: { destChain: string; edgeTokenId: string } | undefined - + for (const asset of data) { const platforms = Object.entries(asset.platforms) const sortedPlatforms = platforms.sort( @@ -160,24 +169,32 @@ const coingeckoToCrossChainMapping = async ( getPriority(invertPlatformMapping[b[0]]) ) + let destAsset: { destChain: string; edgeTokenId: string } | undefined + for (const [platform, address] of sortedPlatforms) { const edgePluginId = invertPlatformMapping[platform] if (edgePluginId == null) continue - const tokenType = tokenTypes[edgePluginId] + const tokenType = tokenTypes.doc[edgePluginId] if (tokenType == null) continue try { const tokenId = createTokenId(tokenType, asset.symbol, address) if (tokenId == null) continue + // Build cross-chain mappings and track the best platform if (destAsset == null) { destAsset = { destChain: edgePluginId, edgeTokenId: tokenId } + // Create UID mapping for the best (first) platform + uidMapping[toCryptoKey({ pluginId: edgePluginId, tokenId })] = { + id: asset.id, + displayName: asset.name + } } else { - out[`${edgePluginId}_${tokenId}`] = { + crossChainMapping[`${edgePluginId}_${tokenId}`] = { sourceChain: edgePluginId, destChain: destAsset.destChain, currencyCode: asset.symbol, @@ -191,67 +208,9 @@ const coingeckoToCrossChainMapping = async ( } } - return out -} - -const tokenMapping: RateEngine = async () => { - const mapping: TokenMap = {} - - // Add the mainnet currency mapping - for (const [key, value] of Object.entries(coingeckoMainnetCurrencyMapping)) { - if (value === null) continue - mapping[key] = { - id: value, - displayName: key - } - } - - const json = await fetchCoingecko( - `${config.providers.coingeckopro.uri}/api/v3/coins/list?include_platform=true` - ) - const tokenTypes = asCouchDoc(asTokenTypeMap)( - await dbSettings.get(TOKEN_TYPES_KEY) - ) - - const data = asCoingeckoAssetResponse(json) - - const invertPlatformMapping: Record = {} - for (const [key, value] of Object.entries(platformIdMappingSyncDoc.doc)) { - if (value === null) continue - invertPlatformMapping[value] = key - } - - for (const asset of data) { - const firstPlatform: [string, string] | undefined = Object.entries( - asset.platforms - )[0] - if (firstPlatform == null) continue - - const [platform, contractAddress] = firstPlatform - - const pluginId = invertPlatformMapping[platform] - if (pluginId == null) continue - - try { - const tokenId = createTokenId( - tokenTypes.doc[pluginId], - asset.symbol, - contractAddress - ) - if (tokenId == null) continue - - mapping[toCryptoKey({ pluginId, tokenId })] = { - id: asset.id, - displayName: asset.name - } - } catch (e) { - // skip assets that we cannot create token id for - } - } - const combinedTokenMappings: TokenMap = { ...automatedTokenMappingsSyncDoc.doc, - ...mapping + ...uidMapping } await dbSettings.insert( @@ -262,17 +221,15 @@ const tokenMapping: RateEngine = async () => { }) ) - const crossChainDocument = await dbSettings.get('crosschain:automated') - const crossChainDoc = asCrossChainDoc(crossChainDocument) - const crossChainMappings = await coingeckoToCrossChainMapping( - data, - tokenTypes.doc as TokenTypeMap - ) + const crossChainDefaultDocument = await dbSettings.get('crosschain') + const crossChainDefaultDoc = asCrossChainDoc(crossChainDefaultDocument) + const crossChainAutoDocument = await dbSettings.get('crosschain:automated') + const crossChainAutoDoc = asCrossChainDoc(crossChainAutoDocument) await dbSettings.insert( wasCrossChainDoc({ - id: crossChainDoc.id, - rev: crossChainDoc.rev, - doc: crossChainMappings + id: crossChainAutoDoc.id, + rev: crossChainAutoDoc.rev, + doc: { ...crossChainMapping, ...crossChainDefaultDoc.doc } }) ) } @@ -287,7 +244,9 @@ const getCurrentRates = async (ids: Set): Promise => { ) const data = asGeckoBulkUsdResponse(json) for (const [key, value] of Object.entries(data)) { - out[key] = value.usd + if (value.usd != null) { + out[key] = value.usd + } } } catch (e) { console.error('coingecko current query error:', e) diff --git a/src/v3/providers/coingecko/defaultPluginIdMapping.ts b/src/v3/providers/coingecko/defaultPluginIdMapping.ts index fe43c6a..66ceb54 100644 --- a/src/v3/providers/coingecko/defaultPluginIdMapping.ts +++ b/src/v3/providers/coingecko/defaultPluginIdMapping.ts @@ -1,6 +1,7 @@ import type { StringNullMap } from '../../types' export const coingeckoMainnetCurrencyMapping: StringNullMap = { + abstract: 'ethereum', algorand: 'algorand', arbitrum: 'ethereum', avalanche: 'avalanche-2', @@ -13,6 +14,7 @@ export const coingeckoMainnetCurrencyMapping: StringNullMap = { bitcoingold: 'bitcoin-gold', bitcoinsv: 'bitcoin-cash-sv', bobevm: 'ethereum', + botanix: 'bitcoin', cardano: 'cardano', celo: 'celo', coreum: 'coreum', @@ -59,7 +61,7 @@ export const coingeckoMainnetCurrencyMapping: StringNullMap = { ton: 'the-open-network', tron: 'tron', ufo: null, - vertcoin: null, + vertcoin: 'vertcoin', wax: null, zcash: 'zcash', zcoin: 'zcoin', @@ -68,6 +70,7 @@ export const coingeckoMainnetCurrencyMapping: StringNullMap = { } export const coingeckoPlatformIdMapping: StringNullMap = { + abstract: 'abstract', algorand: 'algorand', arbitrum: 'arbitrum-one', avalanche: 'avalanche', @@ -80,6 +83,7 @@ export const coingeckoPlatformIdMapping: StringNullMap = { bitcoingold: null, bitcoinsv: null, bobevm: 'bob-network', + botanix: 'botanix', cardano: 'cardano', celo: 'celo', coreum: 'coreum', diff --git a/src/v3/providers/coinmarketcap/coinmarketcap.ts b/src/v3/providers/coinmarketcap/coinmarketcap.ts index 74e6775..5078806 100644 --- a/src/v3/providers/coinmarketcap/coinmarketcap.ts +++ b/src/v3/providers/coinmarketcap/coinmarketcap.ts @@ -22,6 +22,7 @@ import { wasExistingMappings } from '../../types' import { + create30MinuteSyncInterval, createTokenId, expandReturnedCryptoRates, isCurrent, @@ -144,15 +145,9 @@ const platformIdMappingSyncDoc = syncedDocument( 'coinmarketcap:platforms', asStringNullMap ) -userTokenMappingsSyncDoc.sync(dbSettings).catch(e => { - console.error('manualTokenMappingsSyncDoc sync error', e) -}) -automatedTokenMappingsSyncDoc.sync(dbSettings).catch(e => { - console.error('automatedTokenMappingsSyncDoc sync error', e) -}) -platformIdMappingSyncDoc.sync(dbSettings).catch(e => { - console.error('platformIdMappingSyncDoc sync error', e) -}) +create30MinuteSyncInterval(userTokenMappingsSyncDoc, dbSettings) +create30MinuteSyncInterval(automatedTokenMappingsSyncDoc, dbSettings) +create30MinuteSyncInterval(platformIdMappingSyncDoc, dbSettings) userTokenMappingsSyncDoc.onChange(userMappings => { coinmarketcapTokenIdMap = { ...automatedTokenMappingsSyncDoc.doc, diff --git a/src/v3/providers/coinmarketcap/defaultPluginIdMapping.ts b/src/v3/providers/coinmarketcap/defaultPluginIdMapping.ts index 8a5d291..a32effa 100644 --- a/src/v3/providers/coinmarketcap/defaultPluginIdMapping.ts +++ b/src/v3/providers/coinmarketcap/defaultPluginIdMapping.ts @@ -1,6 +1,7 @@ import type { StringNullMap } from '../../types' export const coinmarketcapMainnetCurrencyMapping: StringNullMap = { + abstract: '1027', algorand: '4030', arbitrum: '1027', avalanche: '5805', @@ -13,6 +14,7 @@ export const coinmarketcapMainnetCurrencyMapping: StringNullMap = { bitcoingold: '2083', bitcoinsv: '3602', bobevm: '1027', + botanix: '1', cardano: '2010', celo: '5567', coreum: '16399', @@ -68,6 +70,7 @@ export const coinmarketcapMainnetCurrencyMapping: StringNullMap = { } export const coinmarketcapPlatformIdMapping: StringNullMap = { + abstract: '247', algorand: '17', arbitrum: '51', avalanche: '28', @@ -80,6 +83,7 @@ export const coinmarketcapPlatformIdMapping: StringNullMap = { bitcoingold: null, bitcoinsv: null, bobevm: null, + botanix: null, cardano: '29', celo: '35', coreum: null, diff --git a/src/v3/providers/constantRates.ts b/src/v3/providers/constantRates.ts new file mode 100644 index 0000000..6dffb02 --- /dev/null +++ b/src/v3/providers/constantRates.ts @@ -0,0 +1,59 @@ +import { asNumber, asObject } from 'cleaners' +import { syncedDocument } from 'edge-server-tools' + +import type { NumberMap, RateBuckets, RateProvider } from '../types' +import { + create30MinuteSyncInterval, + expandReturnedCryptoRates, + reduceRequestedCryptoRates +} from '../utils' +import { dbSettings } from './couch' + +const constantRateSyncDoc = syncedDocument('constantrates', asObject(asNumber)) +create30MinuteSyncInterval(constantRateSyncDoc, dbSettings) + +export const constantRates: RateProvider = { + providerId: 'constantRates', + type: 'api', + getCryptoRates: async ({ targetFiat, requestedRates }, rightNow) => { + if (targetFiat !== 'USD') { + return { + foundRates: new Map(), + requestedRates + } + } + + const rateBuckets = reduceRequestedCryptoRates(requestedRates, rightNow) + + const allResults: RateBuckets = new Map() + rateBuckets.forEach((ids, date) => { + const dateResults: NumberMap = {} + ids.forEach(pluginIdTokenId => { + const rate = constantRateSyncDoc.doc[pluginIdTokenId] + if (rate != null) { + dateResults[pluginIdTokenId] = rate + } + }) + if (Object.keys(dateResults).length > 0) { + allResults.set(date, dateResults) + } + }) + + const out = expandReturnedCryptoRates(requestedRates, rightNow, allResults) + + return { + foundRates: out.foundRates, + requestedRates: out.requestedRates + } + }, + documents: [ + { + name: 'rates_settings', + templates: { + constantrates: {} + }, + syncedDocuments: [constantRateSyncDoc] + } + ], + engines: [] +} diff --git a/src/v3/providers/edgerates/defaults.ts b/src/v3/providers/edgerates/defaults.ts index 5c3ee3b..22a2883 100644 --- a/src/v3/providers/edgerates/defaults.ts +++ b/src/v3/providers/edgerates/defaults.ts @@ -1,4 +1,4 @@ -import type { EdgeAsset, TokenTypeMap } from '../../types' +import type { CrossChainMapping, EdgeAsset, TokenTypeMap } from '../../types' export const defaultCrypto: EdgeAsset[] = [ { pluginId: 'bitcoin', tokenId: null }, @@ -29,6 +29,8 @@ export const defaultCrypto: EdgeAsset[] = [ { pluginId: 'rsk', tokenId: null }, { pluginId: 'ethereum', tokenId: null }, { pluginId: 'ethereumclassic', tokenId: null }, + { pluginId: 'abstract', tokenId: null }, + { pluginId: 'botanix', tokenId: null }, { pluginId: 'ethereum', tokenId: '1985365e9f78359a9b6ad760e32412f4a445e862' }, { pluginId: 'ethereum', tokenId: '6b175474e89094c44da98b954eedeac495271d0f' }, { pluginId: 'ethereum', tokenId: '89d24a6b4ccb1b6faa2625fe562bdd9a23260359' }, @@ -427,6 +429,7 @@ export const defaultFiat: string[] = [ ] export const defaultTokenTypes: TokenTypeMap = { + abstract: 'evm', algorand: 'simple', arbitrum: 'evm', avalanche: 'evm', @@ -439,6 +442,7 @@ export const defaultTokenTypes: TokenTypeMap = { bitcoingold: null, bitcoinsv: null, bobevm: 'evm', + botanix: 'evm', cardano: null, celo: 'evm', coreum: 'cosmos', @@ -553,5 +557,52 @@ export const defaultPlatformPriority: Record = { pulsechain: 570, smartcash: 580, binance: 590, - fantom: 600 + fantom: 600, + abstract: 610, + botanix: 620 +} + +export const defaultCrossChainMapping: CrossChainMapping = { + amoy: { + sourceChain: 'amoy', + destChain: 'polygon', + currencyCode: 'POL', + tokenId: null + }, + bitcointestnet: { + sourceChain: 'bitcointestnet', + destChain: 'bitcoin', + currencyCode: 'TESTBTC', + tokenId: null + }, + bitcointestnet4: { + sourceChain: 'bitcointestnet4', + destChain: 'bitcoin', + currencyCode: 'TESTBTC', + tokenId: null + }, + filecoinfevmcalibration: { + sourceChain: 'filecoinfevmcalibration', + destChain: 'filecoin', + currencyCode: 'tFIL', + tokenId: null + }, + sepolia: { + sourceChain: 'sepolia', + destChain: 'ethereum', + currencyCode: 'ETH', + tokenId: null + }, + thorchainrunestagenet: { + sourceChain: 'thorchainrunestagenet', + destChain: 'thorchainrune', + currencyCode: 'RUNE', + tokenId: null + }, + thorchainrunestagenet_tcy: { + sourceChain: 'thorchainrunestagenet', + destChain: 'thorchainrune', + currencyCode: 'TCY', + tokenId: 'tcy' + } } diff --git a/src/v3/providers/edgerates/edgerates.ts b/src/v3/providers/edgerates/edgerates.ts index 0b50694..2ba4cfb 100644 --- a/src/v3/providers/edgerates/edgerates.ts +++ b/src/v3/providers/edgerates/edgerates.ts @@ -14,6 +14,7 @@ import { fromCryptoKey } from '../../utils' import { dbSettings } from '../couch' import { client } from '../redis' import { + defaultCrossChainMapping, defaultCrypto, defaultFiat, defaultPlatformPriority, @@ -122,7 +123,8 @@ export const edgerates: RateProvider = { }, tokenTypes: defaultTokenTypes, platformPriority: defaultPlatformPriority, - 'crosschain:automated': {} + 'crosschain:automated': {}, + crosschain: defaultCrossChainMapping } } ], diff --git a/src/v3/router.ts b/src/v3/router.ts index 0e8a325..188e73c 100644 --- a/src/v3/router.ts +++ b/src/v3/router.ts @@ -15,7 +15,7 @@ import { type GetRatesParams, type IncomingGetRatesParams } from './types' -import { toCryptoKey } from './utils' +import { create30MinuteSyncInterval, toCryptoKey } from './utils' const fixIncomingGetRatesParams = ( rawParams: IncomingGetRatesParams, @@ -59,9 +59,9 @@ const fixIncomingGetRatesParams = ( // Map incoming crypto assets to their cross-chain canonical versions // Also return a mapping from each original asset key to its canonical key +let crosschainMappings: CrossChainMapping = {} const applyCrossChainMappings = ( - params: GetRatesParams, - mapping: CrossChainMapping + params: GetRatesParams ): { mappedParams: GetRatesParams originalToCanonicalKey: Map @@ -69,7 +69,7 @@ const applyCrossChainMappings = ( const originalToCanonicalKey = new Map() const mappedCrypto = params.crypto.map(c => { const originalKey = toCryptoKey(c.asset) - const cross = mapping[originalKey] + const cross = crosschainMappings[originalKey] if (cross == null) { originalToCanonicalKey.set(originalKey, originalKey) return c @@ -91,12 +91,27 @@ const applyCrossChainMappings = ( } } -const crosschainMappings = syncedDocument( +const defaultCrossChainSyncDoc = syncedDocument( + 'crosschain', + asCrossChainMapping +) +const automatedCrossChainSyncDoc = syncedDocument( 'crosschain:automated', asCrossChainMapping ) -crosschainMappings.sync(dbSettings).catch(e => { - console.error('crosschainMappings sync error', e) +create30MinuteSyncInterval(defaultCrossChainSyncDoc, dbSettings) +create30MinuteSyncInterval(automatedCrossChainSyncDoc, dbSettings) +defaultCrossChainSyncDoc.onChange(defaultMappings => { + crosschainMappings = { + ...automatedCrossChainSyncDoc.doc, + ...defaultMappings + } +}) +automatedCrossChainSyncDoc.onChange(automatedMappings => { + crosschainMappings = { + ...automatedMappings, + ...defaultCrossChainSyncDoc.doc + } }) export const ratesV3 = async ( @@ -107,10 +122,8 @@ export const ratesV3 = async ( const params = fixIncomingGetRatesParams(request.req.body, rightNow) // Map all incoming crypto assets to their canonical versions - const { mappedParams, originalToCanonicalKey } = applyCrossChainMappings( - params, - crosschainMappings.doc - ) + const { mappedParams, originalToCanonicalKey } = + applyCrossChainMappings(params) const result = await getRates(mappedParams, rightNow) // Build a quick lookup from canonical key + isoDate -> rate diff --git a/src/v3/types.ts b/src/v3/types.ts index 8a87800..d93fa6f 100644 --- a/src/v3/types.ts +++ b/src/v3/types.ts @@ -178,7 +178,7 @@ export const asCrossChainMapping = asObject( sourceChain: asString, destChain: asString, currencyCode: asString, - tokenId: asString + tokenId: asEdgeTokenId }) ) export type CrossChainMapping = ReturnType diff --git a/src/v3/utils.ts b/src/v3/utils.ts index b68de9f..1742b65 100644 --- a/src/v3/utils.ts +++ b/src/v3/utils.ts @@ -1,3 +1,6 @@ +import type { SyncedDocument } from 'edge-server-tools' +import type nano from 'nano' + import { FIVE_MINUTES, ONE_MINUTE, TWENTY_FOUR_HOURS } from './constants' import type { CryptoRateMap, @@ -304,3 +307,25 @@ export const isCurrent = ( } export const isCurrentFiat = (isoDate: Date, rightNow: Date): boolean => isCurrent(isoDate, rightNow, TWENTY_FOUR_HOURS) + +/** + * Create an interval to manually refresh the synced document. + * This is a workaround in case we lose the connection to the CouchDB changes feed. + */ +export const create30MinuteSyncInterval = ( + syncedDocument: SyncedDocument, + db: nano.DocumentScope +): void => { + syncedDocument + .sync(db) + .then(() => { + setInterval(() => { + syncedDocument.sync(db).catch(e => { + console.error('interval sync error', syncedDocument.id, e) + }) + }, 30 * ONE_MINUTE) + }) + .catch(e => { + console.error('create30MinuteSyncInterval error', syncedDocument.id, e) + }) +}