From 4aa99dabded51aeadcc1a554a5a031f295dc0592 Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Thu, 29 Aug 2024 17:59:33 -0700 Subject: [PATCH] Implement SupportedAssetStore for bityProvider --- .../gui/providers/ProviderSupportStore.ts | 214 ++++++++++++++++++ src/plugins/gui/providers/bityProvider.ts | 137 +++++------ 2 files changed, 284 insertions(+), 67 deletions(-) create mode 100644 src/plugins/gui/providers/ProviderSupportStore.ts diff --git a/src/plugins/gui/providers/ProviderSupportStore.ts b/src/plugins/gui/providers/ProviderSupportStore.ts new file mode 100644 index 00000000000..40dccb2dff3 --- /dev/null +++ b/src/plugins/gui/providers/ProviderSupportStore.ts @@ -0,0 +1,214 @@ +import { EdgeTokenId } from 'edge-core-js' + +import { FiatProviderAssetMap } from '../fiatProviderTypes' + +type NodeType = 'direction' | 'payment' | 'region' | 'fiat' | 'plugin' | 'crypto' +type NodeKey = `${NodeType}:${string}` | '*' +type Tree = Map | Leaf> +type SupportTree = Tree +type OtherInfoTree = Tree +type JsonSupportTree = { [key in NodeKey]: JsonSupportTree | boolean } + +export class ProviderSupportStore { + providerId: string + private readonly support: SupportTree = new Map() + private readonly otherInfo: OtherInfoTree = new Map() + + constructor(providerId: string) { + this.providerId = providerId + } + + addSupport(...keys: NodeKey[]): NodeKey[] { + this.addToTree(this.support, keys, true) + return keys + } + + addOtherInfo(path: NodeKey[], info: unknown): void { + this.addToTree(this.otherInfo, path, info) + } + + getOtherInfo(...keys: NodeKey[]): unknown { + const subTree = this.getSubTree(this.otherInfo, keys) + if (subTree == null) return undefined + return subTree.get(keys[keys.length - 1]) + } + + getFiatProviderAssetMap(...keys: NodeKey[]): FiatProviderAssetMap { + const fiatProviderAssetMap: FiatProviderAssetMap = { + providerId: this.providerId, + crypto: {}, + fiat: {} + } + + const subTree = this.getSubTree(this.support, keys) + if (subTree == null) { + return fiatProviderAssetMap + } + + // Iterate through the subTree searching for fiat/plugin/crypto node types + // to build the asset map: + for (const [fiatNodeKey, pluginNode] of subTree) { + const [nodeType, fiatCurrencyCode] = this.keyToNode(fiatNodeKey) + + // Only search for fiat node types at this level: + if (nodeType !== 'fiat') continue + + // Only include fiat currency codes (not wildcards): + if (fiatCurrencyCode !== '*') fiatProviderAssetMap.fiat[fiatCurrencyCode] = true + + // Assert the next node has children: + if (pluginNode === true) continue + + for (const [pluginNodeKey, cryptoNode] of pluginNode) { + const [nodeType, pluginId] = this.keyToNode(pluginNodeKey) + + // Only search for plugin node types at this level: + if (nodeType !== 'plugin') continue + + // Assert the next node has children: + if (cryptoNode === true) continue + + for (const [cryptoNodeKey] of cryptoNode) { + const [nodeType, tokenIdValue] = this.keyToNode(cryptoNodeKey) + + // Only search for crypto node types at this level: + if (nodeType !== 'crypto') continue + + // Only include tokenId values (not wildcards): + if (tokenIdValue === '*') continue + + const tokenId: EdgeTokenId = tokenIdValue === 'null' ? null : tokenIdValue + + // Add the tokenId to the fiatProviderAssetMap: + const otherInfo = this.getOtherInfo(...keys, fiatNodeKey, pluginNodeKey, cryptoNodeKey) + fiatProviderAssetMap.crypto[pluginId] = fiatProviderAssetMap.crypto[pluginId] ?? [] + fiatProviderAssetMap.crypto[pluginId].push({ tokenId, otherInfo }) + } + } + } + + return fiatProviderAssetMap + } + + isSupported(...keys: NodeKey[]): boolean { + return this.isSupportedRecursive(this.support, keys, 0) + } + + private isSupportedRecursive(tree: SupportTree = this.support, path: NodeKey[], level: number): boolean { + const key = path[level] + const [nodeType, value] = this.keyToNode(key) + const nodeKeys = nodeType === '*' ? Array.from(tree.keys()) : value === '*' ? Array.from(tree.keys()).filter(k => k.startsWith(nodeType)) : [key] + + // Add wildcard search + nodeKeys.push('*') + if (nodeType !== '*') nodeKeys.push(`${nodeType}:*`) + + const results = nodeKeys.some((nodeKey): boolean => { + const node = tree.get(nodeKey) + if (node instanceof Map) { + if (level === path.length - 1) { + return true + } + return this.isSupportedRecursive(node, path, level + 1) + } + if (node === true) { + if (level === path.length - 1) return true + return false + } + return false + }) + if (results) return true + + // We've reached the end of the keys and the last node is not a boolean + return false + } + + toJson(): string { + // Convert the support Map tree to a JSON string: + return JSON.stringify(this.toJsonObject()) + } + + toJsonObject(tree: SupportTree = this.support): object { + const result: { [key: string]: object | boolean } = {} + + for (const [key, value] of tree.entries()) { + if (value === true) { + result[key.toString()] = true + } else if (value instanceof Map) { + result[key.toString()] = this.toJsonObject(value) + } + } + + return result + } + + fromJson(json: string): void { + const data = JSON.parse(json) + this.support.clear() + this.fromJsonObject(data, this.support) + } + + fromJsonObject(data: { [key in NodeKey]: JsonSupportTree | boolean }, node: SupportTree): void { + for (const entry of Object.entries(data)) { + const [key, value] = entry as [NodeKey, JsonSupportTree | boolean] + if (value === true) { + node.set(key, true) + } else if (typeof value === 'object') { + const childNode = new Map() + node.set(key, childNode) + this.fromJsonObject(value, childNode) + } + } + } + + private addToTree(tree: Tree, path: NodeKey[], value: T): void { + const nodes: Array> = [tree] + for (let i = 0; i < path.length; ++i) { + const key = path[i] + const lastNode = nodes[nodes.length - 1] + const nextNode = lastNode.get(key) + // Continue if node exits: + if (nextNode instanceof Map) { + nodes.push(nextNode) + continue + } + // If we've reached the end of the keys (path), set the last node to true: + if (i === path.length - 1) { + lastNode.set(key, value) + continue + } + // Create new node if it doesn't exist or if it's a leaf: + const newNode = new Map() + nodes.push(newNode) + lastNode.set(key, newNode) + } + } + + private getSubTree(tree: Tree, path: NodeKey[], level: number = 0): Tree | undefined { + const key = path[level] + const [nodeType, value] = this.keyToNode(key) + const nodeKeys = nodeType === '*' ? Array.from(tree.keys()) : value === '*' ? Array.from(tree.keys()).filter(k => k.startsWith(nodeType)) : [key] + + for (const nodeKey of nodeKeys) { + const node = tree.get(nodeKey) + if (node instanceof Map) { + if (level === path.length - 1) { + return node + } + const result = this.getSubTree(node, path, level + 1) + if (result != null) return result + } else if (node != null) { + if (level === path.length - 1) { + return tree + } + } + } + + return undefined + } + + private keyToNode(key: NodeKey): [NodeType | '*', string] { + const [nodeType, ...rest] = key.split(':') as [NodeType | '*', string] + return [nodeType, rest.join(':')] + } +} diff --git a/src/plugins/gui/providers/bityProvider.ts b/src/plugins/gui/providers/bityProvider.ts index b0e7ee01424..469ed0200c6 100644 --- a/src/plugins/gui/providers/bityProvider.ts +++ b/src/plugins/gui/providers/bityProvider.ts @@ -6,6 +6,7 @@ import { sprintf } from 'sprintf-js' import { lstrings } from '../../../locales/strings' import { HomeAddress, SepaInfo } from '../../../types/FormTypes' import { StringMap } from '../../../types/types' +// import { getToken } from '../../../util/CurrencyInfoHelpers' import { utf8 } from '../../../util/encoding' import { removeIsoPrefix } from '../../../util/utils' import { FiatPaymentType, FiatPluginUi } from '../fiatPluginTypes' @@ -17,10 +18,9 @@ import { FiatProviderFactory, FiatProviderFactoryParams, FiatProviderGetQuoteParams, - FiatProviderGetTokenId, FiatProviderQuote } from '../fiatProviderTypes' -import { addTokenToArray } from '../util/providerUtils' +import { ProviderSupportStore } from './ProviderSupportStore' const providerId = 'bity' const storeId = 'com.bity' @@ -31,39 +31,38 @@ const supportEmail = 'support_edge@bity.com' const supportedPaymentType: FiatPaymentType = 'sepa' const partnerFee = 0.005 -const allowedCurrencyCodes: FiatProviderAssetMap = { providerId, crypto: {}, fiat: {} } -const allowedCountryCodes: { readonly [code: string]: boolean } = { - AT: true, - BE: true, - BG: true, - CH: true, - CZ: true, - DK: true, - EE: true, - FI: true, - FR: true, - DE: true, - GR: true, - HU: true, - IE: true, // Ireland - IT: true, - LV: true, - LT: true, - LU: true, - NL: true, - PL: true, - PT: true, - RO: true, - SK: true, - SI: true, - ES: true, - SE: true, - HR: true, - LI: true, - NO: true, - SM: true, - GB: true -} +const supportedRegionCodes = [ + 'AT', + 'BE', + 'BG', + 'CH', + 'CZ', + 'DK', + 'EE', + 'FI', + 'FR', + 'DE', + 'GR', + 'HU', + 'IE', // Ireland + 'IT', + 'LV', + 'LT', + 'LU', + 'NL', + 'PL', + 'PT', + 'RO', + 'SK', + 'SI', + 'ES', + 'SE', + 'HR', + 'LI', + 'NO', + 'SM', + 'GB' +] const CURRENCY_PLUGINID_MAP: StringMap = { BTC: 'bitcoin', @@ -342,6 +341,10 @@ export const bityProvider: FiatProviderFactory = { const { apiKeys, getTokenId } = params const clientId = asBityApiKeys(apiKeys).clientId + const supportedAssets = new ProviderSupportStore(providerId) + + supportedRegionCodes.forEach(code => supportedAssets.addSupport(`region:${code}`)) + const out: FiatProvider = { providerId, partnerIcon, @@ -353,7 +356,7 @@ export const bityProvider: FiatProviderFactory = { const response = await fetch(`https://exchange.api.bity.com/v2/currencies`).catch(e => undefined) if (response == null || !response.ok) { console.error(`Bity getSupportedAssets response error: ${await response?.text()}`) - return allowedCurrencyCodes + return supportedAssets.getFiatProviderAssetMap(`payment:${supportedPaymentType}`) } const result = await response.json() @@ -362,32 +365,36 @@ export const bityProvider: FiatProviderFactory = { bityCurrencies = asBityCurrencyResponse(result).currencies } catch (error: any) { console.error(error) - return allowedCurrencyCodes + return supportedAssets.getFiatProviderAssetMap(`payment:${supportedPaymentType}`) } + for (const currency of bityCurrencies) { - let isAddCurrencySuccess = false if (currency.tags.length === 1 && currency.tags[0] === 'fiat') { - allowedCurrencyCodes.fiat['iso:' + currency.code.toUpperCase()] = currency - isAddCurrencySuccess = true + const fiatCurrencyCode = 'iso:' + currency.code.toUpperCase() + + const path = supportedAssets.addSupport(`region:*`, `payment:${supportedPaymentType}`, `fiat:${fiatCurrencyCode}`) + + supportedAssets.addOtherInfo(path, currency) } else if (currency.tags.includes('crypto')) { // Bity reports cryptos with a set of multiple tags such that there is // overlap, such as USDC being 'crypto', 'ethereum', 'erc20'. - if (currency.tags.includes('erc20') && currency.tags.includes('ethereum')) { - // ETH tokens - addToAllowedCurrencies(getTokenId, 'ethereum', currency, currency.code) - isAddCurrencySuccess = true - } else if (Object.keys(CURRENCY_PLUGINID_MAP).includes(currency.code)) { - // Mainnet currencies - addToAllowedCurrencies(getTokenId, CURRENCY_PLUGINID_MAP[currency.code], currency, currency.code) - isAddCurrencySuccess = true - } - } + const pluginId = currency.tags.includes('erc20') && currency.tags.includes('ethereum') ? 'ethereum' : CURRENCY_PLUGINID_MAP[currency.code] + if (pluginId == null) continue + + const tokenId = getTokenId(pluginId, currency.code) + if (tokenId === undefined) continue - // Unhandled combination not caught by cleaner. Skip to be safe. - if (!isAddCurrencySuccess) console.log('Unhandled Bity supported currency: ', currency) + const path = supportedAssets.addSupport(`region:*`, `payment:${supportedPaymentType}`, `fiat:*`, `plugin:${pluginId}`, `crypto:${tokenId}`) + supportedAssets.addOtherInfo(path, currency) + } else { + // Unhandled combination not caught by cleaner. Skip to be safe. + console.log('Unhandled Bity supported currency: ', currency) + } } - return allowedCurrencyCodes + const assetMap = supportedAssets.getFiatProviderAssetMap(`region:*`, `payment:${supportedPaymentType}`) + + return assetMap }, getQuote: async (params: FiatProviderGetQuoteParams): Promise => { const { @@ -402,12 +409,17 @@ export const bityProvider: FiatProviderFactory = { displayCurrencyCode } = params const isBuy = direction === 'buy' - if (!allowedCountryCodes[regionCode.countryCode]) throw new FiatProviderError({ providerId, errorType: 'regionRestricted', displayCurrencyCode }) - if (!paymentTypes.includes(supportedPaymentType)) throw new FiatProviderError({ providerId, errorType: 'paymentUnsupported' }) - const bityCurrency = allowedCurrencyCodes.crypto[pluginId].find(t => t.tokenId === tokenId) - const cryptoCurrencyObj = asBityCurrency(bityCurrency?.otherInfo) - const fiatCurrencyObj = asBityCurrency(allowedCurrencyCodes.fiat[fiatCurrencyCode]) + if (!supportedAssets.isSupported(`region:${regionCode.countryCode}`)) + throw new FiatProviderError({ providerId, errorType: 'regionRestricted', displayCurrencyCode }) + if (!supportedAssets.isSupported(`region:${regionCode.countryCode}`, `payment:${supportedPaymentType}`)) + throw new FiatProviderError({ providerId, errorType: 'regionRestricted', displayCurrencyCode }) + + const cryptoOtherInfo = supportedAssets.getOtherInfo(`region:*`, `payment:${supportedPaymentType}`, `fiat:*`, `plugin:${pluginId}`, `crypto:${tokenId}`) + const cryptoCurrencyObj = asBityCurrency(cryptoOtherInfo) + + const fiatOtherInfo = supportedAssets.getOtherInfo(`region:*`, `payment:${supportedPaymentType}`, `fiat:${fiatCurrencyCode}`) + const fiatCurrencyObj = asBityCurrency(fiatOtherInfo) if (cryptoCurrencyObj == null || fiatCurrencyObj == null) throw new Error('Bity: Could not query supported currencies') const cryptoCode = cryptoCurrencyObj.code @@ -603,15 +615,6 @@ export const bityProvider: FiatProviderFactory = { } } -const addToAllowedCurrencies = (getTokenId: FiatProviderGetTokenId, pluginId: string, currency: BityCurrency, currencyCode: string) => { - if (allowedCurrencyCodes.crypto[pluginId] == null) allowedCurrencyCodes.crypto[pluginId] = [] - const tokenId = getTokenId(pluginId, currencyCode) - if (tokenId === undefined) return - - const tokens = allowedCurrencyCodes.crypto[pluginId] - addTokenToArray({ tokenId, otherInfo: currency }, tokens) -} - /** * Transition to the send scene pre-populted with the payment address from the * previously opened/approved sell order