From 56658e3e6374bfd77c44765429a87c5e5095857c Mon Sep 17 00:00:00 2001 From: "James Morris, MS" <96435344+james-a-morris@users.noreply.github.com> Date: Tue, 26 Nov 2024 07:24:36 -0500 Subject: [PATCH 1/5] feat(indexer): include deposit tracking for load testing (#1280) Signed-off-by: james-a-morris --- src/hooks/useIndexerDepositTracking.ts | 75 +++++++++++++++++++ src/utils/constants.ts | 3 + .../components/PersonalTransactions.tsx | 9 +++ 3 files changed, 87 insertions(+) create mode 100644 src/hooks/useIndexerDepositTracking.ts diff --git a/src/hooks/useIndexerDepositTracking.ts b/src/hooks/useIndexerDepositTracking.ts new file mode 100644 index 000000000..6bc027a93 --- /dev/null +++ b/src/hooks/useIndexerDepositTracking.ts @@ -0,0 +1,75 @@ +import { useQueries } from "@tanstack/react-query"; +import axios from "axios"; +import { BigNumber } from "ethers"; +import { indexerApiBaseUrl, isDefined } from "utils"; + +/** + * A hook used to track the statuses of multiple deposits via the indexer API + * @param deposits Array of deposit objects containing `originChainId` and `depositId` + */ +export function useIndexerDepositsTracking( + deposits: { + originChainId?: number; + depositId?: number; + }[] +) { + const queries = useQueries({ + queries: deposits.map((deposit) => ({ + queryKey: [ + "indexer_deposit_tracking", + deposit.originChainId, + deposit.depositId, + ] as [string, number, number], + enabled: + isDefined(deposit.originChainId) && + isDefined(deposit.depositId) && + isDefined(indexerApiBaseUrl), + queryFn: async (): Promise => { + try { + const response = await axios.get( + `${indexerApiBaseUrl}/deposit/status`, + { + params: { + originChainId: deposit.originChainId, + depositId: deposit.depositId, + }, + } + ); + return response.data; + } catch (e) { + // FIXME: for now we ignore since this is for load testing purposes + } + }, + refetchInterval: 5_000, // 5 seconds + })), + }); + + return queries.map((query) => ({ + depositStatus: query.data ?? undefined, + ...query, + })); +} + +/** + * A hook used to track a single deposit status via the indexer API + * @param originChainId The chain ID of the deposit's origin + * @param depositId The deposit ID + */ +export function useIndexerDepositTracking( + originChainId?: number, + depositId?: number +) { + const [singleDeposit] = useIndexerDepositsTracking( + isDefined(originChainId) && isDefined(depositId) + ? [{ originChainId, depositId }] + : [] + ); + + return ( + singleDeposit ?? { + depositStatus: undefined, + isLoading: false, + isError: false, + } + ); +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f4b6e3bbc..152be5e6d 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -536,3 +536,6 @@ export const vercelApiBaseUrl = export const defaultSwapSlippage = Number( process.env.REACT_APP_DEFAULT_SWAP_SLIPPAGE || 0.5 ); + +export const indexerApiBaseUrl = + process.env.REACT_APP_INDEXER_BASE_URL || undefined; diff --git a/src/views/Transactions/components/PersonalTransactions.tsx b/src/views/Transactions/components/PersonalTransactions.tsx index f4bbcc042..2b30e4db2 100644 --- a/src/views/Transactions/components/PersonalTransactions.tsx +++ b/src/views/Transactions/components/PersonalTransactions.tsx @@ -11,6 +11,7 @@ import { EmptyTable } from "./EmptyTable"; import { usePersonalTransactions } from "../hooks/usePersonalTransactions"; import { DepositStatusFilter } from "../types"; import { SpeedUpModal } from "./SpeedUpModal"; +import { useIndexerDepositsTracking } from "hooks/useIndexerDepositTracking"; type Props = { statusFilter: DepositStatusFilter; @@ -32,6 +33,14 @@ export function PersonalTransactions({ statusFilter }: Props) { const history = useHistory(); const queryClient = useQueryClient(); + // FIXME: remove after tracking is complete + void useIndexerDepositsTracking( + deposits.map((d) => ({ + depositId: d.depositId, + originChainId: d.sourceChainId, + })) + ); + if (!isConnected) { return ( From 6f50100f5f0330c28ee42a0e1c81780ae6260094 Mon Sep 17 00:00:00 2001 From: "James Morris, MS" <96435344+james-a-morris@users.noreply.github.com> Date: Wed, 27 Nov 2024 10:56:06 -0500 Subject: [PATCH 2/5] feat: throw quote error if fees exceed 500bips (#1292) --- src/views/Bridge/components/AmountInput.tsx | 4 ++- src/views/Bridge/hooks/useBridge.ts | 2 +- src/views/Bridge/utils.ts | 29 ++++++++++++++++++--- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/views/Bridge/components/AmountInput.tsx b/src/views/Bridge/components/AmountInput.tsx index b68f7c1d6..9691077b6 100644 --- a/src/views/Bridge/components/AmountInput.tsx +++ b/src/views/Bridge/components/AmountInput.tsx @@ -6,7 +6,7 @@ import { AmountInputError, SelectedRoute } from "../utils"; import { formatUnitsWithMaxFractions, getToken } from "utils"; import { BridgeLimits } from "hooks"; -const validationErrorTextMap = { +const validationErrorTextMap: Record = { [AmountInputError.INSUFFICIENT_BALANCE]: "Insufficient balance to process this transfer.", [AmountInputError.PAUSED_DEPOSITS]: @@ -16,6 +16,8 @@ const validationErrorTextMap = { [AmountInputError.INVALID]: "Only positive numbers are allowed as an input.", [AmountInputError.AMOUNT_TOO_LOW]: "The amount you are trying to bridge is too low.", + [AmountInputError.PRICE_IMPACT_TOO_HIGH]: + "Price impact is too high. Check back later when liquidity is restored.", }; type Props = { diff --git a/src/views/Bridge/hooks/useBridge.ts b/src/views/Bridge/hooks/useBridge.ts index 40d54ec3f..1df04e653 100644 --- a/src/views/Bridge/hooks/useBridge.ts +++ b/src/views/Bridge/hooks/useBridge.ts @@ -61,7 +61,7 @@ export function useBridge() { const { error: amountValidationError } = validateBridgeAmount( parsedAmount, - quotedFees?.isAmountTooLow, + quotedFees, maxBalance, limitsQuery.limits?.maxDeposit, selectedRoute.type === "swap" && quotedSwap?.minExpectedInputTokenAmount diff --git a/src/views/Bridge/utils.ts b/src/views/Bridge/utils.ts index c82446168..28925491a 100644 --- a/src/views/Bridge/utils.ts +++ b/src/views/Bridge/utils.ts @@ -1,6 +1,5 @@ import { CHAIN_IDs } from "@across-protocol/constants"; import { BigNumber } from "ethers"; - import { Route, SwapRoute, @@ -12,6 +11,9 @@ import { isProductionBuild, interchangeableTokensMap, nonEthChains, + GetBridgeFeesResult, + isDefined, + parseUnits, } from "utils"; import { SwapQuoteApiResponse } from "utils/serverless-api/prod/swap-quote"; @@ -37,6 +39,7 @@ export enum AmountInputError { INSUFFICIENT_LIQUIDITY = "insufficientLiquidity", INSUFFICIENT_BALANCE = "insufficientBalance", AMOUNT_TOO_LOW = "amountTooLow", + PRICE_IMPACT_TOO_HIGH = "priceImpactTooHigh", } const config = getConfig(); const enabledRoutes = config.getEnabledRoutes(); @@ -92,7 +95,7 @@ export function getReceiveTokenSymbol( export function validateBridgeAmount( parsedAmountInput?: BigNumber, - isAmountTooLow?: boolean, + quoteFees?: GetBridgeFeesResult, currentBalance?: BigNumber, maxDeposit?: BigNumber, amountToBridgeAfterSwap?: BigNumber @@ -121,12 +124,32 @@ export function validateBridgeAmount( }; } - if (isAmountTooLow) { + if (quoteFees?.isAmountTooLow) { return { error: AmountInputError.AMOUNT_TOO_LOW, }; } + if ( + isDefined(quoteFees) && + isDefined(parsedAmountInput) && + !quoteFees.isAmountTooLow + ) { + const bridgeFee = quoteFees.relayerCapitalFee.total.add( + quoteFees.lpFee.total + ); + const gasFee = quoteFees.relayerGasFee.total; + const totalFeeInL1 = bridgeFee.add(gasFee); + const maximalFee = parsedAmountInput + .mul(parseUnits("0.0500", 18)) // Cap fee at 500 basis points of input amount + .div(fixedPointAdjustment); + if (totalFeeInL1.gt(maximalFee)) { + return { + error: AmountInputError.PRICE_IMPACT_TOO_HIGH, + }; + } + } + if (parsedAmountInput.lt(0) || amountToBridgeAfterSwap.lt(0)) { return { error: AmountInputError.INVALID, From be502099e9c25db670be6202fca94e5ee4eb3a10 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Thu, 28 Nov 2024 17:29:30 +0700 Subject: [PATCH 3/5] feat: add unblocking warning-level input validation (#1296) --- src/components/AmountInput/AmountInput.tsx | 47 +++++++++++++++------ src/views/Bridge/Bridge.tsx | 2 + src/views/Bridge/components/AmountInput.tsx | 44 +++++++++++++++---- src/views/Bridge/components/BridgeForm.tsx | 5 +++ src/views/Bridge/hooks/useBridge.ts | 20 +++++---- src/views/Bridge/utils.ts | 2 +- 6 files changed, 88 insertions(+), 32 deletions(-) diff --git a/src/components/AmountInput/AmountInput.tsx b/src/components/AmountInput/AmountInput.tsx index 4154da6dd..08852953e 100644 --- a/src/components/AmountInput/AmountInput.tsx +++ b/src/components/AmountInput/AmountInput.tsx @@ -8,11 +8,11 @@ import { Text } from "components/Text"; import { Tooltip } from "components/Tooltip"; import { useTokenConversion } from "hooks/useTokenConversion"; import { + COLORS, QUERIESV2, formatUSD, formatUnitsWithMaxFractions, getToken, - isDefined, isNumberEthersParseable, parseUnits, } from "utils"; @@ -25,6 +25,7 @@ export type Props = { onClickMaxBalance: () => void; inputTokenSymbol: string; validationError?: string; + validationWarning?: string; dataCy?: string; disableErrorText?: boolean; disableInput?: boolean; @@ -41,6 +42,7 @@ export function AmountInput({ onClickMaxBalance, inputTokenSymbol, validationError, + validationWarning, disableErrorText, disableInput, disableMaxButton, @@ -48,8 +50,14 @@ export function AmountInput({ }: Props) { const token = getToken(inputTokenSymbol); - const isAmountValid = - (amountInput ?? "") === "" || !isDefined(validationError); + const validationLevel = + (amountInput ?? "") === "" + ? "valid" + : validationError + ? "error" + : validationWarning + ? "warning" + : "valid"; const { convertTokenToBaseCurrency } = useTokenConversion( inputTokenSymbol, @@ -68,7 +76,7 @@ export function AmountInput({ return ( - + {displayTokenIcon ? ( token.logoURIs?.length === 2 ? ( @@ -84,7 +92,7 @@ export function AmountInput({ ) : null} e.currentTarget.blur()} @@ -130,9 +138,9 @@ export function AmountInput({ ${formatUSD(estimatedUsdInputAmount)} )} - {!isAmountValid && !disableErrorText && ( - - {validationError} + {validationLevel !== "valid" && !disableErrorText && ( + + {validationError || validationWarning} )} @@ -143,9 +151,24 @@ export function AmountInput({ export default AmountInput; interface IValidInput { - valid: boolean; + validationLevel: "valid" | "error" | "warning"; } +const colorMap = { + valid: { + border: COLORS["grey-600"], + text: COLORS["white-100"], + }, + error: { + border: COLORS.red, + text: COLORS.red, + }, + warning: { + border: COLORS.yellow, + text: COLORS.yellow, + }, +}; + const Wrapper = styled.div` display: flex; flex-direction: column; @@ -159,7 +182,7 @@ const InputGroupWrapper = styled.div` align-items: center; padding: 9px 12px 9px 16px; background: #2d2e33; - border: 1px solid ${({ valid }) => (valid ? "#3E4047" : "#f96c6c")}; + border: 1px solid ${({ validationLevel }) => colorMap[validationLevel].border}; border-radius: 12px; height: 48px; gap: 8px; @@ -173,9 +196,7 @@ const Input = styled.input` font-weight: 400; font-size: 18px; line-height: 26px; - color: #e0f3ff; - - color: ${({ valid }) => (valid ? "#e0f3ff" : "#f96c6c")}; + color: ${({ validationLevel }) => colorMap[validationLevel].text}; background: none; width: 100%; diff --git a/src/views/Bridge/Bridge.tsx b/src/views/Bridge/Bridge.tsx index 80887f201..c6f3baa23 100644 --- a/src/views/Bridge/Bridge.tsx +++ b/src/views/Bridge/Bridge.tsx @@ -23,6 +23,7 @@ const Bridge = () => { fees, balance, amountValidationError, + amountValidationWarning, userAmountInput, swapSlippage, parsedAmountInput, @@ -78,6 +79,7 @@ const Bridge = () => { buttonLabel={buttonLabel} isBridgeDisabled={isBridgeDisabled} validationError={amountValidationError} + validationWarning={amountValidationWarning} balance={balance} isQuoteLoading={isQuoteLoading} swapQuote={swapQuote} diff --git a/src/views/Bridge/components/AmountInput.tsx b/src/views/Bridge/components/AmountInput.tsx index 9691077b6..371f98999 100644 --- a/src/views/Bridge/components/AmountInput.tsx +++ b/src/views/Bridge/components/AmountInput.tsx @@ -27,6 +27,7 @@ type Props = { onClickMaxBalance: () => void; selectedRoute: SelectedRoute; validationError?: AmountInputError; + validationWarning?: AmountInputError; limits?: BridgeLimits; }; @@ -37,6 +38,7 @@ export function AmountInput({ onClickMaxBalance, selectedRoute, validationError, + validationWarning, limits, }: Props) { return ( @@ -49,15 +51,20 @@ export function AmountInput({ } validationError={ validationError - ? validationErrorTextMap[validationError] - .replace("[INPUT_TOKEN]", selectedRoute.fromTokenSymbol) - .replace( - "[MAX_DEPOSIT]", - `${formatUnitsWithMaxFractions( - limits?.maxDeposit || 0, - getToken(selectedRoute.fromTokenSymbol).decimals - )} ${selectedRoute.fromTokenSymbol}` - ) + ? getValidationErrorText({ + validationError: validationError, + selectedRoute, + limits, + }) + : undefined + } + validationWarning={ + validationWarning + ? getValidationErrorText({ + validationError: validationWarning, + selectedRoute, + limits, + }) : undefined } onChangeAmountInput={onChangeAmountInput} @@ -69,4 +76,23 @@ export function AmountInput({ ); } +function getValidationErrorText(props: { + validationError?: AmountInputError; + selectedRoute: SelectedRoute; + limits?: BridgeLimits; +}) { + if (!props.validationError) { + return undefined; + } + return validationErrorTextMap[props.validationError] + .replace("[INPUT_TOKEN]", props.selectedRoute.fromTokenSymbol) + .replace( + "[MAX_DEPOSIT]", + `${formatUnitsWithMaxFractions( + props.limits?.maxDeposit || 0, + getToken(props.selectedRoute.fromTokenSymbol).decimals + )} ${props.selectedRoute.fromTokenSymbol}` + ); +} + export default AmountInput; diff --git a/src/views/Bridge/components/BridgeForm.tsx b/src/views/Bridge/components/BridgeForm.tsx index 339868b46..510a6e170 100644 --- a/src/views/Bridge/components/BridgeForm.tsx +++ b/src/views/Bridge/components/BridgeForm.tsx @@ -66,6 +66,7 @@ export type BridgeFormProps = { buttonLabel: string; isBridgeDisabled: boolean; validationError?: AmountInputError; + validationWarning?: AmountInputError; isQuoteLoading: boolean; }; @@ -101,6 +102,7 @@ const BridgeForm = ({ buttonLabel, isBridgeDisabled, validationError, + validationWarning, isQuoteLoading, }: BridgeFormProps) => { const programName = chainIdToRewardsProgramName[selectedRoute.toChain]; @@ -149,6 +151,9 @@ const BridgeForm = ({ onChangeAmountInput={onChangeAmountInput} onClickMaxBalance={onClickMaxBalance} validationError={parsedAmountInput ? validationError : undefined} + validationWarning={ + parsedAmountInput ? validationWarning : undefined + } balance={balance} limits={limits} /> diff --git a/src/views/Bridge/hooks/useBridge.ts b/src/views/Bridge/hooks/useBridge.ts index 1df04e653..fe4c4e4ce 100644 --- a/src/views/Bridge/hooks/useBridge.ts +++ b/src/views/Bridge/hooks/useBridge.ts @@ -59,15 +59,16 @@ export function useBridge() { (transferQuoteQuery.isInitialLoading || feesQuery.isInitialLoading) && !transferQuote; - const { error: amountValidationError } = validateBridgeAmount( - parsedAmount, - quotedFees, - maxBalance, - limitsQuery.limits?.maxDeposit, - selectedRoute.type === "swap" && quotedSwap?.minExpectedInputTokenAmount - ? BigNumber.from(quotedSwap?.minExpectedInputTokenAmount) - : parsedAmount - ); + const { error: amountValidationError, warn: amountValidationWarning } = + validateBridgeAmount( + parsedAmount, + quotedFees, + maxBalance, + limitsQuery.limits?.maxDeposit, + selectedRoute.type === "swap" && quotedSwap?.minExpectedInputTokenAmount + ? BigNumber.from(quotedSwap?.minExpectedInputTokenAmount) + : parsedAmount + ); const isAmountValid = !amountValidationError; const { @@ -138,6 +139,7 @@ export function useBridge() { userAmountInput, swapSlippage, amountValidationError, + amountValidationWarning, handleSelectFromChain, handleSelectToChain, handleSelectInputToken, diff --git a/src/views/Bridge/utils.ts b/src/views/Bridge/utils.ts index 28925491a..9ba927ff3 100644 --- a/src/views/Bridge/utils.ts +++ b/src/views/Bridge/utils.ts @@ -145,7 +145,7 @@ export function validateBridgeAmount( .div(fixedPointAdjustment); if (totalFeeInL1.gt(maximalFee)) { return { - error: AmountInputError.PRICE_IMPACT_TOO_HIGH, + warn: AmountInputError.PRICE_IMPACT_TOO_HIGH, }; } } From 10eb0771f52e9a2729bb995f62c548c89a15fc32 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Thu, 28 Nov 2024 13:03:27 +0100 Subject: [PATCH 4/5] fix: revert max fees warning (#1297) --- src/views/Bridge/utils.ts | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/src/views/Bridge/utils.ts b/src/views/Bridge/utils.ts index 9ba927ff3..214f67cd9 100644 --- a/src/views/Bridge/utils.ts +++ b/src/views/Bridge/utils.ts @@ -130,26 +130,6 @@ export function validateBridgeAmount( }; } - if ( - isDefined(quoteFees) && - isDefined(parsedAmountInput) && - !quoteFees.isAmountTooLow - ) { - const bridgeFee = quoteFees.relayerCapitalFee.total.add( - quoteFees.lpFee.total - ); - const gasFee = quoteFees.relayerGasFee.total; - const totalFeeInL1 = bridgeFee.add(gasFee); - const maximalFee = parsedAmountInput - .mul(parseUnits("0.0500", 18)) // Cap fee at 500 basis points of input amount - .div(fixedPointAdjustment); - if (totalFeeInL1.gt(maximalFee)) { - return { - warn: AmountInputError.PRICE_IMPACT_TOO_HIGH, - }; - } - } - if (parsedAmountInput.lt(0) || amountToBridgeAfterSwap.lt(0)) { return { error: AmountInputError.INVALID, @@ -157,6 +137,7 @@ export function validateBridgeAmount( } return { + warn: undefined, error: undefined, }; } From 7c8213cc4fa2b05044dac0cc20c73c7d69137e96 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Fri, 29 Nov 2024 11:47:01 +0100 Subject: [PATCH 5/5] feat: cron job to keep endpoints hot (#1295) --- api/cron-ping-endpoints.ts | 82 ++++++++++++++++++++++++++++++++++++++ vercel.json | 4 ++ 2 files changed, 86 insertions(+) create mode 100644 api/cron-ping-endpoints.ts diff --git a/api/cron-ping-endpoints.ts b/api/cron-ping-endpoints.ts new file mode 100644 index 000000000..7656eddb0 --- /dev/null +++ b/api/cron-ping-endpoints.ts @@ -0,0 +1,82 @@ +import { VercelResponse } from "@vercel/node"; +import { ethers } from "ethers"; +import { utils } from "@across-protocol/sdk"; +import axios from "axios"; + +import { TypedVercelRequest } from "./_types"; +import { HUB_POOL_CHAIN_ID, getLogger, handleErrorCondition } from "./_utils"; +import { UnauthorizedError } from "./_errors"; +import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "./_constants"; + +const endpoints = [ + { + url: "https://preview.across.to/api/swap/allowance", + params: { + amount: ethers.utils.parseUnits("1", 6).toString(), + tradeType: "minOutput", + inputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM], + originChainId: CHAIN_IDs.ARBITRUM, + outputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.OPTIMISM], + destinationChainId: CHAIN_IDs.OPTIMISM, + depositor: "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D", + skipOriginTxEstimation: true, + refundOnOrigin: false, + }, + updateIntervalSec: 10, + }, +]; + +const maxDurationSec = 60; + +const handler = async ( + request: TypedVercelRequest>, + response: VercelResponse +) => { + const logger = getLogger(); + logger.debug({ + at: "CronPingEndpoints", + 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) { + return; + } + + const functionStart = Date.now(); + + const requestPromises = endpoints.map( + async ({ updateIntervalSec, url, params }) => { + while (true) { + const diff = Date.now() - functionStart; + // Stop after `maxDurationSec` seconds + if (diff >= maxDurationSec * 1000) { + break; + } + await axios.get(url, { params }); + await utils.delay(updateIntervalSec); + } + } + ); + await Promise.all(requestPromises); + + logger.debug({ + at: "CronPingEndpoints", + message: "Finished", + }); + response.status(200); + response.send("OK"); + } catch (error: unknown) { + return handleErrorCondition("cron-ping-endpoints", response, logger, error); + } +}; + +export default handler; diff --git a/vercel.json b/vercel.json index 9c73f3f86..09793cdef 100644 --- a/vercel.json +++ b/vercel.json @@ -8,6 +8,10 @@ { "path": "/api/cron-cache-gas-prices", "schedule": "* * * * *" + }, + { + "path": "/api/cron-ping-endpoints", + "schedule": "* * * * *" } ], "functions": {