From b42d17ff9d1b38cddc374d40ec89d9fe42bba4ae Mon Sep 17 00:00:00 2001 From: nicholaspai <9457025+nicholaspai@users.noreply.github.com> Date: Tue, 14 Jan 2025 07:23:34 -0700 Subject: [PATCH] feat(API): Split gas component cron jobs up into different files (#1372) --- api/_types/utility.types.ts | 9 ++ api/cron-cache-gas-costs.ts | 152 ++++++++++++++++++++++++++++++++ api/cron-cache-gas-prices.ts | 144 +++++++------------------------ api/cron-cache-l1-data-fee.ts | 158 ++++++++++++++++++++++++++++++++++ vercel.json | 14 +++ 5 files changed, 363 insertions(+), 114 deletions(-) create mode 100644 api/cron-cache-gas-costs.ts create mode 100644 api/cron-cache-l1-data-fee.ts diff --git a/api/_types/utility.types.ts b/api/_types/utility.types.ts index 629d75320..68fcaf589 100644 --- a/api/_types/utility.types.ts +++ b/api/_types/utility.types.ts @@ -21,3 +21,12 @@ export type TokenInfo = { name: string; addresses: Record; }; + +export type DepositRoute = { + originChainId: number; + originToken: string; + destinationChainId: number; + destinationToken: string; + originTokenSymbol: string; + destinationTokenSymbol: string; +}; diff --git a/api/cron-cache-gas-costs.ts b/api/cron-cache-gas-costs.ts new file mode 100644 index 000000000..a2e6a6da8 --- /dev/null +++ b/api/cron-cache-gas-costs.ts @@ -0,0 +1,152 @@ +import { VercelResponse } from "@vercel/node"; +import { DepositRoute, TypedVercelRequest } from "./_types"; +import { + HUB_POOL_CHAIN_ID, + getCachedNativeGasCost, + getLogger, + handleErrorCondition, + resolveVercelEndpoint, +} from "./_utils"; +import { UnauthorizedError } from "./_errors"; + +import mainnetChains from "../src/data/chains_1.json"; +import { utils, constants } from "@across-protocol/sdk"; +import { DEFAULT_SIMULATED_RECIPIENT_ADDRESS } from "./_constants"; +import axios from "axios"; +import { ethers } from "ethers"; + +// Set lower than TTL in getCachedNativeGasCost. This should rarely change so we should just make sure +// we keep this cache warm. +const updateIntervalsSecPerChain = { + default: 30, +}; + +const maxDurationSec = 60; + +const getDepositArgsForChainId = (chainId: number, tokenAddress: string) => { + return { + amount: ethers.BigNumber.from(100), + inputToken: constants.ZERO_ADDRESS, + outputToken: tokenAddress, + recipientAddress: DEFAULT_SIMULATED_RECIPIENT_ADDRESS, + originChainId: 0, // Shouldn't matter for simulation + destinationChainId: Number(chainId), + }; +}; + +const handler = async ( + request: TypedVercelRequest>, + response: VercelResponse +) => { + const logger = getLogger(); + logger.debug({ + at: "CronCacheGasCosts", + message: "Starting cron job...", + }); + try { + const authHeader = request.headers?.["authorization"]; + if ( + !process.env.CRON_SECRET || + authHeader !== `Bearer ${process.env.CRON_SECRET}` + ) { + throw new UnauthorizedError(); + } + + // Skip cron job on testnet + if (HUB_POOL_CHAIN_ID !== 1) { + logger.info({ + at: "CronCacheGasCosts", + message: "Skipping cron job on testnet", + }); + return; + } + + const availableRoutes = ( + await axios(`${resolveVercelEndpoint()}/api/available-routes`) + ).data as Array; + + // This marks the timestamp when the function started + const functionStart = Date.now(); + + const updateCounts: Record> = {}; + + /** + * @notice Updates the native gas cost cache every `updateNativeGasCostIntervalsSecPerChain` seconds + * up to `maxDurationSec` seconds. + * @param chainId Chain to estimate gas cost for + * @param outputTokenAddress This output token will be used to construct a fill transaction to simulate + * gas costs for. + */ + const updateNativeGasCostPromise = async ( + chainId: number, + outputTokenAddress: string + ): Promise => { + updateCounts[chainId] ??= {}; + updateCounts[chainId][outputTokenAddress] ??= 0; + const secondsPerUpdate = updateIntervalsSecPerChain.default; + const depositArgs = getDepositArgsForChainId(chainId, outputTokenAddress); + const cache = getCachedNativeGasCost(depositArgs); + + while (true) { + const diff = Date.now() - functionStart; + // Stop after `maxDurationSec` seconds + if (diff >= maxDurationSec * 1000) { + break; + } + try { + await cache.set(); + updateCounts[chainId][outputTokenAddress]++; + } catch (err) { + logger.warn({ + at: "CronCacheGasCosts#updateNativeGasCostPromise", + message: `Failed to set native gas cost cache for chain ${chainId}`, + depositArgs, + error: err, + }); + } + await utils.delay(secondsPerUpdate); + } + }; + + const getOutputTokensToChain = (chainId: number) => { + const destinationTokens = new Set(); + availableRoutes + .filter(({ destinationChainId }) => destinationChainId === chainId) + .forEach(({ destinationToken }) => { + if (!destinationTokens.has(destinationToken)) { + destinationTokens.add(destinationToken); + } + }); + return Array.from(destinationTokens); + }; + + const cacheUpdatePromise = Promise.all( + mainnetChains.map(async (chain) => { + await Promise.all( + getOutputTokensToChain(chain.chainId).map((outputToken) => + updateNativeGasCostPromise(chain.chainId, outputToken) + ) + ); + }) + ); + // There are many routes and therefore many promises to wait to resolve so we force the + // function to stop after `maxDurationSec` seconds. + await Promise.race([cacheUpdatePromise, utils.delay(maxDurationSec)]); + + logger.debug({ + at: "CronCacheGasCosts", + message: "Finished", + updateCounts, + }); + response.status(200).json({ updateCounts }); + } catch (error: unknown) { + return handleErrorCondition( + "cron-cache-gas-costs", + response, + logger, + error + ); + } +}; + +export default handler; diff --git a/api/cron-cache-gas-prices.ts b/api/cron-cache-gas-prices.ts index 6e5f5cb92..f7e94b081 100644 --- a/api/cron-cache-gas-prices.ts +++ b/api/cron-cache-gas-prices.ts @@ -1,9 +1,7 @@ import { VercelResponse } from "@vercel/node"; -import { TypedVercelRequest } from "./_types"; +import { DepositRoute, TypedVercelRequest } from "./_types"; import { HUB_POOL_CHAIN_ID, - getCachedNativeGasCost, - getCachedOpStackL1DataFee, getLogger, handleErrorCondition, latestGasPriceCache, @@ -17,32 +15,11 @@ import { CHAIN_IDs, DEFAULT_SIMULATED_RECIPIENT_ADDRESS } from "./_constants"; import axios from "axios"; import { ethers } from "ethers"; -type Route = { - originChainId: number; - originToken: string; - destinationChainId: number; - destinationToken: string; - originTokenSymbol: string; - destinationTokenSymbol: string; -}; - // Set lower than TTL in latestGasPriceCache const updateIntervalsSecPerChain = { default: 5, }; -// Set lower than TTL in getCachedOpStackL1DataFee -// Set lower than the L1 block time so we can try to get as up to date L1 data fees based on L1 base fees as possible. -const updateL1DataFeeIntervalsSecPerChain = { - default: 10, -}; - -// Set lower than TTL in getCachedNativeGasCost. This should rarely change so we should just make sure -// we keep this cache warm. -const updateNativeGasCostIntervalsSecPerChain = { - default: 30, -}; - const maxDurationSec = 60; const getDepositArgsForChainId = (chainId: number, tokenAddress: string) => { @@ -85,11 +62,13 @@ const handler = async ( const availableRoutes = ( await axios(`${resolveVercelEndpoint()}/api/available-routes`) - ).data as Array; + ).data as Array; // This marks the timestamp when the function started const functionStart = Date.now(); + const updateCounts: Record = {}; + /** * @notice Updates the gas price cache every `updateIntervalsSecPerChain` seconds up to `maxDurationSec` seconds. * @param chainId Chain to estimate gas price for @@ -100,39 +79,12 @@ const handler = async ( chainId: number, outputTokenAddress?: string ): Promise => { + updateCounts[chainId] ??= 0; const secondsPerUpdateForChain = updateIntervalsSecPerChain.default; - const cache = latestGasPriceCache( - chainId, - outputTokenAddress - ? getDepositArgsForChainId(chainId, outputTokenAddress) - : undefined - ); - - while (true) { - const diff = Date.now() - functionStart; - // Stop after `maxDurationSec` seconds - if (diff >= maxDurationSec * 1000) { - break; - } - await cache.set(); - await utils.delay(secondsPerUpdateForChain); - } - }; - - /** - * @notice Updates the L1 data fee gas cost cache every `updateL1DataFeeIntervalsSecPerChain` seconds - * up to `maxDurationSec` seconds. - * @param chainId Chain to estimate l1 data fee for - * @param outputTokenAddress This output token will be used to construct a fill transaction to simulate - * gas costs for. - */ - const updateL1DataFeePromise = async ( - chainId: number, - outputTokenAddress: string - ): Promise => { - const secondsPerUpdate = updateL1DataFeeIntervalsSecPerChain.default; - const depositArgs = getDepositArgsForChainId(chainId, outputTokenAddress); - const gasCostCache = getCachedNativeGasCost(depositArgs); + const depositArgs = outputTokenAddress + ? getDepositArgsForChainId(chainId, outputTokenAddress) + : undefined; + const cache = latestGasPriceCache(chainId, depositArgs); while (true) { const diff = Date.now() - functionStart; @@ -140,44 +92,30 @@ const handler = async ( if (diff >= maxDurationSec * 1000) { break; } - const gasCost = await gasCostCache.get(); - if (utils.chainIsOPStack(chainId)) { - const cache = getCachedOpStackL1DataFee(depositArgs, gasCost); + try { await cache.set(); + updateCounts[chainId]++; + } catch (err) { + logger.warn({ + at: "CronCacheGasPrices#updateGasPricePromise", + message: `Failed to set gas price cache for chain ${chainId}`, + depositArgs, + error: err, + }); } - await utils.delay(secondsPerUpdate); + await utils.delay(secondsPerUpdateForChain); } }; - /** - * @notice Updates the native gas cost cache every `updateNativeGasCostIntervalsSecPerChain` seconds - * up to `maxDurationSec` seconds. - * @param chainId Chain to estimate gas cost for - * @param outputTokenAddress This output token will be used to construct a fill transaction to simulate - * gas costs for. - */ - const updateNativeGasCostPromise = async ( - chainId: number, - outputTokenAddress: string - ): Promise => { - const secondsPerUpdate = updateNativeGasCostIntervalsSecPerChain.default; - const depositArgs = getDepositArgsForChainId(chainId, outputTokenAddress); - const cache = getCachedNativeGasCost(depositArgs); - - while (true) { - const diff = Date.now() - functionStart; - // Stop after `maxDurationSec` seconds - if (diff >= maxDurationSec * 1000) { - break; - } - await cache.set(); - await utils.delay(secondsPerUpdate); - } + const lineaDestinationRoutes = () => { + const routes = new Set(); + availableRoutes + .filter( + ({ destinationChainId }) => destinationChainId === CHAIN_IDs.LINEA + ) + .forEach(({ destinationToken }) => routes.add(destinationToken)); + return Array.from(routes); }; - - const lineaDestinationRoutes = availableRoutes.filter( - ({ destinationChainId }) => destinationChainId === CHAIN_IDs.LINEA - ); // The minimum interval for Vercel Serverless Functions cron jobs is 1 minute. // But we want to update gas data more frequently than that. // To circumvent this, we run the function in a loop and update gas prices every @@ -191,40 +129,18 @@ const handler = async ( .map((chain) => updateGasPricePromise(chain.chainId)) ), Promise.all( - lineaDestinationRoutes.map(({ destinationToken }) => + lineaDestinationRoutes().map((destinationToken) => updateGasPricePromise(CHAIN_IDs.LINEA, destinationToken) ) ), - Promise.all( - mainnetChains.map(async (chain) => { - const routesToChain = availableRoutes.filter( - ({ destinationChainId }) => destinationChainId === chain.chainId - ); - const outputTokensForChain = routesToChain.map( - ({ destinationToken }) => destinationToken - ); - await Promise.all([ - Promise.all( - outputTokensForChain.map((outputToken) => - updateNativeGasCostPromise(chain.chainId, outputToken) - ) - ), - Promise.all( - outputTokensForChain.map((outputToken) => - updateL1DataFeePromise(chain.chainId, outputToken) - ) - ), - ]); - }) - ), ]); logger.debug({ at: "CronCacheGasPrices", message: "Finished", + updateCounts, }); - response.status(200); - response.send("OK"); + response.status(200).json({ updateCounts }); } catch (error: unknown) { return handleErrorCondition( "cron-cache-gas-prices", diff --git a/api/cron-cache-l1-data-fee.ts b/api/cron-cache-l1-data-fee.ts new file mode 100644 index 000000000..483088469 --- /dev/null +++ b/api/cron-cache-l1-data-fee.ts @@ -0,0 +1,158 @@ +import { VercelResponse } from "@vercel/node"; +import { DepositRoute, TypedVercelRequest } from "./_types"; +import { + HUB_POOL_CHAIN_ID, + getCachedNativeGasCost, + getCachedOpStackL1DataFee, + getLogger, + handleErrorCondition, + resolveVercelEndpoint, +} from "./_utils"; +import { UnauthorizedError } from "./_errors"; + +import mainnetChains from "../src/data/chains_1.json"; +import { utils, constants } from "@across-protocol/sdk"; +import { DEFAULT_SIMULATED_RECIPIENT_ADDRESS } from "./_constants"; +import axios from "axios"; +import { ethers } from "ethers"; + +// Set lower than TTL in getCachedOpStackL1DataFee +// Set lower than the L1 block time so we can try to get as up to date L1 data fees based on L1 base fees as possible. +const updateIntervalsSecPerChain = { + default: 10, +}; + +const maxDurationSec = 60; + +const getDepositArgsForChainId = (chainId: number, tokenAddress: string) => { + return { + amount: ethers.BigNumber.from(100), + inputToken: constants.ZERO_ADDRESS, + outputToken: tokenAddress, + recipientAddress: DEFAULT_SIMULATED_RECIPIENT_ADDRESS, + originChainId: 0, // Shouldn't matter for simulation + destinationChainId: Number(chainId), + }; +}; + +const handler = async ( + request: TypedVercelRequest>, + response: VercelResponse +) => { + const logger = getLogger(); + logger.debug({ + at: "CronCacheL1DataFee", + message: "Starting cron job...", + }); + try { + const authHeader = request.headers?.["authorization"]; + if ( + !process.env.CRON_SECRET || + authHeader !== `Bearer ${process.env.CRON_SECRET}` + ) { + throw new UnauthorizedError(); + } + + // Skip cron job on testnet + if (HUB_POOL_CHAIN_ID !== 1) { + logger.info({ + at: "CronCacheL1DataFee", + message: "Skipping cron job on testnet", + }); + return; + } + + const availableRoutes = ( + await axios(`${resolveVercelEndpoint()}/api/available-routes`) + ).data as Array; + + // This marks the timestamp when the function started + const functionStart = Date.now(); + + const updateCounts: Record> = {}; + + /** + * @notice Updates the L1 data fee gas cost cache every `updateL1DataFeeIntervalsSecPerChain` seconds + * up to `maxDurationSec` seconds. + * @param chainId Chain to estimate l1 data fee for + * @param outputTokenAddress This output token will be used to construct a fill transaction to simulate + * gas costs for. + */ + const updateL1DataFeePromise = async ( + chainId: number, + outputTokenAddress: string + ): Promise => { + updateCounts[chainId] ??= {}; + updateCounts[chainId][outputTokenAddress] ??= 0; + const secondsPerUpdate = updateIntervalsSecPerChain.default; + const depositArgs = getDepositArgsForChainId(chainId, outputTokenAddress); + const gasCostCache = getCachedNativeGasCost(depositArgs); + + while (true) { + const diff = Date.now() - functionStart; + // Stop after `maxDurationSec` seconds + if (diff >= maxDurationSec * 1000) { + break; + } + const gasCost = await gasCostCache.get(); + const cache = getCachedOpStackL1DataFee(depositArgs, gasCost); + try { + await cache.set(); + updateCounts[chainId][outputTokenAddress]++; + } catch (err) { + logger.warn({ + at: "CronCacheL1DataFee#updateL1DataFeePromise", + message: `Failed to set l1 data fee cache for chain ${chainId}`, + depositArgs, + gasCost, + error: err, + }); + } + await utils.delay(secondsPerUpdate); + } + }; + + const getOutputTokensToChain = (chainId: number) => { + const destinationTokens = new Set(); + availableRoutes + .filter(({ destinationChainId }) => destinationChainId === chainId) + .forEach(({ destinationToken }) => { + if (!destinationTokens.has(destinationToken)) { + destinationTokens.add(destinationToken); + } + }); + return Array.from(destinationTokens); + }; + + const cacheUpdatePromise = Promise.all( + mainnetChains + .filter((chain) => utils.chainIsOPStack(chain.chainId)) + .map(async (chain) => { + await Promise.all( + getOutputTokensToChain(chain.chainId).map((outputToken) => + updateL1DataFeePromise(chain.chainId, outputToken) + ) + ); + }) + ); + // There are many routes and therefore many promises to wait to resolve so we force the + // function to stop after `maxDurationSec` seconds. + await Promise.race([cacheUpdatePromise, utils.delay(maxDurationSec)]); + + logger.debug({ + at: "CronCacheL1DataFee", + message: "Finished", + updateCounts, + }); + response.status(200).json({ updateCounts }); + } catch (error: unknown) { + return handleErrorCondition( + "cron-cache-l1-data-fee", + response, + logger, + error + ); + } +}; + +export default handler; diff --git a/vercel.json b/vercel.json index fa7fa812a..c777ee361 100644 --- a/vercel.json +++ b/vercel.json @@ -9,6 +9,14 @@ "path": "/api/cron-cache-gas-prices", "schedule": "* * * * *" }, + { + "path": "/api/cron-cache-gas-costs", + "schedule": "* * * * *" + }, + { + "path": "/api/cron-cache-l1-data-fee", + "schedule": "* * * * *" + }, { "path": "/api/cron-ping-endpoints", "schedule": "* * * * *" @@ -18,6 +26,12 @@ "api/cron-cache-gas-prices.ts": { "maxDuration": 90 }, + "api/cron-cache-gas-costs.ts": { + "maxDuration": 90 + }, + "api/cron-cache-l1-data-fee.ts": { + "maxDuration": 90 + }, "api/cron-ping-endpoints.ts": { "maxDuration": 90 }