Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added some more explicit code and comments #1415

Merged
merged 1 commit into from
Mar 6, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 116 additions & 37 deletions netlify/functions/tokenPrices.mts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getStore } from '@netlify/blobs';
import { ethers } from 'ethers';

type TokenPriceWithMetadata = {
data: {
Expand All @@ -11,84 +12,162 @@ type TokenPriceWithMetadata = {
};

export default async function getTokenPrices(request: Request) {
const store = getStore('token-prices');
const tokensString = new URL(request.url).searchParams.get('tokens');
if (!process.env.COINGECKO_API_KEY) {
console.error('CoinGecko API key is missing');
return Response.json({ error: 'Unknown error while fetching prices' }, { status: 503 });
}

const now = Date.now();

if (!tokensString) {
return Response.json({ error: 'Tokens to request were not provided' });
// First we want to pull the tokens off of the request's query param.
const tokensStringParam = new URL(request.url).searchParams.get('tokens');
if (!tokensStringParam) {
return Response.json({ error: 'Tokens to request were not provided' }, { status: 400 });
}
const tokens = tokensString.split(',');

// These are the token addresses from the client, split up.
const rawTokenAddresses = tokensStringParam.split(',');

// Let's make sure all of these given addresses are valid.
const anyInvalidTokens = rawTokenAddresses.some(address => !ethers.utils.isAddress(address));
if (!anyInvalidTokens) {
return Response.json({ error: 'One or more token addresses is invalid' }, { status: 400 });
}

// Next we want to standardize them all by making them lowercase.
const lowerCaseTokens = rawTokenAddresses.map(address => address.toLowerCase());

// Finally, make sure we're dealing with a unique set of token addresses.
const tokens = [...new Set(lowerCaseTokens)];

const store = getStore('token-prices');

// Let's immediately build up our repsonse object, containing each
// token address and an value of 0. We'll modify this along the way
// populating it with more relevant prices.
const responseBody: Record<string, number> = tokens.reduce((p, c) => ({ ...p, [c]: 0 }), {});

try {
const now = Date.now();
const cachedPrices = await Promise.all(
// Try to get all of the tokens from our store.
// Any token address that we don't have a record for will
// come back as null.
const possibleCachedTokenPrices = await Promise.all(
tokens.map(
tokenAddress =>
store.getWithMetadata(tokenAddress, {
type: 'json',
}) as Promise<TokenPriceWithMetadata> | null
)
);
const cachedUnexpiredPrices = cachedPrices
.filter(tokenPrice => tokenPrice && tokenPrice.metadata.expiration <= now)
.map(tokenPrice => ({
tokenAddress: tokenPrice!.data.tokenAddress,
price: tokenPrice!.data.price,
}));
const nonCachedTokensAddresses = tokens.filter(
address => !cachedUnexpiredPrices.find(tokenPrice => tokenPrice.tokenAddress === address)

// Filter out the null values, leaving us with an array of
// TokenPricesWithMetadata. All of these TokenPrices will be either
// expired or unexpired.
const allCachedTokenPrices = possibleCachedTokenPrices.filter(
(possible): possible is TokenPriceWithMetadata => possible !== null
);

// Let's pull out all of the unexpired TokenPrices from our cache.
const cachedUnexpiredTokenPrices = allCachedTokenPrices.filter(
tokenPrice => tokenPrice.metadata.expiration <= now
);

// We'll update our response with those unexpired cached prices.
cachedUnexpiredTokenPrices.forEach(tokenPrice => {
responseBody[tokenPrice.data.tokenAddress] = tokenPrice.data.price;
});

// Let's pull out all of the expired TokenPrices from our cache.
const cachedExpiredTokenPrices = allCachedTokenPrices.filter(
tokenPrice => tokenPrice.metadata.expiration > now
);
const responseBody: Record<string, number> = {};
cachedUnexpiredPrices.forEach(tokenPrice => {
responseBody[tokenPrice.tokenAddress] = tokenPrice.price;

// We'll update our response with those expired cached prices.
// This is done now in the offchance that we won't be able to contact
// CoinGecko later. Ideally these will be updated after getting
// fresher data from CoinGecko
cachedExpiredTokenPrices.forEach(tokenPrice => {
responseBody[tokenPrice.data.tokenAddress] = tokenPrice.data.price;
});
if (nonCachedTokensAddresses.length === 0) {

// Finally let's get a list of all of the token addresses that
// we don't have any price for in our cache, expired or not.
const allUncachedTokenPrices = possibleCachedTokenPrices.filter(
(possible): possible is TokenPriceWithMetadata => possible === null
);

// If there are no expired token prices, and no token addresses that we
// don't have a cached value for at all, we can early return!
if (allUncachedTokenPrices.length === 0 && cachedExpiredTokenPrices.length === 0) {
return Response.json({ data: responseBody });
}
if (!process.env.COINGECKO_API_KEY) {
console.error('CoinGecko API key is missing');
return Response.json({ error: 'Unknown error while fetching prices' });
}

// If we got here, then we have either some expired prices for given tokens,
// or no prices at all for given tokens.

// First, let's build up our list of token addresses to query CoinGecko with,
// which is all uncached tokens and tokens that have expired.
const needPrices = [...allUncachedTokenPrices, ...cachedExpiredTokenPrices];

try {
// Build up the request URL.
const PUBLIC_DEMO_API_BASE_URL = 'https://api.coingecko.com/api/v3/';
const AUTH_QUERY_PARAM = `?x_cg_demo_api_key=${process.env.COINGECKO_API_KEY}`;
const tokenPricesUrl = `${PUBLIC_DEMO_API_BASE_URL}simple/token_price/ethereum/${AUTH_QUERY_PARAM}&vs_currencies=usd&contract_addresses=${nonCachedTokensAddresses.join(
const tokenPricesUrl = `${PUBLIC_DEMO_API_BASE_URL}simple/token_price/ethereum/${AUTH_QUERY_PARAM}&vs_currencies=usd&contract_addresses=${needPrices.join(
','
)}`;

// Make the request.
const tokenPricesResponse = await fetch(tokenPricesUrl);
const tokenPricesResponseJson = await tokenPricesResponse.json();

// Create the metadata for our new token prices, with an
// expiration in the future.
const tokenPriceMetadata = { metadata: { expiration: now + 1000 * 60 * 30 } };

// With our response...
Object.keys(tokenPricesResponseJson).forEach(tokenAddress => {
const price = tokenPricesResponseJson[tokenAddress].usd;
responseBody[tokenAddress] = price;
store.setJSON(tokenAddress, { tokenAddress, price }, tokenPriceMetadata);
const sanitizedAddress = tokenAddress.toLowerCase();
// 1. Replace the token addresses of our existing response object with the new prices.
responseBody[sanitizedAddress] = price;
// 2. Store these fresh prices in our Blob store.
store.setJSON(
sanitizedAddress,
{ tokenAddress: sanitizedAddress, price },
tokenPriceMetadata
);
});

const ethAsset = nonCachedTokensAddresses.find(token => token === 'ethereum');
// Do we need to get the price of our chain's gas token (ethereum)?
const ethAsset = needPrices.find(token => token.data.tokenAddress === 'ethereum');
if (ethAsset) {
// Unfortunately, there's no way avoiding 2 requests. We either need to fetch asset IDs from CoinGecko for given token contract addresses
// And then use this endpoint to get all the prices. But that brings us way more bandwidth
// Or, we are doing this "hardcoded" call for ETH price. But our request for token prices simpler.
// Build up the request URL.
const ethPriceUrl = `${PUBLIC_DEMO_API_BASE_URL}simple/price${AUTH_QUERY_PARAM}&ids=ethereum&vs_currencies=usd`;

// Make the request.
const ethPriceResponse = await fetch(ethPriceUrl);
const price = (await ethPriceResponse.json()).ethereum.usd;
store.setJSON('ethereum', { tokenAddress: 'ethereum', price }, tokenPriceMetadata);
const ethPriceResponseJson = await ethPriceResponse.json();

// Get the price data.
const price = ethPriceResponseJson.ethereum.usd;

// 1. Replace the token addresses of our existing response object with the new prices.
responseBody.ethereum = price;

// 2. Store this fresh prices in our Blob store.
store.setJSON('ethereum', { tokenAddress: 'ethereum', price }, tokenPriceMetadata);
}
return Response.json({ data: responseBody });
} catch (e) {
console.error('Error while querying CoinGecko', e);
cachedPrices.forEach(tokenPrice => {
if (tokenPrice && !responseBody[tokenPrice.data.tokenAddress]) {
responseBody[tokenPrice.data.tokenAddress] = tokenPrice.data.price;
}
});
return Response.json({ error: 'Error while fetching prices', data: responseBody });
}
} catch (e) {
console.error('Error while fetching prices', e);
return Response.json({
error: 'Unknown error while fetching prices',
data: responseBody,
});
}
}