|
| 1 | +/* eslint-disable @typescript-eslint/no-empty-function */ |
1 | 2 | import { Price, Findable, TokenPrices } from '@/types';
|
2 | 3 | import { wrappedTokensMap as aaveWrappedMap } from '../token-yields/tokens/aave';
|
3 | 4 | import axios from 'axios';
|
4 | 5 |
|
| 6 | +// Conscious choice for a deferred promise since we have setTimeout that returns a promise |
| 7 | +// Some reference for history buffs: https://github.com/petkaantonov/bluebird/wiki/Promise-anti-patterns |
| 8 | +interface PromisedTokenPrices { |
| 9 | + promise: Promise<TokenPrices>; |
| 10 | + resolve: (value: TokenPrices) => void; |
| 11 | + reject: (reason: unknown) => void; |
| 12 | +} |
| 13 | + |
| 14 | +const makePromise = (): PromisedTokenPrices => { |
| 15 | + let resolve: (value: TokenPrices) => void = () => {}; |
| 16 | + let reject: (reason: unknown) => void = () => {}; |
| 17 | + const promise = new Promise<TokenPrices>((res, rej) => { |
| 18 | + [resolve, reject] = [res, rej]; |
| 19 | + }); |
| 20 | + return { promise, reject, resolve }; |
| 21 | +}; |
| 22 | + |
5 | 23 | /**
|
6 | 24 | * Simple coingecko price source implementation. Configurable by network and token addresses.
|
7 | 25 | */
|
8 | 26 | export class CoingeckoPriceRepository implements Findable<Price> {
|
9 | 27 | prices: TokenPrices = {};
|
10 |
| - fetching: { [address: string]: Promise<TokenPrices> } = {}; |
11 | 28 | urlBase: string;
|
12 | 29 | baseTokenAddresses: string[];
|
13 | 30 |
|
| 31 | + // Properties used for deferring API calls |
| 32 | + // TODO: move this logic to hooks |
| 33 | + requestedAddresses = new Set<string>(); // Accumulates requested addresses |
| 34 | + debounceWait = 200; // Debouncing waiting time [ms] |
| 35 | + promisedCalls: PromisedTokenPrices[] = []; // When requesting a price we return a deferred promise |
| 36 | + promisedCount = 0; // New request coming when setTimeout is executing will make a new promise |
| 37 | + timeout?: ReturnType<typeof setTimeout>; |
| 38 | + debounceCancel = (): void => {}; // Allow to cancel mid-flight requests |
| 39 | + |
14 | 40 | constructor(tokenAddresses: string[], chainId = 1) {
|
15 |
| - this.baseTokenAddresses = tokenAddresses.map((a) => a.toLowerCase()); |
| 41 | + this.baseTokenAddresses = tokenAddresses |
| 42 | + .map((a) => a.toLowerCase()) |
| 43 | + .map((a) => unwrapToken(a)); |
16 | 44 | this.urlBase = `https://api.coingecko.com/api/v3/simple/token_price/${this.platform(
|
17 | 45 | chainId
|
18 | 46 | )}?vs_currencies=usd,eth`;
|
19 | 47 | }
|
20 | 48 |
|
21 |
| - fetch(address: string): { [address: string]: Promise<TokenPrices> } { |
22 |
| - console.time(`fetching coingecko ${address}`); |
23 |
| - const addresses = this.addresses(address); |
24 |
| - const prices = axios |
25 |
| - .get(this.url(addresses)) |
| 49 | + private fetch( |
| 50 | + addresses: string[], |
| 51 | + { signal }: { signal?: AbortSignal } = {} |
| 52 | + ): Promise<TokenPrices> { |
| 53 | + console.time(`fetching coingecko for ${addresses.length} tokens`); |
| 54 | + return axios |
| 55 | + .get<TokenPrices>(this.url(addresses), { signal }) |
26 | 56 | .then(({ data }) => {
|
27 |
| - addresses.forEach((address) => { |
28 |
| - delete this.fetching[address]; |
29 |
| - }); |
30 |
| - this.prices = { |
31 |
| - ...this.prices, |
32 |
| - ...(Object.keys(data).length == 0 ? { [address]: {} } : data), |
33 |
| - }; |
34 |
| - return this.prices; |
| 57 | + return data; |
35 | 58 | })
|
36 |
| - .catch((error) => { |
37 |
| - console.error(error); |
38 |
| - return this.prices; |
| 59 | + .finally(() => { |
| 60 | + console.timeEnd(`fetching coingecko for ${addresses.length} tokens`); |
39 | 61 | });
|
40 |
| - console.timeEnd(`fetching coingecko ${address}`); |
41 |
| - return Object.fromEntries(addresses.map((a) => [a, prices])); |
| 62 | + } |
| 63 | + |
| 64 | + private debouncedFetch(): Promise<TokenPrices> { |
| 65 | + if (!this.promisedCalls[this.promisedCount]) { |
| 66 | + this.promisedCalls[this.promisedCount] = makePromise(); |
| 67 | + } |
| 68 | + |
| 69 | + const { promise, resolve, reject } = this.promisedCalls[this.promisedCount]; |
| 70 | + |
| 71 | + if (this.timeout) { |
| 72 | + clearTimeout(this.timeout); |
| 73 | + } |
| 74 | + |
| 75 | + this.timeout = setTimeout(() => { |
| 76 | + this.promisedCount++; // any new call will get a new promise |
| 77 | + this.fetch([...this.requestedAddresses]) |
| 78 | + .then((results) => { |
| 79 | + resolve(results); |
| 80 | + this.debounceCancel = () => {}; |
| 81 | + }) |
| 82 | + .catch((reason) => { |
| 83 | + console.error(reason); |
| 84 | + }); |
| 85 | + }, this.debounceWait); |
| 86 | + |
| 87 | + this.debounceCancel = () => { |
| 88 | + if (this.timeout) { |
| 89 | + clearTimeout(this.timeout); |
| 90 | + } |
| 91 | + reject('Cancelled'); |
| 92 | + delete this.promisedCalls[this.promisedCount]; |
| 93 | + }; |
| 94 | + |
| 95 | + return promise; |
42 | 96 | }
|
43 | 97 |
|
44 | 98 | async find(address: string): Promise<Price | undefined> {
|
45 | 99 | const lowercaseAddress = address.toLowerCase();
|
46 | 100 | const unwrapped = unwrapToken(lowercaseAddress);
|
47 |
| - if (Object.keys(this.fetching).includes(unwrapped)) { |
48 |
| - await this.fetching[unwrapped]; |
49 |
| - } else if (!Object.keys(this.prices).includes(unwrapped)) { |
50 |
| - this.fetching = { |
51 |
| - ...this.fetching, |
52 |
| - ...this.fetch(unwrapped), |
53 |
| - }; |
54 |
| - await this.fetching[unwrapped]; |
| 101 | + if (!this.prices[unwrapped]) { |
| 102 | + try { |
| 103 | + let init = false; |
| 104 | + if (Object.keys(this.prices).length === 0) { |
| 105 | + // Make initial call with all the tokens we want to preload |
| 106 | + this.baseTokenAddresses.forEach( |
| 107 | + this.requestedAddresses.add.bind(this.requestedAddresses) |
| 108 | + ); |
| 109 | + init = true; |
| 110 | + } |
| 111 | + this.requestedAddresses.add(unwrapped); |
| 112 | + const promised = await this.debouncedFetch(); |
| 113 | + this.prices[unwrapped] = promised[unwrapped]; |
| 114 | + this.requestedAddresses.delete(unwrapped); |
| 115 | + if (init) { |
| 116 | + this.baseTokenAddresses.forEach((a) => { |
| 117 | + this.prices[a] = promised[a]; |
| 118 | + this.requestedAddresses.delete(a); |
| 119 | + }); |
| 120 | + } |
| 121 | + } catch (error) { |
| 122 | + console.error(error); |
| 123 | + } |
55 | 124 | }
|
56 | 125 |
|
57 | 126 | return this.prices[unwrapped];
|
@@ -83,14 +152,6 @@ export class CoingeckoPriceRepository implements Findable<Price> {
|
83 | 152 | private url(addresses: string[]): string {
|
84 | 153 | return `${this.urlBase}&contract_addresses=${addresses.join(',')}`;
|
85 | 154 | }
|
86 |
| - |
87 |
| - private addresses(address: string): string[] { |
88 |
| - if (this.baseTokenAddresses.includes(address)) { |
89 |
| - return this.baseTokenAddresses; |
90 |
| - } else { |
91 |
| - return [address]; |
92 |
| - } |
93 |
| - } |
94 | 155 | }
|
95 | 156 |
|
96 | 157 | const unwrapToken = (wrappedAddress: string) => {
|
|
0 commit comments