From dbe9e0c5d41ef4e9de5efb2015969d39821e7f52 Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Fri, 25 Oct 2024 12:34:24 -0700 Subject: [PATCH 1/3] Implement ProviderSupportStore --- .../utils/ProviderSupportStore.test.ts | 564 ++++++++++++++++++ .../gui/providers/ProviderSupportStore.ts | 347 +++++++++++ src/util/cleaners.ts | 77 ++- 3 files changed, 987 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/utils/ProviderSupportStore.test.ts create mode 100644 src/plugins/gui/providers/ProviderSupportStore.ts diff --git a/src/__tests__/utils/ProviderSupportStore.test.ts b/src/__tests__/utils/ProviderSupportStore.test.ts new file mode 100644 index 00000000000..fbb673d1c32 --- /dev/null +++ b/src/__tests__/utils/ProviderSupportStore.test.ts @@ -0,0 +1,564 @@ +import { describe, it } from '@jest/globals' + +import { + CryptoKey, + FiatProviderAssetMapQuery, + PaymentKey, + ProviderSupportObject, + ProviderSupportStore, + queryNodes +} from '../../plugins/gui/providers/ProviderSupportStore' + +describe('ProviderSupportStore', () => { + const generalStore = makeGeneralStoreFixture() + + it('toJsonObject, toJson, fromJsonObject, fromJson', () => { + const obj = generalStore.toJsonObject() + const json = generalStore.toJson() + + const expectedObj = { + buy: true, + '*': { + US: true, + 'US:CA': true, + UK: true, + '*': { + 'iso:USD': true, + 'iso:CAD': true, + 'iso:GBP': true, + '*': { + ach: true, + sepa: true, + credit: true, + '*': { + 'ethereum:null': true, + 'ethereum:USDC': true, + 'bitcoin:null': true + } + } + } + } + } + expect(obj).toEqual(expectedObj) + + const expectedJson = JSON.stringify(expectedObj) + expect(json).toBe(expectedJson) + + generalStore.fromJsonObject(obj) + expect(generalStore.toJsonObject()).toEqual(expectedObj) + + generalStore.fromJson(json) + expect(generalStore.toJson()).toEqual(expectedJson) + }) + + it('toJsonObject', () => { + const obj = generalStore.toJsonObject() + + expect(obj).toEqual({ + buy: true, + '*': { + US: true, + 'US:CA': true, + UK: true, + '*': { + 'iso:USD': true, + 'iso:CAD': true, + 'iso:GBP': true, + '*': { + ach: true, + sepa: true, + credit: true, + '*': { + 'ethereum:null': true, + 'ethereum:USDC': true, + 'bitcoin:null': true + } + } + } + } + }) + }) + + it('isSupported -> true', () => { + expect(generalStore.is.direction('buy').supported).toBe(true) + expect(generalStore.is.direction('*').region('US').supported).toBe(true) + expect(generalStore.is.direction('buy').region('US').supported).toBe(true) + expect(generalStore.is.direction('buy').region('US').fiat('iso:USD').supported).toBe(true) + expect(generalStore.is.direction('buy').region('US').fiat('iso:USD').payment('ach').supported).toBe(true) + expect(generalStore.is.direction('buy').region('US').fiat('iso:USD').payment('ach').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('*').region('US').fiat('iso:USD').payment('ach').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('buy').region('*').fiat('iso:USD').payment('ach').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('*').region('*').fiat('iso:USD').payment('ach').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('buy').region('US').fiat('*').payment('ach').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('*').region('US').fiat('*').payment('ach').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('buy').region('*').fiat('*').payment('ach').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('*').region('*').fiat('*').payment('ach').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('buy').region('US').fiat('iso:USD').payment('*').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('*').region('US').fiat('iso:USD').payment('*').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('buy').region('*').fiat('iso:USD').payment('*').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('*').region('*').fiat('iso:USD').payment('*').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('buy').region('US').fiat('*').payment('*').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('*').region('US').fiat('*').payment('*').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('buy').region('*').fiat('*').payment('*').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('*').region('*').fiat('*').payment('*').crypto('ethereum:null').supported).toBe(true) + expect(generalStore.is.direction('buy').region('US').fiat('iso:USD').payment('ach').crypto('*').supported).toBe(true) + expect(generalStore.is.direction('*').region('US').fiat('iso:USD').payment('ach').crypto('*').supported).toBe(true) + expect(generalStore.is.direction('buy').region('*').fiat('iso:USD').payment('ach').crypto('*').supported).toBe(true) + expect(generalStore.is.direction('*').region('*').fiat('iso:USD').payment('ach').crypto('*').supported).toBe(true) + expect(generalStore.is.direction('buy').region('US').fiat('*').payment('ach').crypto('*').supported).toBe(true) + expect(generalStore.is.direction('*').region('US').fiat('*').payment('ach').crypto('*').supported).toBe(true) + expect(generalStore.is.direction('buy').region('*').fiat('*').payment('ach').crypto('*').supported).toBe(true) + expect(generalStore.is.direction('*').region('*').fiat('*').payment('ach').crypto('*').supported).toBe(true) + expect(generalStore.is.direction('buy').region('US').fiat('iso:USD').payment('*').crypto('*').supported).toBe(true) + expect(generalStore.is.direction('*').region('US').fiat('iso:USD').payment('*').crypto('*').supported).toBe(true) + expect(generalStore.is.direction('buy').region('*').fiat('iso:USD').payment('*').crypto('*').supported).toBe(true) + expect(generalStore.is.direction('*').region('*').fiat('iso:USD').payment('*').crypto('*').supported).toBe(true) + expect(generalStore.is.direction('buy').region('US').fiat('*').payment('*').crypto('*').supported).toBe(true) + expect(generalStore.is.direction('*').region('US').fiat('*').payment('*').crypto('*').supported).toBe(true) + expect(generalStore.is.direction('buy').region('*').fiat('*').payment('*').crypto('*').supported).toBe(true) + expect(generalStore.is.direction('*').region('*').fiat('*').payment('*').crypto('*').supported).toBe(true) + }) + + it('isSupported -> false', () => { + expect(generalStore.is.direction('sell').supported).toBe(false) + expect(generalStore.is.direction('sell').region('US').supported).toBe(false) + expect(generalStore.is.direction('sell').region('US').fiat('iso:USD').supported).toBe(false) + expect(generalStore.is.direction('sell').region('US').fiat('iso:USD').payment('ach').supported).toBe(false) + expect(generalStore.is.direction('sell').region('US').fiat('iso:USD').payment('ach').crypto('ethereum:null').supported).toBe(false) + expect(generalStore.is.direction('*').region('IT').fiat('iso:USD').payment('ach').crypto('ethereum:null').supported).toBe(false) + expect(generalStore.is.direction('buy').region('*').fiat('iso:JPY').payment('ach').crypto('ethereum:null').supported).toBe(false) + expect(generalStore.is.direction('buy').region('*').fiat('iso:JPY').payment('ach').supported).toBe(false) + expect(generalStore.is.direction('buy').region('*').fiat('iso:JPY').supported).toBe(false) + expect(generalStore.is.direction('*').region('*').fiat('*').payment('*').crypto('monero').supported).toBe(false) + }) + + it('special matching rules', () => { + const store = new ProviderSupportStore('test') + + // all rule with explicit regions + store.add.direction('buy').region('US:CA') + store.add.direction('buy').region('US:FL') + store.add.direction('buy').region('US:*').fiat('iso:USD') + // any rule (implied) + store.add.direction('buy').region('UK').fiat('iso:GBP') + // any rule with explicit any region + store.add.direction('buy').region('CA:').fiat('iso:CAD') + + // all rule -> true + expect(store.is.direction('buy').region('US:CA').fiat('iso:USD').supported).toBe(true) + expect(store.is.direction('buy').region('US:FL').fiat('iso:USD').supported).toBe(true) + expect(store.is.direction('buy').region('*').fiat('iso:USD').supported).toBe(true) + // all rule -> false + expect(store.is.direction('buy').region('US:TX').fiat('iso:USD').supported).toBe(false) + expect(store.is.direction('buy').region('US').fiat('iso:USD').supported).toBe(false) + + // any rule -> true + expect(store.is.direction('buy').region('UK').fiat('iso:GBP').supported).toBe(true) + expect(store.is.direction('buy').region('UK:JQ').fiat('iso:GBP').supported).toBe(true) + expect(store.is.direction('buy').region('CA').fiat('iso:CAD').supported).toBe(true) + expect(store.is.direction('buy').region('CA:QC').fiat('iso:CAD').supported).toBe(true) + // any rule -> false + expect(store.is.direction('buy').region('UK').fiat('iso:USD').supported).toBe(false) + expect(store.is.direction('buy').region('UK:JQ').fiat('iso:USD').supported).toBe(false) + expect(store.is.direction('buy').region('CA:QC').fiat('iso:USD').supported).toBe(false) + expect(store.is.direction('buy').region('CA').fiat('iso:USD').supported).toBe(false) + + // match-all queries: + expect(store.is.direction('buy').region('*').fiat('iso:CAD').supported).toBe(true) + expect(store.is.direction('buy').region('*').fiat('iso:CAD').supported).toBe(true) + expect(store.is.direction('buy').region('*').fiat('iso:CAD').supported).toBe(true) + expect(store.is.direction('buy').region('UK').fiat('*').supported).toBe(true) + expect(store.is.direction('*').region('UK').fiat('iso:GBP').supported).toBe(true) + expect(store.is.direction('*').region('UK').fiat('iso:USD').supported).toBe(false) + }) + + it('queries across branches', () => { + const store = makeBityStoreFixture() + + expect(store.toJsonObject()).toEqual({ + '*': { + 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, + 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, + '*': { + '*': { + sepa: { + 'bitcoin:null': true, + 'ethereum:null': true, + 'ethereum:a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': true, + 'ethereum:dac17f958d2ee523a2206206994597c13d831ec7': true + } + } + } + }, + sell: { + '*': { + 'iso:CHF': { + sepa: true + }, + 'iso:EUR': { + sepa: true + } + } + } + }) + + expect(store.is.direction('*').region('IT').fiat('iso:CHF').supported).toBe(true) + expect(store.is.direction('*').region('FR').fiat('iso:CHF').supported).toBe(true) + expect(store.is.direction('*').region('FR').fiat('iso:EUR').supported).toBe(true) + expect(store.is.direction('*').region('FR').fiat('iso:EUR').payment('sepa').supported).toBe(true) + expect(store.is.direction('*').region('FR').fiat('iso:EUR').payment('sepa').crypto('ethereum:null').supported).toBe(true) + + expect(store.is.direction('*').region('US').fiat('iso:EUR').payment('sepa').crypto('ethereum:null').supported).toBe(false) + expect(store.is.direction('*').region('US').fiat('iso:EUR').payment('sepa').supported).toBe(false) + expect(store.is.direction('*').region('US').fiat('iso:EUR').supported).toBe(false) + expect(store.is.direction('*').region('US').supported).toBe(false) + expect(store.is.direction('buy').region('FR').fiat('iso:EUR').payment('sepa').crypto('ethereum:null').supported).toBe(false) + expect(store.is.direction('buy').region('FR').fiat('iso:EUR').payment('sepa').supported).toBe(false) + expect(store.is.direction('buy').region('FR').fiat('iso:EUR').supported).toBe(false) + expect(store.is.direction('buy').region('FR').supported).toBe(false) + expect(store.is.direction('buy').supported).toBe(false) + expect(store.is.direction('*').region('*').fiat('iso:USD').payment('sepa').crypto('ethereum:null').supported).toBe(false) + expect(store.is.direction('*').region('*').fiat('iso:USD').payment('sepa').supported).toBe(false) + expect(store.is.direction('*').region('*').fiat('iso:USD').supported).toBe(false) + expect(store.is.direction('*').region('*').supported).toBe(true) + expect(store.is.direction('*').supported).toBe(true) + }) + + describe('getFiatProviderAssetMap', () => { + type Tester = (params: FiatProviderAssetMapQuery) => { crypto: string[]; fiat: string[] } + + function makeTestCase(fixture: ProviderSupportObject): Tester { + // Setup fixtures: + const store = new ProviderSupportStore('test') + store.fromJsonObject(fixture) + + // Make tester function: + const tester: Tester = params => { + const result = store.getFiatProviderAssetMap(params) + const crypto: string[] = Object.keys(result.crypto).flatMap(pluginId => { + return result.crypto[pluginId].map(token => `${pluginId}:${token.tokenId}`) + }) + const fiat: string[] = Object.keys(result.fiat) + return { crypto, fiat } + } + + // Return tester function: + return tester + } + + const test = makeTestCase({ + // direction + sell: { + // region + IT: true, + '*': { + // fiat + 'iso:EUR': true, + 'iso:CHF': true, + '*': { + // payment + sepa: { + // crypto + 'ethereum:null': true, + 'ethereum:a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': true, + 'ethereum:dac17f958d2ee523a2206206994597c13d831ec7': true, + 'bitcoin:null': true + } + } + } + }, + // Negative test data (this data shouldn't appear in test results): + buy: { + IT: true, + US: { + 'iso:USD': { + sepa: { + 'ethereum:null': true, + 'ethereum:USDC': true, + 'bitcoin:null': true + } + } + }, + '*': { + 'iso:USD': { + ach: { + 'ethereum:null': true, + 'ethereum:USDC': true, + 'bitcoin:null': true + } + } + } + } + }) + + it('will match query-all with match-all node', () => { + const store = new ProviderSupportStore('test') + store.fromJsonObject({ + buy: true, + sell: true, + '*': { + '*': { '*': { sepa: true } } + } + }) + + expect(store.is.direction('*').region('*').fiat('*').payment('sepa').supported).toBe(true) + }) + + it('will return crypto and fiat for a given query', () => { + expect(test({ direction: 'sell', region: 'IT', payment: 'sepa' })).toStrictEqual({ + crypto: ['ethereum:null', 'ethereum:a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', 'ethereum:dac17f958d2ee523a2206206994597c13d831ec7', 'bitcoin:null'], + fiat: ['iso:EUR', 'iso:CHF'] + }) + }) + + it('will return empty results for wrong direction', () => { + expect(test({ direction: 'buy', region: 'IT', payment: 'sepa' })).toStrictEqual({ + crypto: [], + fiat: [] + }) + }) + + it('will return empty results for wrong region', () => { + expect(test({ direction: 'sell', region: 'US', payment: 'sepa' })).toStrictEqual({ + crypto: [], + fiat: [] + }) + }) + + it('will return empty results for wrong payment', () => { + expect(test({ direction: 'sell', region: 'IT', payment: 'ach' })).toStrictEqual({ + crypto: [], + fiat: [] + }) + }) + }) + + describe('queryNodes', () => { + type InternalTree = Map + interface InternalTreeRecord { + [key: string]: InternalTreeRecord + } + + // Convert objects to maps for testing convenience + const toMap = (obj: InternalTreeRecord): InternalTree => { + const map = new Map() + for (const key in obj) { + map.set(key, toMap(obj[key])) + } + return map + } + const allToMap = (arr: InternalTreeRecord[]): InternalTree[] => arr.map(toMap) + + const foodTree: InternalTree = toMap({ + 'fruit:apple': { + horse: {} + }, + 'fruit:banana': { + ape: {} + }, + 'veggie:carrot': { + bunny: {}, + horse: {} + }, + '': { + blender: {} + }, + '*': { + human: {} + } + }) + + it('will return all nodes for match-all query', () => { + const result = queryNodes([foodTree], '*') + expect(result).toStrictEqual( + allToMap([ + // fruit:apple + { + horse: {} + }, + // fruit:banana + { + ape: {} + }, + // veggie:carrot + { + bunny: {}, + horse: {} + }, + // match-any + { + blender: {} + }, + // match-all + { + human: {} + } + ]) + ) + }) + + it('will return exact node', () => { + const result = queryNodes([foodTree], 'veggie:carrot') + expect(result).toStrictEqual( + allToMap([ + // veggie:carrot + { + bunny: {}, + horse: {} + }, + // match-any + { + blender: {} + }, + // match-all + { + human: {} + } + ]) + ) + }) + + it('will always return match-any node', () => { + const result = queryNodes([foodTree], 'frog') + expect(result).toStrictEqual( + allToMap([ + // match-any + { + blender: {} + } + ]) + ) + }) + + it('will return sub-group node', () => { + const result = queryNodes([foodTree], 'fruit:*') + expect(result).toStrictEqual( + allToMap([ + // fruit:apple + { + horse: {} + }, + // fruit:banana + { + ape: {} + }, + // match-any + { + blender: {} + }, + // match-all + { + human: {} + } + ]) + ) + }) + }) +}) + +function makeGeneralStoreFixture(): ProviderSupportStore { + const store = new ProviderSupportStore('test') + + const directions = ['buy'] as const + const regions = ['US', 'US:CA', 'UK'] as const + const fiats = ['iso:USD', 'iso:CAD', 'iso:GBP'] as const + const payments: PaymentKey[] = ['ach', 'sepa', 'credit'] + const cryptos: CryptoKey[] = ['ethereum:null', 'ethereum:USDC', 'bitcoin:null'] + directions.forEach(direction => { + store.add.direction(direction) + }) + regions.forEach(region => { + store.add.direction('*').region(region) + }) + fiats.forEach(fiat => { + store.add.direction('*').region('*').fiat(fiat) + }) + payments.forEach(payment => { + store.add.direction('*').region('*').fiat('*').payment(payment) + }) + cryptos.forEach(crypto => { + store.add.direction('*').region('*').fiat('*').payment('*').crypto(crypto) + }) + + return store +} + +function makeBityStoreFixture(): ProviderSupportStore { + const store = new ProviderSupportStore('bity') + + const regions = [ + 'AT', + 'BE', + 'BG', + 'CH', + 'CZ', + 'DK', + 'EE', + 'FI', + 'FR', + 'DE', + 'GR', + 'HU', + 'IE', + 'IT', + 'LV', + 'LT', + 'LU', + 'NL', + 'PL', + 'PT', + 'RO', + 'SK', + 'SI', + 'ES', + 'SE', + 'HR', + 'LI', + 'NO', + 'SM', + 'GB' + ] + + // Add regions + regions.forEach(region => store.add.direction('*').region(region)) + + // Add fiats and payment methods + store.add.direction('sell').region('*').fiat('iso:CHF').payment('sepa') + store.add.direction('sell').region('*').fiat('iso:EUR').payment('sepa') + + // Add crypto assets + store.add.direction('*').region('*').fiat('*').payment('sepa').crypto('bitcoin:null') + store.add.direction('*').region('*').fiat('*').payment('sepa').crypto('ethereum:null') + store.add.direction('*').region('*').fiat('*').payment('sepa').crypto('ethereum:a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48') + store.add.direction('*').region('*').fiat('*').payment('sepa').crypto('ethereum:dac17f958d2ee523a2206206994597c13d831ec7') + + return store +} diff --git a/src/plugins/gui/providers/ProviderSupportStore.ts b/src/plugins/gui/providers/ProviderSupportStore.ts new file mode 100644 index 00000000000..503b62c43fd --- /dev/null +++ b/src/plugins/gui/providers/ProviderSupportStore.ts @@ -0,0 +1,347 @@ +import { asEither, asJSON, asString, asValue, uncleaner } from 'cleaners' +import { EdgeTokenId } from 'edge-core-js' + +import { asObjectIn, asObjectInOrTrue } from '../../../util/cleaners' +import { asFiatPaymentType, FiatPaymentType } from '../fiatPluginTypes' +import { FiatProviderAssetMap } from '../fiatProviderTypes' + +// '*' means 'all'; apply to all specified keys +// '' means 'any'; apply to all keys (even ones not specified) +export type SpecialQualifier = '*' | '' + +export type DirectionKey = 'buy' | 'sell' | SpecialQualifier +export type RegionKey = string // "US" | "US:CA" | "UK" | "US:*" | "*" | "" +export type FiatKey = string // "USD" | "GBP" | "*" | "" +export type PaymentKey = FiatPaymentType | SpecialQualifier +export type CryptoKey = string // "bitcoin:null" | "ethereum:null" | "ethereum:" + +// The internal in-memory data structure representing the support-tree +// for each provider +type InternalTree = Map +// The tree storing "other info" for each crypto currency +type CryptoAssetInfoTree = Map +// The tree storing "other info" for each fiat currency +type FiatAssetInfoTree = Map + +// The JSON-serializable object structure for the provider support tree +export type ProviderSupportObject = { + [direction in DirectionKey]?: + | true + | { + [region in RegionKey]?: + | true + | { + [fiat in FiatKey]?: + | true + | { + [payment in PaymentKey]?: + | true + | { + [crypto in CryptoKey]?: true + } + } + } + } +} + +// Cleaner for serializing/deserializing the provider support object: +const asSpecialQualifier = asValue('*', '') +const asDirectionKeyPartial = asValue('buy', 'sell') // Necessary to satisfy type inference +const asDirectionKey = asEither(asDirectionKeyPartial, asSpecialQualifier) +const asRegionKey = asString +const asFiatKey = asString +const asPaymentKey = asEither(asFiatPaymentType, asSpecialQualifier) +const asCryptoKey = asString +const asProviderSupportObject = asJSON( + asObjectIn( + asDirectionKey, + asObjectInOrTrue(asRegionKey, asObjectInOrTrue(asFiatKey, asObjectInOrTrue(asPaymentKey, asObjectInOrTrue(asCryptoKey, asValue(true))))) + ) +) +const wasProviderSupportObject = uncleaner(asProviderSupportObject) + +interface ProviderSupportAddApi { + direction: (key: DirectionKey) => { + region: (key: RegionKey) => { + fiat: (key: FiatKey) => { + payment: (key: PaymentKey) => { + crypto: (key: CryptoKey) => void + } + } + } + } +} + +interface ProviderSupportQueryApi { + direction: (key: DirectionKey) => { + supported: boolean + region: (key: RegionKey) => { + supported: boolean + fiat: (key: FiatKey) => { + supported: boolean + payment: (key: PaymentKey) => { + supported: boolean + crypto: (key: CryptoKey) => { + supported: boolean + } + } + } + } + } +} + +export interface FiatProviderAssetMapQuery { + direction: DirectionKey + region: RegionKey + payment: PaymentKey +} + +export class ProviderSupportStore { + readonly providerId: string + readonly add: ProviderSupportAddApi + readonly is: ProviderSupportQueryApi + + private readonly supportTree: InternalTree = new Map() + private readonly cryptoAssetInfo: CryptoAssetInfoTree = new Map() + private readonly fiatAssetInfo: FiatAssetInfoTree = new Map() + + constructor(providerId: string) { + this.providerId = providerId + this.add = makeAddApi(this.supportTree, ['direction', 'region', 'fiat', 'payment', 'crypto']) as any + this.is = makeQueryApi([this.supportTree], ['direction', 'region', 'fiat', 'payment', 'crypto']) as any + } + + addCryptoInfo(crypto: CryptoKey, otherInfo: unknown): void { + this.cryptoAssetInfo.set(crypto, otherInfo) + } + + addFiatInfo(fiat: FiatKey, otherInfo: unknown): void { + this.fiatAssetInfo.set(fiat, otherInfo) + } + + getCryptoInfo(crypto: CryptoKey): unknown { + return this.cryptoAssetInfo.get(crypto) + } + + getFiatInfo(fiat: FiatKey): unknown { + return this.fiatAssetInfo.get(fiat) + } + + getFiatProviderAssetMap(query: FiatProviderAssetMapQuery): FiatProviderAssetMap { + const fiatProviderAssetMap: FiatProviderAssetMap = { + providerId: this.providerId, + crypto: {}, + fiat: {} + } + + const regionNodes = queryNodes([this.supportTree], query.direction) + if (regionNodes.length === 0) return fiatProviderAssetMap + + const fiatNodes = queryNodes(regionNodes, query.region) + if (fiatNodes.length === 0) return fiatProviderAssetMap + + for (const fiatNode of fiatNodes) { + for (const fiatKey of fiatNode.keys()) { + const paymentNodes = fiatNode.get(fiatKey) + if (paymentNodes == null) continue + const result = queryNodes([paymentNodes], query.payment) + + // Skip if no payment matches: + if (result.length === 0) continue + + if (fiatKey === '*') { + // Add all fiat keys to fiat map: + for (const fiatKey of fiatNode.keys()) { + // Except for qualifiers: + if (fiatKey === '*') continue + fiatProviderAssetMap.fiat[fiatKey] = true + } + } else { + // Add fiat to fiat map: + fiatProviderAssetMap.fiat[fiatKey] = true + } + } + } + + // Fiat tree must had no payments: + const paymentNodes = queryNodes(fiatNodes, '*') + const cryptoNodes = queryNodes(paymentNodes, query.payment) + + const tokenIdMap: { [pluginId: string]: Set } = {} + for (const cryptoNode of cryptoNodes) { + for (const cryptoKey of cryptoNode.keys()) { + if (cryptoKey === '*') continue + const [pluginId, tokenIdString] = cryptoKey.split(':') + const tokenId: EdgeTokenId = tokenIdString === 'null' ? null : tokenIdString + + // Add fiat to crypto array for pluginId: + ;(tokenIdMap[pluginId] = tokenIdMap[pluginId] ?? new Set()).add(tokenId) + } + } + for (const [pluginId, tokenSet] of Object.entries(tokenIdMap)) { + fiatProviderAssetMap.crypto[pluginId] = Array.from(tokenSet).map(tokenId => ({ tokenId })) + } + + return fiatProviderAssetMap + } + + fromJson(json: string): void { + const obj = asProviderSupportObject(json) + this.fromJsonObject(obj) + } + + fromJsonObject(obj: ProviderSupportObject): void { + this.supportTree.clear() + fromJsonObject(this.supportTree, obj) + } + + toJson(): string { + return wasProviderSupportObject(this.toJsonObject()) + } + + toJsonObject(): ProviderSupportObject { + const obj: any = {} + return toJsonObject(obj, this.supportTree) + } +} + +function toJsonObject(obj: any, tree: InternalTree): ProviderSupportObject { + for (const [key, node] of tree.entries()) { + if (node.size === 0) { + obj[key] = true + continue + } + // Value is a nested object: + obj[key] = {} + toJsonObject(obj[key], node) + } + return obj +} + +function fromJsonObject(tree: InternalTree, obj: any): void { + for (const [key, value] of Object.entries(obj)) { + // Create node if doesn't exit: + const node = tree.get(key) ?? new Map() + tree.set(key, node) + // Recurse if value is an object: + if (typeof value === 'object') { + fromJsonObject(node, value) + } + } +} + +interface GenericAddApi { + [method: string]: (key: string) => GenericAddApi | undefined +} +function makeAddApi(tree: InternalTree, levels: string[], level: number = 0): GenericAddApi { + const method = levels[level] + return { + [method]: (key: string) => { + // Create node if it doesn't exist: + const node = tree.get(key) ?? new Map() + tree.set(key, node) + // Return more API if not at the end of the levels: + if (level < levels.length - 1) { + return makeAddApi(node, levels, level + 1) + } + } + } +} + +interface GenericQueryApi { + supported?: boolean + fn?: (key: string) => GenericQueryApi | undefined +} +function makeQueryApi(trees: InternalTree[], levels: string[], level: number = 0, supported?: boolean): GenericQueryApi { + const method = levels[level] as 'fn' // this is the magical hack to make TypeScript happy + return { + supported, + [method]: (query: string) => { + // Find all matching nodes: + const nextTrees: InternalTree[] = queryNodes(trees, query) + const supported = nextTrees.length !== 0 + + // Return more API if not at the end of the levels: + if (level < levels.length - 1) { + return makeQueryApi(nextTrees, levels, level + 1, supported) + } else { + return { supported } + } + } + } +} + +/** + * This is used to query the direct child nodes of a particular set of nodes. + * It follows the string matching rules respecting the special qualifiers '*' and ''. + * + * @param nodes An array of nodes to query against + * @param query A query string used to search for nested nodes (e.g. 'US:CA', 'US:*', '*', etc) + * @returns an array of nodes that match the query + */ +export function queryNodes(nodes: InternalTree[], query: string): InternalTree[] { + let matchFound = false + const result: InternalTree[] = [] + + const matchAnyNodes: InternalTree[] = [] + const matchAllNodes: InternalTree[] = [] + + // Handle regular nodes: + for (const node of nodes) { + for (const entry of node.entries()) { + const [nodeKey, childNodes] = entry + + // Ignore special nodes + if (nodeKey === '') { + matchAnyNodes.push(childNodes) + continue + } + if (nodeKey === '*') { + matchAllNodes.push(childNodes) + if (keyMatchesQuery(nodeKey, query)) { + matchFound = true + } + continue + } + + if (keyMatchesQuery(nodeKey, query)) { + matchFound = true + result.push(childNodes) + } else if (keyMatchesQuery(query, nodeKey)) { + matchAllNodes.push(childNodes) + } + } + } + + // Handle special nodes: + for (const childNodes of matchAnyNodes) { + result.push(childNodes) + } + if (matchFound) { + for (const childNodes of matchAllNodes) { + result.push(childNodes) + } + } + + return result +} + +function keyMatchesQuery(key: string, query: string): boolean { + const [keyGroup, keySub] = key.split(':') + const [queryGroup, subQuery] = query.split(':') + + // Match-all: + if (queryGroup === '*') return true + + // Match group: + if (queryGroup === keyGroup) { + // Exact-match: + if (subQuery === keySub) return true + // Group-sub match-all: + if (subQuery === '*') return true + // Group-sub match-any: + if (keySub == null || keySub === '') return true + } + + return false +} diff --git a/src/util/cleaners.ts b/src/util/cleaners.ts index f229e36bc39..2f92f078227 100644 --- a/src/util/cleaners.ts +++ b/src/util/cleaners.ts @@ -1,4 +1,4 @@ -import { asMaybe, asObject, asString } from 'cleaners' +import { asEither, asMaybe, asObject, asString, asValue, Cleaner } from 'cleaners' /** * Accepts strings that are valid numbers according to biggystring. @@ -26,3 +26,78 @@ export const asMaybeContractLocation = asMaybe( contractAddress: asString }) ) + +export const asObjectIn = (asKey: Cleaner, asT: Cleaner): Cleaner<{ [k in K]: T }> => { + return function asObject(raw) { + if (typeof raw !== 'object' || raw == null) { + throw new TypeError('Expected an object, got ' + showValue(raw)) + } + + const out: any = {} + const keys = Object.keys(raw) + for (let i = 0; i < keys.length; ++i) { + try { + const key = asKey(keys[i]) + if (key === '__proto__') continue + out[key] = asT(raw[key]) + } catch (error) { + throw locateError(error, '[' + JSON.stringify(keys[i]) + ']', 0) + } + } + return out + } +} + +export const asObjectInOrTrue = (asKey: Cleaner, asT: Cleaner) => + asEither(asObjectIn(asKey, asT), asValue<[true]>(true)) + +// ----------------------------------------------------------------------------- +// Internal functions taken from `cleaners` package +// ----------------------------------------------------------------------------- + +/** + * Given a JS value, produce a descriptive string. + */ +function showValue(value: any): string { + switch (typeof value) { + case 'function': + case 'object': + if (value == null) return 'null' + if (Array.isArray(value)) return 'array' + return typeof value + + case 'string': + return JSON.stringify(value) + + default: + return String(value) + } +} + +/** + * Adds location information to an error message. + * + * Errors can occur inside deeply-nested cleaners, + * such as "TypeError: Expected a string at .array[0].some.property". + * To build this information, each cleaner along the path + * should add its own location information as the stack unwinds. + * + * If the error has a `insertStepAt` property, that is the character offset + * where the next step will go in the error message. Otherwise, + * the next step just goes on the end of the string with the word "at". + */ +function locateError(error: unknown, step: string, offset: number): unknown { + if (error instanceof Error) { + // @ts-expect-error + if (error.insertStepAt == null) { + error.message += ' at ' + // @ts-expect-error + error.insertStepAt = error.message.length + } + // @ts-expect-error + error.message = error.message.slice(0, error.insertStepAt) + step + error.message.slice(error.insertStepAt) + // @ts-expect-error + error.insertStepAt += offset + } + return error +} From 5546ececafb0876dc6f6b4c4089d4fbf0a3aaea7 Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Fri, 25 Oct 2024 12:36:32 -0700 Subject: [PATCH 2/3] Use ProviderSupportStore for bityProvider --- src/plugins/gui/providers/bityProvider.ts | 190 ++++++++++++---------- 1 file changed, 105 insertions(+), 85 deletions(-) diff --git a/src/plugins/gui/providers/bityProvider.ts b/src/plugins/gui/providers/bityProvider.ts index fdd3ab3bbb5..bee9573186a 100644 --- a/src/plugins/gui/providers/bityProvider.ts +++ b/src/plugins/gui/providers/bityProvider.ts @@ -18,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' @@ -32,11 +31,6 @@ const supportEmail = 'support_edge@bity.com' const supportedPaymentType: FiatPaymentType = 'sepa' const partnerFee = 0.005 -const allowedCurrencyCodes: Record = { - buy: { providerId, fiat: {}, crypto: {} }, - sell: { providerId, fiat: {}, crypto: {} } -} - const noKycCurrencyCodes: Record = { buy: { providerId, @@ -58,38 +52,38 @@ const noKycCurrencyCodes: Record = { } } -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', @@ -370,18 +364,47 @@ export const bityProvider: FiatProviderFactory = { const { apiKeys, getTokenId } = params const clientId = asBityApiKeys(apiKeys).clientId + const supportedAssets = new ProviderSupportStore(providerId) + + // Bit supports buy and sell directions + supportedAssets.add.direction('buy') + supportedAssets.add.direction('sell') + + // Bity supports regions for all directions + supportedRegionCodes.forEach(region => { + supportedAssets.add.direction('*').region(region) + }) + + // Add supported payment types + supportedAssets.add.direction('*').region('*').fiat('*').payment(supportedPaymentType) + const out: FiatProvider = { providerId, partnerIcon, pluginDisplayName, - getSupportedAssets: async ({ direction, paymentTypes }): Promise => { - // Return nothing if 'sepa' is not included in the props - if (!paymentTypes.includes(supportedPaymentType)) throw new FiatProviderError({ providerId, errorType: 'paymentUnsupported' }) + getSupportedAssets: async ({ direction, paymentTypes, regionCode }): Promise => { + // Only one payment type is supported for getSupportedAssets query + const payment = paymentTypes[0] + // Region code is a combination of country and state/province + const region = regionCode.stateProvinceCode == null ? regionCode.countryCode : `${regionCode.countryCode}:${regionCode.stateProvinceCode}` + + // Check region support + if (!supportedAssets.is.direction('*').region(region).supported) { + throw new FiatProviderError({ providerId, errorType: 'regionRestricted' }) + } + // Check payment type support + if (!supportedAssets.is.direction('*').region(region).fiat('*').payment(payment).supported) { + throw new FiatProviderError({ providerId, errorType: 'paymentUnsupported' }) + } 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[direction] + return supportedAssets.getFiatProviderAssetMap({ + direction, + region, + payment + }) } const result = await response.json() @@ -390,32 +413,45 @@ export const bityProvider: FiatProviderFactory = { bityCurrencies = asBityCurrencyResponse(result).currencies } catch (error: any) { console.error(error) - return allowedCurrencyCodes[direction] + return supportedAssets.getFiatProviderAssetMap({ + direction, + region, + payment + }) } + for (const currency of bityCurrencies) { - let isAddCurrencySuccess = false if (currency.tags.length === 1 && currency.tags[0] === 'fiat') { - allowedCurrencyCodes[direction].fiat['iso:' + currency.code.toUpperCase()] = currency - isAddCurrencySuccess = true + const fiatCurrencyCode = 'iso:' + currency.code.toUpperCase() + supportedAssets.add.direction('*').region('*').fiat(fiatCurrencyCode).payment('*') + supportedAssets.addFiatInfo(fiatCurrencyCode, 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(direction, getTokenId, 'ethereum', currency, currency.code) - isAddCurrencySuccess = true - } else if (Object.keys(CURRENCY_PLUGINID_MAP).includes(currency.code)) { - // Mainnet currencies - addToAllowedCurrencies(direction, 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 + + // If token is not in the no-KYC list do not add it + const list = noKycCurrencyCodes[direction].crypto[pluginId] + if (list == null || !list.some(t => t.tokenId === tokenId)) { + continue } - } - // Unhandled combination not caught by cleaner. Skip to be safe. - if (!isAddCurrencySuccess) console.log('Unhandled Bity supported currency: ', currency) + const crypto = `${pluginId}:${tokenId}` + supportedAssets.add.direction('*').region('*').fiat('*').payment('*').crypto(crypto) + supportedAssets.addCryptoInfo(crypto, currency) + } else { + // Unhandled combination not caught by cleaner. Skip to be safe. + console.log('Unhandled Bity supported currency: ', currency) + } } - return allowedCurrencyCodes[direction] + const assetMap = supportedAssets.getFiatProviderAssetMap({ direction, region, payment }) + + return assetMap }, getQuote: async (params: FiatProviderGetQuoteParams): Promise => { const { @@ -430,12 +466,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[direction].crypto[pluginId].find(t => t.tokenId === tokenId) - const cryptoCurrencyObj = asBityCurrency(bityCurrency?.otherInfo) - const fiatCurrencyObj = asBityCurrency(allowedCurrencyCodes[direction].fiat[fiatCurrencyCode]) + if (!supportedAssets.is.direction(direction).region(regionCode.countryCode).supported) + throw new FiatProviderError({ providerId, errorType: 'regionRestricted', displayCurrencyCode }) + if (!supportedAssets.is.direction(direction).region(regionCode.countryCode).fiat('*').payment(supportedPaymentType).supported) + throw new FiatProviderError({ providerId, errorType: 'regionRestricted', displayCurrencyCode }) + + const cryptoOtherInfo = supportedAssets.getCryptoInfo(`${pluginId}:${tokenId}`) + const cryptoCurrencyObj = asBityCurrency(cryptoOtherInfo) + + const fiatOtherInfo = supportedAssets.getFiatInfo(fiatCurrencyCode) + const fiatCurrencyObj = asBityCurrency(fiatOtherInfo) if (cryptoCurrencyObj == null || fiatCurrencyObj == null) throw new Error('Bity: Could not query supported currencies') const cryptoCode = cryptoCurrencyObj.code @@ -639,27 +680,6 @@ export const bityProvider: FiatProviderFactory = { } } -const addToAllowedCurrencies = ( - direction: FiatDirection, - getTokenId: FiatProviderGetTokenId, - pluginId: string, - currency: BityCurrency, - currencyCode: string -) => { - if (allowedCurrencyCodes[direction].crypto[pluginId] == null) allowedCurrencyCodes[direction].crypto[pluginId] = [] - const tokenId = getTokenId(pluginId, currencyCode) - if (tokenId === undefined) return - - const tokens = allowedCurrencyCodes[direction].crypto[pluginId] - - // If token is not in the no-KYC list do not add it - const list = noKycCurrencyCodes[direction].crypto[pluginId] - if (list == null || !list.some(t => t.tokenId === tokenId)) { - return - } - addTokenToArray({ tokenId, otherInfo: currency }, tokens) -} - /** * Transition to the send scene pre-populted with the payment address from the * previously opened/approved sell order From 0fd52f58d547f42dacb592fcbbb3a89144bf9ef7 Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Tue, 5 Nov 2024 15:40:55 -0800 Subject: [PATCH 3/3] Add a check-due function to bitProvider for caching support queries --- src/plugins/gui/providers/bityProvider.ts | 97 ++++++++++++----------- src/plugins/gui/providers/common.ts | 30 +++++++ 2 files changed, 81 insertions(+), 46 deletions(-) diff --git a/src/plugins/gui/providers/bityProvider.ts b/src/plugins/gui/providers/bityProvider.ts index bee9573186a..82de2248589 100644 --- a/src/plugins/gui/providers/bityProvider.ts +++ b/src/plugins/gui/providers/bityProvider.ts @@ -20,6 +20,7 @@ import { FiatProviderGetQuoteParams, FiatProviderQuote } from '../fiatProviderTypes' +import { makeCheckDue } from './common' import { ProviderSupportStore } from './ProviderSupportStore' const providerId = 'bity' @@ -364,6 +365,7 @@ export const bityProvider: FiatProviderFactory = { const { apiKeys, getTokenId } = params const clientId = asBityApiKeys(apiKeys).clientId + const isCheckDue = makeCheckDue(1000 * 60 * 60) // 1 hour const supportedAssets = new ProviderSupportStore(providerId) // Bit supports buy and sell directions @@ -397,55 +399,58 @@ export const bityProvider: FiatProviderFactory = { throw new FiatProviderError({ providerId, errorType: 'paymentUnsupported' }) } - 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 supportedAssets.getFiatProviderAssetMap({ - direction, - region, - payment - }) - } + if (isCheckDue()) { + 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()}`) + isCheckDue(true) + return supportedAssets.getFiatProviderAssetMap({ + direction, + region, + payment + }) + } - const result = await response.json() - let bityCurrencies: BityCurrency[] = [] - try { - bityCurrencies = asBityCurrencyResponse(result).currencies - } catch (error: any) { - console.error(error) - return supportedAssets.getFiatProviderAssetMap({ - direction, - region, - payment - }) - } + const result = await response.json() + let bityCurrencies: BityCurrency[] = [] + try { + bityCurrencies = asBityCurrencyResponse(result).currencies + } catch (error: any) { + console.error(error) + return supportedAssets.getFiatProviderAssetMap({ + direction, + region, + payment + }) + } - for (const currency of bityCurrencies) { - if (currency.tags.length === 1 && currency.tags[0] === 'fiat') { - const fiatCurrencyCode = 'iso:' + currency.code.toUpperCase() - supportedAssets.add.direction('*').region('*').fiat(fiatCurrencyCode).payment('*') - supportedAssets.addFiatInfo(fiatCurrencyCode, 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'. - 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 - - // If token is not in the no-KYC list do not add it - const list = noKycCurrencyCodes[direction].crypto[pluginId] - if (list == null || !list.some(t => t.tokenId === tokenId)) { - continue + for (const currency of bityCurrencies) { + if (currency.tags.length === 1 && currency.tags[0] === 'fiat') { + const fiatCurrencyCode = 'iso:' + currency.code.toUpperCase() + supportedAssets.add.direction('*').region('*').fiat(fiatCurrencyCode).payment('*') + supportedAssets.addFiatInfo(fiatCurrencyCode, 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'. + 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 + + // If token is not in the no-KYC list do not add it + const list = noKycCurrencyCodes[direction].crypto[pluginId] + if (list == null || !list.some(t => t.tokenId === tokenId)) { + continue + } + + const crypto = `${pluginId}:${tokenId}` + supportedAssets.add.direction('*').region('*').fiat('*').payment('*').crypto(crypto) + supportedAssets.addCryptoInfo(crypto, currency) + } else { + // Unhandled combination not caught by cleaner. Skip to be safe. + console.log('Unhandled Bity supported currency: ', currency) } - - const crypto = `${pluginId}:${tokenId}` - supportedAssets.add.direction('*').region('*').fiat('*').payment('*').crypto(crypto) - supportedAssets.addCryptoInfo(crypto, currency) - } else { - // Unhandled combination not caught by cleaner. Skip to be safe. - console.log('Unhandled Bity supported currency: ', currency) } } diff --git a/src/plugins/gui/providers/common.ts b/src/plugins/gui/providers/common.ts index 5dcd5b0a6e1..ca146fb8333 100644 --- a/src/plugins/gui/providers/common.ts +++ b/src/plugins/gui/providers/common.ts @@ -106,3 +106,33 @@ export const isDailyCheckDue = (lastCheck: number): boolean => { const last = new Date(lastCheck).getTime() return now - last > DAILY_INTERVAL_MS } + +/** + * Checks if a interval has passed based on the last check time. If the check + * is due, the last check time is updated to the current time. + * + * @param interval the interval in milliseconds + * @returns a "check-due" function which will return `true` if the check interval + * has elapsed since the last check, `false` otherwise. Also allows for an override + * to reset the last check time. + */ +export const makeCheckDue = (interval: number) => { + let last: number = 0 + /** + * Checks if a interval has passed based on the last check time. If the check + * is due, the last check time is updated to the current time. + * Also allows for an override to reset the last check time. + */ + return function checkDue(override?: boolean): boolean { + if (override != null) { + last = override ? last : 0 + return override + } + const now = Date.now() + if (now - last > interval) { + last = now + return true + } + return false + } +}