Skip to content

Commit 3174dc3

Browse files
committed
feat: coingecko token price API
1 parent 9aca0c2 commit 3174dc3

File tree

26 files changed

+268
-112
lines changed

26 files changed

+268
-112
lines changed

apps/browser-extension-wallet/.env.defaults

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ USE_BITCOIN_NETWORK_SWITCHING=false
1616
USE_PASSWORD_VERIFICATION=false
1717
USE_DAPP_CONNECTOR=true
1818
USE_TREZOR_HW=true
19-
USE_TOKEN_PRICING=true
2019
USE_NFT_FOLDERS=true
2120
USE_MULTI_CURRENCY=true
2221
USE_HIDE_MY_BALANCE=true
@@ -106,6 +105,10 @@ MAESTRO_URL_TESTNET=https://xbt-testnet.gomaestro-api.org
106105
MAESTRO_PROJECT_ID_MAINNET=
107106
MAESTRO_PROJECT_ID_TESTNET=
108107

108+
# Lace proxy to CoinGecko
109+
110+
TOKEN_PRICES_URL=https://coingecko.live-mainnet.eks.lw.iog.io/api/v3/onchain/networks
111+
109112
# Bitcoin Explorer URLs
110113
MEMPOOL_URL_MAINNET=https://mempool.space/tx
111114
MEMPOOL_URL_TESTNET4=https://mempool.space/testnet4/tx

apps/browser-extension-wallet/.env.developerpreview

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ SAVED_PRICE_DURATION_IN_MINUTES=720
1414
USE_PASSWORD_VERIFICATION=false
1515
USE_DAPP_CONNECTOR=true
1616
USE_TREZOR_HW=false
17-
USE_TOKEN_PRICING=true
1817
USE_NFT_FOLDERS=true
1918
USE_MULTI_CURRENCY=true
2019
USE_HIDE_MY_BALANCE=true

apps/browser-extension-wallet/.env.example

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ WALLET_POLLING_INTERVAL_IN_SEC=45
1414
USE_PASSWORD_VERIFICATION=false
1515
USE_DAPP_CONNECTOR=true
1616
USE_TREZOR_HW=true
17-
USE_TOKEN_PRICING=true
1817
USE_NFT_FOLDERS=true
1918
USE_MULTI_CURRENCY=true
2019
USE_HIDE_MY_BALANCE=true

apps/browser-extension-wallet/src/api/__tests__/token-transformer.test.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,11 @@ describe('Testing tokenTransformer function', () => {
4040
test('should format token with fiatBalance', () => {
4141
const prices = {
4242
...mockPrices,
43-
tokens: new Map([
44-
[
45-
balance[0],
46-
{
47-
id: balance[0].toString(),
48-
priceInAda: 1.2,
49-
priceVariationPercentage24h: 2.9
50-
}
51-
]
52-
])
43+
cardano: {
44+
...mockPrices.cardano,
45+
getTokenPrice: (assetId: Wallet.Cardano.AssetId) =>
46+
assetId === balance[0] ? { priceInAda: 1.2, priceVariationPercentage24h: 2.9 } : undefined
47+
}
5348
};
5449
const result = tokenTransformer(mockAsset, balance, prices, defaultCurrency);
5550
const tokenPrice = 1.2;

apps/browser-extension-wallet/src/api/transformers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export const tokenTransformer = (
6161
const { name } = { ...tokenMetadata, ...nftMetadata };
6262
const [assetId, bigintBalance] = assetBalance;
6363
const amount = Wallet.util.calculateAssetBalance(bigintBalance, assetInfo);
64-
const tokenPriceInAda = prices?.tokens?.get(assetId)?.priceInAda;
64+
const tokenPriceInAda = prices?.cardano.getTokenPrice(assetId)?.priceInAda;
6565
const fiatBalance =
6666
tokenMetadata !== undefined &&
6767
tokenPriceInAda &&

apps/browser-extension-wallet/src/hooks/__tests__/useFetchCoinPrice.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { act } from 'react-dom/test-utils';
88
import { BehaviorSubject } from 'rxjs';
99

1010
import { BackgroundServiceAPIProviderProps } from '@src/providers';
11+
import { Wallet } from '@lace/cardano';
1112

1213
const tokenPrices$ = new BehaviorSubject({});
1314
const adaPrices$ = new BehaviorSubject({});
@@ -17,6 +18,14 @@ jest.mock('@providers/currency', (): typeof CurrencyProvider => ({
1718
useCurrencyStore: mockUseCurrencyStore
1819
}));
1920

21+
jest.mock('../../stores', () => ({
22+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
23+
...jest.requireActual<any>('../../stores'),
24+
useWalletStore: () => ({
25+
currentChain: { networkId: Wallet.Cardano.NetworkId.Mainnet }
26+
})
27+
}));
28+
2029
const backgroundServices = {
2130
getBackgroundStorage: jest.fn(),
2231
setBackgroundStorage: jest.fn(),
@@ -32,9 +41,11 @@ jest.mock('@providers/BackgroundServiceAPI', () => ({
3241
describe('Testing useFetchCoinPrice hook', () => {
3342
test('should return proper state', async () => {
3443
const hook = renderHook(() => useFetchCoinPrice());
44+
expect(typeof hook.result.current.priceResult.cardano.getTokenPrice).toBe('function');
3545
expect(hook.result.current).toEqual({
3646
priceResult: {
3747
cardano: {
48+
getTokenPrice: hook.result.current.priceResult.cardano.getTokenPrice,
3849
price: 1,
3950
priceVariationPercentage24h: 0
4051
},
@@ -108,12 +119,14 @@ describe('Testing useFetchCoinPrice hook', () => {
108119
priceVariationPercentage24h: 0
109120
},
110121
cardano: {
122+
getTokenPrice: hook.result.current.priceResult.cardano.getTokenPrice,
111123
price: 1,
112124
priceVariationPercentage24h: 0
113125
},
114126
tokens
115127
},
116-
status: 'fetched'
128+
status: 'fetched',
129+
timestamp: undefined
117130
});
118131

119132
const fiatCurrency = { code: 'USD', symbol: '$' };
@@ -130,6 +143,7 @@ describe('Testing useFetchCoinPrice hook', () => {
130143
priceVariationPercentage24h: 0
131144
},
132145
cardano: {
146+
getTokenPrice: hook.result.current.priceResult.cardano.getTokenPrice,
133147
price: prices.usd,
134148
priceVariationPercentage24h: prices.usd_24h_change
135149
},
Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
import { useMemo } from 'react';
2-
import { useObservable } from '@lace/common';
3-
import { TokenPrices, StatusTypes, ADAPricesKeys } from '@lib/scripts/types';
2+
import { logger, useObservable } from '@lace/common';
3+
import { TokenPrices, StatusTypes, ADAPricesKeys, TokenPrice } from '@lib/scripts/types';
44
import { useBackgroundServiceAPIContext, useCurrencyStore } from '../providers';
55
import { CARDANO_COIN_SYMBOL } from '@src/utils/constants';
66
import { Wallet } from '@lace/cardano';
7+
import { config } from '@src/config';
8+
import { useWalletStore } from '@stores';
79

810
export interface PriceResult {
911
cardano: {
12+
getTokenPrice: (assetId: Wallet.Cardano.AssetId) => TokenPrice | undefined;
1013
price: number;
1114
priceVariationPercentage24h: number;
1215
};
1316
bitcoin: {
1417
price: number;
1518
priceVariationPercentage24h: number;
1619
};
20+
/** @deprecated Use `cardano.getTokenPrice` instead. */
1721
tokens: TokenPrices;
1822
}
1923

@@ -23,13 +27,15 @@ export interface UseFetchCoinPrice {
2327
timestamp?: number;
2428
}
2529

30+
const { TOKEN_PRICE_CHECK_INTERVAL } = config();
31+
2632
export const useFetchCoinPrice = (): UseFetchCoinPrice => {
27-
const backgroundServices = useBackgroundServiceAPIContext();
33+
const { coinPrices, trackCardanoTokenPrice } = useBackgroundServiceAPIContext();
2834
const { fiatCurrency } = useCurrencyStore();
29-
const { coinPrices } = backgroundServices;
3035
const tokenPrices = useObservable(coinPrices.tokenPrices$);
3136
const adaPrices = useObservable(coinPrices.adaPrices$);
3237
const bitcoinPrices = useObservable(coinPrices.bitcoinPrices$);
38+
const networkId = useWalletStore((state) => state.currentChain?.networkId);
3339

3440
const isAdaCurrency = fiatCurrency.code === CARDANO_COIN_SYMBOL[Wallet.Cardano.NetworkId.Mainnet];
3541

@@ -42,25 +48,49 @@ export const useFetchCoinPrice = (): UseFetchCoinPrice => {
4248
[bitcoinPrices?.prices, fiatCurrency.code]
4349
);
4450

45-
const price = useMemo(
51+
const cardano = useMemo(
4652
() => ({
53+
getTokenPrice: (assetId: Wallet.Cardano.AssetId): TokenPrice | undefined => {
54+
const tokenPrice = tokenPrices?.tokens.get(assetId);
55+
// Actually track the price only for token in Cardano mainnet, otherwise just do nothing
56+
const trackPrice = () =>
57+
networkId === Wallet.Cardano.NetworkId.Mainnet
58+
? trackCardanoTokenPrice(assetId).catch((error) => logger.error(error))
59+
: undefined;
60+
61+
// If the price for this token was never fetched, wee need to track it
62+
if (!tokenPrice) {
63+
trackPrice();
64+
65+
return undefined;
66+
}
67+
68+
const { lastFetchTime, price } = tokenPrice;
69+
70+
// If the price was fetched, but it is still not present, it means the price for this token is not tracked by CoinGecko:
71+
// let's retry a new fetch only after the TOKEN_PRICE_CHECK_INTERVAL to check if now the price is being tracked.
72+
if (!price && lastFetchTime < Date.now() - TOKEN_PRICE_CHECK_INTERVAL) trackPrice();
73+
74+
// eslint-disable-next-line consistent-return
75+
return price;
76+
},
4777
price: isAdaCurrency ? 1 : adaPrices?.prices?.[fiatCurrency.code.toLowerCase() as ADAPricesKeys],
4878
priceVariationPercentage24h:
4979
adaPrices?.prices?.[`${fiatCurrency.code.toLowerCase()}_24h_change` as ADAPricesKeys] || 0
5080
}),
51-
[adaPrices?.prices, fiatCurrency.code, isAdaCurrency]
81+
[adaPrices?.prices, fiatCurrency.code, isAdaCurrency, tokenPrices?.tokens, trackCardanoTokenPrice, networkId]
5282
);
5383

5484
return useMemo(
5585
() => ({
5686
priceResult: {
57-
cardano: price,
87+
cardano,
5888
bitcoin: bitcoinPrice,
5989
tokens: tokenPrices?.tokens
6090
},
6191
status: adaPrices?.status,
6292
timestamp: adaPrices?.timestamp
6393
}),
64-
[tokenPrices, adaPrices, price, bitcoinPrice]
94+
[tokenPrices, adaPrices, cardano, bitcoinPrice]
6595
);
6696
};

apps/browser-extension-wallet/src/lib/scripts/background/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const backgroundServiceProperties: RemoteApiProperties<BackgroundService>
1919
adaPrices$: RemoteApiPropertyType.HotObservable,
2020
tokenPrices$: RemoteApiPropertyType.HotObservable
2121
},
22+
trackCardanoTokenPrice: RemoteApiPropertyType.MethodReturningPromise,
2223
handleOpenBrowser: RemoteApiPropertyType.MethodReturningPromise,
2324
handleOpenNamiBrowser: RemoteApiPropertyType.MethodReturningPromise,
2425
closeAllTabsAndOpenPopup: RemoteApiPropertyType.MethodReturningPromise,
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
2+
import { BehaviorSubject } from 'rxjs';
3+
import { storage } from 'webextension-polyfill';
4+
import { TokenPrices, Status } from '../../types';
5+
import { Wallet } from '@lace/cardano';
6+
import { Cardano } from '@cardano-sdk/core';
7+
import { config } from '@src/config';
8+
9+
/** The subset of token data from CoinGecko relevant for Lace to show token prices. */
10+
type PriceData = [priceInAda: number, priceVariationPercentage24h: number];
11+
12+
/** The structure to store token data and when given data for a token was fetched optimized to have the thinnest size once stringified. */
13+
type FetchedPriceData = [lastFetchTime: number] | [lastFetchTime: number, priceData: PriceData];
14+
15+
const CACHE_KEY = 'cardano-token-prices';
16+
const { TOKEN_PRICE_CHECK_INTERVAL } = config();
17+
18+
/**
19+
* Given the total amount of token prices data is quite small, rather than fetching it from cache every time it is required,
20+
* the entire data is cached in RAM by `priceData` to make read accesses as cheap as possible.
21+
*
22+
* Write accesses are centralized through `updatePriceData` to ensure data consistency.
23+
*/
24+
let priceData: Record<string, FetchedPriceData>;
25+
26+
const tokenPrices$ = new BehaviorSubject<{ tokens: TokenPrices } & Status>({
27+
tokens: new Map(),
28+
status: 'idle'
29+
});
30+
31+
const emitPrices = () =>
32+
tokenPrices$.next({
33+
status: 'fetched',
34+
tokens: new Map(
35+
Object.entries(priceData)
36+
.filter(([, data]) => data)
37+
.map(([assetId, [lastFetchTime, data]]) => {
38+
const asset = assetId as Wallet.Cardano.AssetId;
39+
40+
if (!data) return [asset, { lastFetchTime }];
41+
42+
const [priceInAda, priceVariationPercentage24h] = data;
43+
44+
return [asset, { lastFetchTime, price: { priceInAda, priceVariationPercentage24h } }];
45+
})
46+
)
47+
});
48+
49+
const updatePriceData = (assetId: string, data?: PriceData) => {
50+
const now = Date.now();
51+
52+
priceData[assetId] = data ? [now, data] : [now];
53+
emitPrices();
54+
storage.local
55+
.set({ [CACHE_KEY]: priceData })
56+
.catch((error) => console.error('Error setting cached cardano token prices', error));
57+
};
58+
59+
const fetchPrice = async (assetId: Cardano.AssetId) => {
60+
const [lastFetchTime, cachedData] = priceData[assetId] || [0];
61+
62+
// If recently fetched, do nothing
63+
if (lastFetchTime > Date.now() - TOKEN_PRICE_CHECK_INTERVAL) return;
64+
65+
// Immediately set the lastFetchTime to avoid other events fetching the same token price to actually perform the request
66+
updatePriceData(assetId, cachedData);
67+
68+
try {
69+
const url = `${process.env.TOKEN_PRICES_URL}/cardano/tokens/${assetId}/pools`;
70+
const response = await fetch(url);
71+
const body = await response.json();
72+
const data = body.data?.[0]?.attributes;
73+
74+
// If not the expected data, do nothing
75+
if (typeof data !== 'object') return;
76+
77+
const {
78+
base_token_price_native_currency: priceInAda,
79+
price_change_percentage: { h24: h24Change }
80+
} = data;
81+
82+
// If not the expected data, do nothing
83+
if (typeof priceInAda !== 'string' || typeof h24Change !== 'string') return;
84+
85+
updatePriceData(assetId, [Number.parseFloat(priceInAda), Number.parseFloat(h24Change)]);
86+
} catch (error) {
87+
console.error('Error fetching cardano token price', assetId, error);
88+
}
89+
};
90+
91+
const fetchPrices = async () => {
92+
for (const assetId in priceData) await fetchPrice(assetId as Cardano.AssetId);
93+
94+
setTimeout(fetchPrices, TOKEN_PRICE_CHECK_INTERVAL);
95+
};
96+
97+
export const initCardanoTokenPrices = () => {
98+
storage.local
99+
.get(CACHE_KEY)
100+
.then((data) => {
101+
priceData = data[CACHE_KEY] || {};
102+
103+
emitPrices();
104+
fetchPrices();
105+
})
106+
.catch((error) => {
107+
console.error('Error getting cached cardano token prices', error);
108+
tokenPrices$.next({ tokens: new Map(), status: 'error' });
109+
});
110+
111+
return tokenPrices$;
112+
};
113+
114+
export const trackCardanoTokenPrice = async (assetId: Cardano.AssetId) => {
115+
// If init not yet completed, do nothing
116+
if (!priceData) return;
117+
118+
fetchPrice(assetId);
119+
};

0 commit comments

Comments
 (0)