diff --git a/api/_cache.ts b/api/_cache.ts index fe431847b..f7d980914 100644 --- a/api/_cache.ts +++ b/api/_cache.ts @@ -73,6 +73,21 @@ export class RedisCache implements interfaces.CachingMechanismInterface { } await this.client.del(key); } + + async getAll(prefix: string): Promise { + if (!this.client) { + return null; + } + + const [_, values] = await this.client.scan(0, { + match: prefix, + }); + if (values.length === 0 || values == null) { + return null; + } + + return (await this.client.mget(values)) as T[]; + } } export const redisCache = new RedisCache(); diff --git a/api/relayer-config-list.ts b/api/relayer-config-list.ts new file mode 100644 index 000000000..c80c8e304 --- /dev/null +++ b/api/relayer-config-list.ts @@ -0,0 +1,32 @@ +import { VercelResponse } from "@vercel/node"; +import { getLogger, handleErrorCondition } from "./_utils"; +import { TypedVercelRequest } from "./_types"; +import { RelayerConfigCacheEntry } from "./relayer/_types"; +import { redisCache } from "./_cache"; + +const handler = async ( + request: TypedVercelRequest>, + response: VercelResponse +) => { + const logger = getLogger(); + logger.debug({ + at: "RelayerConfig", + message: "Body data", + body: request.body, + }); + try { + const relayerConfigs = + await redisCache.getAll("relayer-config*"); + + logger.debug({ + at: "RelayerConfigList", + message: "Response data", + responseJson: relayerConfigs, + }); + response.status(200).json(relayerConfigs); + } catch (error: unknown) { + return handleErrorCondition("relayer-config", response, logger, error); + } +}; + +export default handler; diff --git a/api/relayer-config.ts b/api/relayer-config.ts new file mode 100644 index 000000000..a832c383b --- /dev/null +++ b/api/relayer-config.ts @@ -0,0 +1,55 @@ +import { VercelResponse } from "@vercel/node"; +import { assert } from "superstruct"; +import { getLogger, handleErrorCondition } from "./_utils"; +import { TypedVercelRequest } from "./_types"; +import { RelayerConfig, RelayerConfigSchema } from "./relayer/_types"; +import { buildCacheKey, redisCache } from "./_cache"; + +const handler = async ( + request: TypedVercelRequest, + response: VercelResponse +) => { + const logger = getLogger(); + logger.debug({ + at: "RelayerConfig", + message: "Body data", + body: request.body, + }); + try { + const { body, method } = request; + + if (method !== "POST") { + return handleErrorCondition( + "relayer-config", + response, + logger, + new Error("Method not allowed") + ); + } + + assert(body, RelayerConfigSchema); + + // TODO: validate authentication + + const relayerConfig: any = body; + relayerConfig.updatedAt = new Date().getTime(); + + const cacheKey = buildCacheKey( + "relayer-config", + body.authentication.address + ); + await redisCache.set(cacheKey, relayerConfig, 60 * 60 * 24 * 2); + const storedConfig = await redisCache.get(cacheKey); + + logger.debug({ + at: "RelayerConfig", + message: "Response data", + responseJson: storedConfig, + }); + response.status(201).json(storedConfig); + } catch (error: unknown) { + return handleErrorCondition("relayer-config", response, logger, error); + } +}; + +export default handler; diff --git a/api/relayer/_types.ts b/api/relayer/_types.ts new file mode 100644 index 000000000..b66c06148 --- /dev/null +++ b/api/relayer/_types.ts @@ -0,0 +1,77 @@ +import { + Infer, + assign, + number, + object, + type, + record, + string, + boolean, + optional, + unknown, + array, +} from "superstruct"; +import { validAddress, positiveIntStr } from "../_utils"; + +export const OrderbookQueryParamsSchema = type({ + originChainId: positiveIntStr(), + destinationChainId: positiveIntStr(), + originToken: validAddress(), + destinationToken: validAddress(), +}); + +export const OrderbookResponseSchema = record( + validAddress(), + array( + object({ + amount: number(), + spread: number(), + }) + ) +); + +export const RelayerConfigSchema = type({ + prices: record( + string(), + object({ + origin: record( + string(), + record(validAddress(), record(positiveIntStr(), number())) + ), + destination: record( + string(), + record(validAddress(), record(positiveIntStr(), number())) + ), + messageExecution: boolean(), + }) + ), + minExclusivityPeriods: object({ + default: number(), + routes: optional(record(string(), record(string(), number()))), + origin: optional(record(string(), record(string(), number()))), + destination: optional(record(string(), record(string(), number()))), + sizes: optional(record(string(), number())), + }), + authentication: object({ + address: validAddress(), + method: optional(string()), + payload: optional(record(string(), unknown())), + }), +}); + +export const RelayerConfigCacheEntrySchema = assign( + RelayerConfigSchema, + object({ + updatedAt: number(), + }) +); + +export type OrderbookQueryParams = Infer; + +export type OrderbookResponse = Infer; + +export type RelayerConfig = Infer; + +export type RelayerConfigCacheEntry = Infer< + typeof RelayerConfigCacheEntrySchema +>; diff --git a/api/relayer/_utils.ts b/api/relayer/_utils.ts new file mode 100644 index 000000000..efe6c4f69 --- /dev/null +++ b/api/relayer/_utils.ts @@ -0,0 +1,8 @@ +export function getBaseCurrency(token: string): string | null { + if (token === "USDC" || token === "USDT") { + return "usd"; + } else if (token === "WETH") { + return "eth"; + } + return null; +} diff --git a/api/relayer/orderbook.ts b/api/relayer/orderbook.ts new file mode 100644 index 000000000..3f9e95f8e --- /dev/null +++ b/api/relayer/orderbook.ts @@ -0,0 +1,148 @@ +import { getTokenByAddress } from "../_utils"; +import { ApiHandler } from "../_base/api-handler"; +import { VercelAdapter } from "../_adapters/vercel-adapter"; +import { redisCache } from "../_cache"; +import { + OrderbookQueryParams, + OrderbookResponse, + OrderbookQueryParamsSchema, + OrderbookResponseSchema, + RelayerConfig, +} from "./_types"; +import { getBaseCurrency } from "./_utils"; + +class OrderbookHandler extends ApiHandler< + OrderbookQueryParams, + OrderbookResponse +> { + constructor() { + super({ + name: "Orderbook", + requestSchema: OrderbookQueryParamsSchema, + responseSchema: OrderbookResponseSchema, + headers: { + "Cache-Control": "s-maxage=1, stale-while-revalidate=1", + }, + }); + } + + protected async process( + request: OrderbookQueryParams + ): Promise { + const { + originChainId: _originChainId, + destinationChainId: _destinationChainId, + originToken, + destinationToken, + } = request; + + const originChainId = Number(_originChainId); + const destinationChainId = Number(_destinationChainId); + + const relayerConfigs = + await redisCache.getAll("relayer-config*"); + + if (!relayerConfigs) { + throw new Error("No relayer configs found"); + } + + const originTokenInfo = getTokenByAddress(originToken, originChainId); + const destinationTokenInfo = getTokenByAddress( + destinationToken, + destinationChainId + ); + + if (!originTokenInfo || !destinationTokenInfo) { + throw new Error("Token information not found for provided addresses"); + } + + const originBaseCurrency = getBaseCurrency(originTokenInfo.symbol); + const destinationBaseCurrency = getBaseCurrency( + destinationTokenInfo.symbol + ); + + if (!originBaseCurrency || !destinationBaseCurrency) { + throw new Error("Base currency not found for provided tokens"); + } + + const orderbooks: OrderbookResponse = {}; + + for (const relayerConfig of relayerConfigs) { + const originChainPrices = relayerConfig.prices[originChainId.toString()]; + const destinationChainPrices = + relayerConfig.prices[destinationChainId.toString()]; + + if (!originChainPrices || !destinationChainPrices) { + continue; + } + + const originTokenPrices = + originChainPrices.origin?.[originBaseCurrency]?.[ + originTokenInfo.addresses[originChainId] + ]; + const destinationTokenPrices = + destinationChainPrices.destination?.[destinationBaseCurrency]?.[ + destinationTokenInfo.addresses[destinationChainId] + ]; + + if (!originTokenPrices || !destinationTokenPrices) { + continue; + } + + const originAmounts = Object.entries(originTokenPrices).sort( + ([, a], [, b]) => b - a + ); + const destinationAmounts = Object.entries(destinationTokenPrices).sort( + ([, a], [, b]) => a - b + ); + + const orderbook = []; + let destinationIndex = 0; + let remainingDestinationAmount = 0; + + for (const [originAmount, originPrice] of originAmounts) { + let remainingOriginAmount = Number(originAmount); + + while ( + remainingOriginAmount > 0 && + destinationIndex < destinationAmounts.length + ) { + const [destAmount, destPrice] = destinationAmounts[destinationIndex]; + const availableDestAmount = + Number(destAmount) - remainingDestinationAmount; + + if (availableDestAmount > 0) { + const matchAmount = Math.min( + remainingOriginAmount, + availableDestAmount + ); + orderbook.push({ + amount: matchAmount, + spread: destPrice - originPrice, + }); + + remainingOriginAmount -= matchAmount; + remainingDestinationAmount += matchAmount; + + if (remainingDestinationAmount >= Number(destAmount)) { + destinationIndex++; + remainingDestinationAmount = 0; + } + } else { + destinationIndex++; + remainingDestinationAmount = 0; + } + } + } + + orderbook.sort((a, b) => a.spread - b.spread); + orderbooks[relayerConfig.authentication.address] = orderbook; + } + + return orderbooks; + } +} + +const handler = new OrderbookHandler(); +const adapter = new VercelAdapter(); +export default adapter.adaptHandler(handler); diff --git a/src/Routes.tsx b/src/Routes.tsx index 141dd8ecb..e9c612464 100644 --- a/src/Routes.tsx +++ b/src/Routes.tsx @@ -58,6 +58,10 @@ const Staking = lazyWithRetry( () => import(/* webpackChunkName: "RewardStaking" */ "./views/Staking") ); const DepositStatus = lazyWithRetry(() => import("./views/DepositStatus")); +const RelayerConfigs = lazyWithRetry( + () => + import(/* webpackChunkName: "RelayerConfigs" */ "./views/RelayerConfigs") +); const warningMessage = ` We noticed that you have connected from a contract address. @@ -201,6 +205,7 @@ const Routes: React.FC = () => { )), ] )} + } diff --git a/src/components/Text/Text.tsx b/src/components/Text/Text.tsx index 7d279f3b0..640534997 100644 --- a/src/components/Text/Text.tsx +++ b/src/components/Text/Text.tsx @@ -77,9 +77,11 @@ type TextProps = { weight?: number; color?: TextColor; casing?: TextCasing; + monospace?: boolean; }; export const Text = styled.div` + font-family: ${({ monospace }) => (monospace ? "monospace" : "inherit")}; font-style: normal; font-weight: ${({ weight = 400 }) => weight}; color: ${({ color = "white-88" }) => COLORS[color]}; diff --git a/src/utils/serverless-api/mocked/index.ts b/src/utils/serverless-api/mocked/index.ts index e7aec8384..66fb68d5f 100644 --- a/src/utils/serverless-api/mocked/index.ts +++ b/src/utils/serverless-api/mocked/index.ts @@ -11,6 +11,8 @@ import { poolsApiCall } from "./pools.mocked"; import { swapQuoteApiCall } from "./swap-quote"; import { poolsUserApiCall } from "./pools-user.mocked"; import { swapApprovalApiCall } from "../prod/swap-approval"; +import { orderBookApiCall } from "../prod/order-book"; +import { relayerConfigsApiCall } from "../prod/relayer-configs"; export const mockedEndpoints: ServerlessAPIEndpoints = { coingecko: coingeckoMockedApiCall, @@ -29,4 +31,6 @@ export const mockedEndpoints: ServerlessAPIEndpoints = { poolsUser: poolsUserApiCall, swapQuote: swapQuoteApiCall, swapApproval: swapApprovalApiCall, + orderBook: orderBookApiCall, + relayerConfigs: relayerConfigsApiCall, }; diff --git a/src/utils/serverless-api/prod/index.ts b/src/utils/serverless-api/prod/index.ts index c885b478e..4613b37c9 100644 --- a/src/utils/serverless-api/prod/index.ts +++ b/src/utils/serverless-api/prod/index.ts @@ -11,6 +11,9 @@ import { poolsApiCall } from "./pools"; import { swapQuoteApiCall } from "./swap-quote"; import { poolsUserApiCall } from "./pools-user"; import { swapApprovalApiCall } from "./swap-approval"; +import { orderBookApiCall } from "./order-book"; +import { relayerConfigsApiCall } from "./relayer-configs"; + export const prodEndpoints: ServerlessAPIEndpoints = { coingecko: coingeckoApiCall, suggestedFees: suggestedFeesApiCall, @@ -28,4 +31,6 @@ export const prodEndpoints: ServerlessAPIEndpoints = { poolsUser: poolsUserApiCall, swapQuote: swapQuoteApiCall, swapApproval: swapApprovalApiCall, + orderBook: orderBookApiCall, + relayerConfigs: relayerConfigsApiCall, }; diff --git a/src/utils/serverless-api/prod/order-book.ts b/src/utils/serverless-api/prod/order-book.ts new file mode 100644 index 000000000..e36ca5f65 --- /dev/null +++ b/src/utils/serverless-api/prod/order-book.ts @@ -0,0 +1,60 @@ +import axios from "axios"; +import { vercelApiBaseUrl } from "utils"; + +export type OrderBookApiCall = typeof orderBookApiCall; + +export type OrderBookApiResponse = { + [relayer: string]: { + amount: number; + spread: number; + }[]; +}; + +export type OrderBookApiQueryParams = { + originChainId: number; + destinationChainId: number; + inputToken: string; + outputToken: string; +}; + +export async function orderBookApiCall( + params: OrderBookApiQueryParams +): Promise { + const response = await axios.get( + `${vercelApiBaseUrl}/api/relayer/orderbook`, + { + params: { + originChainId: params.originChainId, + destinationChainId: params.destinationChainId, + originToken: params.inputToken, + destinationToken: params.outputToken, + }, + } + ); + console.log(response.data); + return response.data; + // return { + // orderBook: { + // "0x1234567890123456789012345678901234567890": [ + // { + // amount: 100, + // spread: 0.01, + // }, + // { + // amount: 200, + // spread: 0.02, + // }, + // ], + // "0x4567890123456789012345678901234567890123": [ + // { + // amount: 200, + // spread: 0.02, + // }, + // { + // amount: 300, + // spread: 0.03, + // }, + // ], + // }, + // }; +} diff --git a/src/utils/serverless-api/prod/relayer-configs.ts b/src/utils/serverless-api/prod/relayer-configs.ts new file mode 100644 index 000000000..a175dc016 --- /dev/null +++ b/src/utils/serverless-api/prod/relayer-configs.ts @@ -0,0 +1,123 @@ +import axios from "axios"; +import { vercelApiBaseUrl } from "utils"; + +export type RelayerConfigsApiCall = typeof relayerConfigsApiCall; + +export type RelayerConfigsApiResponse = { + authentication: { + address: string; + method: string; + }; + updatedAt: number; + prices: { + [chainId: number]: { + origin: { + [baseCurrency: string]: { + [token: string]: { + [amount: number]: number; + }; + }; + }; + destination: { + [baseCurrency: string]: { + [token: string]: { + [amount: number]: number; + }; + }; + }; + }; + }; + minExclusivityPeriods: { + default: number; + routes?: { + [route: string]: { + [amount: number]: number; + }; + }; + origin?: { + [chainId: number]: { + [amount: number]: number; + }; + }; + destination?: { + [chainId: number]: { + [amount: number]: number; + }; + }; + sizes?: { + [amount: number]: number; + }; + }; +}[]; + +export async function relayerConfigsApiCall(): Promise { + const { data } = await axios.get( + `${vercelApiBaseUrl}/api/relayer-config-list` + ); + return data; + // return createMockData() as RelayerConfigsApiResponse; +} + +function createMockData() { + const relayerAddresses = [ + "0x000000000000000000000000000000000000000A", + "0x000000000000000000000000000000000000000B", + "0x000000000000000000000000000000000000000C", + "0x000000000000000000000000000000000000000D", + "0x000000000000000000000000000000000000000E", + "0x000000000000000000000000000000000000000F", + "0x0000000000000000000000000000000000000010", + "0x0000000000000000000000000000000000000011", + "0x0000000000000000000000000000000000000012", + ]; + const tokens = ["USDC", "USDT", "DAI"]; + const chains = [1, 10, 42161]; + + // random number between a and b, with 2 decimal places + const randomPrice = (a: number, b: number) => + Number((Math.random() * (b - a) + a).toFixed(2)); + + return relayerAddresses.map((relayerAddress) => { + const prices = chains.reduce((acc, chain) => { + const origin = tokens.reduce((acc, token) => { + return { + ...acc, + [token]: { + [100]: randomPrice(0.9, 0.95), + [200]: randomPrice(0.951, 0.99), + }, + }; + }, {}); + const destination = tokens.reduce((acc, token) => { + return { + ...acc, + [token]: { + [100]: randomPrice(1.01, 1.05), + [200]: randomPrice(1.051, 1.09), + }, + }; + }, {}); + return { + ...acc, + [chain]: { + origin: { + usd: origin as any, + }, + destination: { + usd: destination as any, + }, + }, + }; + }, {}); + return { + relayer: relayerAddress, + config: { + updatedAt: Date.now() - Math.floor(Math.random() * 10000), + prices, + minExclusivityPeriods: { + default: 12, + }, + }, + }; + }); +} diff --git a/src/utils/serverless-api/types.ts b/src/utils/serverless-api/types.ts index ec196ea61..98179a0c0 100644 --- a/src/utils/serverless-api/types.ts +++ b/src/utils/serverless-api/types.ts @@ -6,6 +6,8 @@ import { PoolsApiCall } from "./prod/pools"; import { SwapQuoteApiCall } from "./prod/swap-quote"; import { PoolsUserApiCall } from "./prod/pools-user"; import { SwapApprovalApiCall } from "./prod/swap-approval"; +import { OrderBookApiCall } from "./prod/order-book"; +import { RelayerConfigsApiCall } from "./prod/relayer-configs"; export type ServerlessAPIEndpoints = { coingecko: CoingeckoApiCall; @@ -24,6 +26,8 @@ export type ServerlessAPIEndpoints = { poolsUser: PoolsUserApiCall; swapQuote: SwapQuoteApiCall; swapApproval: SwapApprovalApiCall; + orderBook: OrderBookApiCall; + relayerConfigs: RelayerConfigsApiCall; }; export type RewardsApiFunction = diff --git a/src/views/RelayerConfigs/RelayerConfigs.tsx b/src/views/RelayerConfigs/RelayerConfigs.tsx new file mode 100644 index 000000000..41e00327b --- /dev/null +++ b/src/views/RelayerConfigs/RelayerConfigs.tsx @@ -0,0 +1,43 @@ +import styled from "@emotion/styled"; +import { LayoutV2, Text } from "components"; +import { QUERIESV2 } from "utils"; +import ConfigsTable from "./components/ConfigsTable"; +import { useRelayerConfigs } from "./hooks/useRelayerConfigs"; +import OrderBook from "./components/OrderBook"; + +const RelayerConfigs = () => { + const { data: relayerConfigsData } = useRelayerConfigs(); + + if (!relayerConfigsData) { + return null; + } + + return ( + + + + + Relayer Configs + {relayerConfigsData && } + + + ); +}; + +const Wrapper = styled.div` + background-color: transparent; + + width: 100%; + + margin: 48px auto 20px; + display: flex; + flex-direction: column; + gap: 24px; + + @media ${QUERIESV2.sm.andDown} { + margin: 16px auto; + gap: 16px; + } +`; + +export default RelayerConfigs; diff --git a/src/views/RelayerConfigs/components/ConfigsTable.tsx b/src/views/RelayerConfigs/components/ConfigsTable.tsx new file mode 100644 index 000000000..2b5276008 --- /dev/null +++ b/src/views/RelayerConfigs/components/ConfigsTable.tsx @@ -0,0 +1,385 @@ +import React, { useState } from "react"; +import styled from "@emotion/styled"; +import { COLORS, getConfig } from "utils"; +import { Text } from "components/Text"; +import { RelayerConfigsApiResponse } from "utils/serverless-api/prod/relayer-configs"; +import { ReactComponent as ChevronRight } from "assets/icons/chevron-right.svg"; +import Heartbeat from "./Heartbeat"; +import { DateTime } from "luxon"; + +type ConfigsTableProps = { + data: RelayerConfigsApiResponse; +}; + +function ConfigsTable({ data }: ConfigsTableProps) { + const [expandedRows, setExpandedRows] = useState>(new Set()); + + const toggleRow = (index: number) => { + const newExpandedRows = new Set(expandedRows); + if (newExpandedRows.has(index)) { + newExpandedRows.delete(index); + } else { + newExpandedRows.add(index); + } + setExpandedRows(newExpandedRows); + }; + + const getSortedAmounts = ( + amounts: Record, + ascOrDesc: "asc" | "desc" + ) => { + return Object.entries(amounts).sort((a, b) => { + return ascOrDesc === "asc" ? a[1] - b[1] : b[1] - a[1]; + }); + }; + + const renderPriceConfig = (prices: any) => { + return Object.entries(prices).map(([chainId, chainData]: [string, any]) => ( + + + Chain ID:{" "} + + {chainId} + + + + + + + + BUY + + + + {chainData.origin && Object.keys(chainData.origin).length > 0 ? ( + Object.entries(chainData.origin).map( + ([baseCurrency, tokens]: [string, any]) => + Object.entries(tokens).map( + ([token, amounts]: [string, any]) => + getSortedAmounts(amounts, "desc").map( + ([amount, price]: [string, any]) => ( + + + + {amount} + + + {" "} + @ {price} {baseCurrency.toUpperCase()} + + + ) + ) + ) + ) + ) : ( + No buy configs + )} + + + + + + + SELL + + + + {chainData.destination && + Object.keys(chainData.destination).length > 0 ? ( + Object.entries(chainData.destination).map( + ([baseCurrency, tokens]: [string, any]) => + Object.entries(tokens).map( + ([token, amounts]: [string, any]) => + getSortedAmounts(amounts, "asc").map( + ([amount, price]: [string, any]) => ( + + + + {amount} + + + {" "} + @ {price} {baseCurrency.toUpperCase()} + + + ) + ) + ) + ) + ) : ( + No sell configs + )} + + + + + )); + }; + + return ( + + + + + + Relayer + + + Config Summary + + + Heartbeat + + + + + {data.map((item, index) => { + const isExpanded = expandedRows.has(index); + const priceChains = Object.keys(item.prices || {}); + + return ( + + toggleRow(index)}> + + + + + {item.authentication.address} + + + + {priceChains.length > 0 + ? `${priceChains.length} chain(s) configured` + : "No price config"} + + + + + + {DateTime.fromMillis(item.updatedAt).toRelative()} + + + + {isExpanded && ( + + + + +
+ Prices + {item.prices && + Object.keys(item.prices).length > 0 ? ( + renderPriceConfig(item.prices) + ) : ( +

No price configuration available

+ )} +
+ +
+ Min Exclusivity Periods + {Object.keys(item.minExclusivityPeriods || {}) + .length > 0 ? ( +
+                                {JSON.stringify(
+                                  item.minExclusivityPeriods,
+                                  null,
+                                  2
+                                )}
+                              
+ ) : ( +

No exclusivity periods configured

+ )} +
+
+
+
+
+ )} +
+ ); + })} + +
+
+ ); +} + +function Token({ token, chainId }: { token: string; chainId: string }) { + const tokenInfo = getConfig().getTokenInfoByAddressSafe( + Number(chainId), + token + ); + if (!tokenInfo) { + return null; + } + return ( + + {tokenInfo.name} + + {tokenInfo.symbol} + + + ); +} + +const TokenWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + width: 64px; +`; + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + overflow-x: auto; +`; + +const StyledTable = styled.table` + white-space: nowrap; + table-layout: fixed; +`; + +const StyledHead = styled.thead``; + +const StyledHeadRow = styled.tr` + display: flex; + height: 40px; + align-items: center; + padding: 0px 24px; + gap: 16px; + background-color: ${COLORS["black-700"]}; + border-radius: 12px 12px 0px 0px; + border: ${COLORS["grey-600"]} 1px solid; +`; + +const StyledHeadCell = styled.th<{ width: number }>` + display: flex; + width: ${({ width }) => width}px; + gap: 4px; + flex-direction: row; + align-items: center; + padding-right: 4px; +`; + +const StyledRow = styled.tr<{ clickable?: boolean }>` + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; + padding: 0px 24px; + border-width: 0px 1px 1px 1px; + border-style: solid; + border-color: ${COLORS["grey-600"]}; + cursor: ${({ clickable }) => (clickable ? "pointer" : "default")}; + + &:hover { + background-color: ${({ clickable }) => + clickable ? COLORS["grey-500"] : "transparent"}; + } +`; + +const StyledCell = styled.td<{ width?: number }>` + display: flex; + width: ${({ width }) => (width ? `${width}px` : "100%")}; + align-items: center; + gap: 8px; + padding: 16px 0; +`; + +const ChevronIcon = styled.span<{ expanded: boolean }>` + transition: transform 0.2s ease; + transform: ${({ expanded }) => (expanded ? "rotate(90deg)" : "rotate(0deg)")}; + display: inline-block; + margin-right: 8px; +`; + +const ExpandableContent = styled.div` + padding: 16px 24px; + /* background-color: ${COLORS["grey-500"]}; */ + /* border-top: 1px solid ${COLORS["grey-600"]}; */ + width: 100%; +`; + +const ConfigDetails = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const ConfigSection = styled.div` + padding-top: 12px; + border-radius: 6px; + margin-bottom: 24px; +`; + +const OrderbookContainer = styled.div` + display: flex; + gap: 24px; + margin-top: 12px; +`; + +const OrderbookColumn = styled.div` + flex: 1; + min-width: 0; +`; + +const OrderbookHeader = styled.div` + padding: 8px 12px; + background-color: ${COLORS["black-800"]}; + border-radius: 4px 4px 0 0; + text-align: center; + border: 1px solid ${COLORS["grey-600"]}; + border-bottom: none; +`; + +const OrderbookList = styled.div` + border: 1px solid ${COLORS["grey-600"]}; + border-radius: 0 0 4px 4px; + background-color: ${COLORS["black-700"]}; + max-height: 200px; + overflow-y: auto; +`; + +const OrderbookItem = styled.div<{ side: "buy" | "sell" }>` + padding: 6px 12px; + border-bottom: 1px solid ${COLORS["grey-600"]}; + display: flex; + flex-direction: row; + gap: 2px; + align-items: center; + justify-content: space-between; + + &:last-child { + border-bottom: none; + } +`; + +const EmptyState = styled.div` + padding: 20px; + text-align: center; + color: ${COLORS["grey-400"]}; + font-size: 12px; +`; + +const OrderBookHeader = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +`; + +export default ConfigsTable; diff --git a/src/views/RelayerConfigs/components/Heartbeat.tsx b/src/views/RelayerConfigs/components/Heartbeat.tsx new file mode 100644 index 000000000..66d3d49ac --- /dev/null +++ b/src/views/RelayerConfigs/components/Heartbeat.tsx @@ -0,0 +1,59 @@ +import styled from "@emotion/styled"; +import { keyframes } from "@emotion/react"; +import { Heart } from "react-feather"; +import { COLORS } from "utils"; + +const heartbeat = keyframes` + 0% { + transform: scale(1); + } + 14% { + transform: scale(1.3); + } + 28% { + transform: scale(1); + } + 42% { + transform: scale(1.3); + } + 70% { + transform: scale(1); + } +`; + +const HeartContainer = styled.div` + display: inline-flex; + align-items: center; + justify-content: center; + animation: ${heartbeat} 1.5s ease-in-out infinite; + cursor: pointer; + transition: color 0.3s ease; + + &:hover { + color: ${COLORS.red}; + } +`; +const StyledHeart = styled(Heart)` + width: 48px; + height: 48px; + fill: currentColor; + color: ${COLORS.red}; +`; + +interface HeartbeatProps { + size?: number; + color?: string; + className?: string; +} + +export default function Heartbeat({ + size = 20, + color = COLORS.aqua, + className, +}: HeartbeatProps) { + return ( + + + + ); +} diff --git a/src/views/RelayerConfigs/components/OrderBook.tsx b/src/views/RelayerConfigs/components/OrderBook.tsx new file mode 100644 index 000000000..93c9e2d78 --- /dev/null +++ b/src/views/RelayerConfigs/components/OrderBook.tsx @@ -0,0 +1,317 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { keyframes } from "@emotion/react"; +import { COLORS, getConfig } from "utils"; +import { Text } from "components/Text"; +import { useOrderBook } from "../hooks/useOrderBook"; +import { ChainId } from "utils/constants"; + +const availableChains = [ + ChainId.MAINNET, + ChainId.POLYGON, + ChainId.ARBITRUM, + ChainId.BASE, + ChainId.OPTIMISM, +]; +const availableTokens = ["USDC", "USDC.e", "USDT", "DAI"]; + +const config = getConfig(); + +// Add the fade-in animation keyframes +const fadeIn = keyframes` + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +`; + +// Create a styled component for the spread text with fade-in effect +const SpreadText = styled(Text)` + animation: ${fadeIn} 0.5s ease-out; +`; + +type Orderbook = { + [relayerAddress: string]: { + amount: number; + spread: number; + }[]; +}; + +interface OrderBookProps { + orderbook: Orderbook; +} + +function OrderBook({ orderbook }: OrderBookProps) { + function formatPrice(price: number) { + return price.toFixed(2); + } + + return ( + + + {Object.entries(orderbook).length > 0 ? ( + Object.entries(orderbook).map(([relayerAddress, orders], index) => ( + + + + {relayerAddress} + + + {orders.map((order) => ( + + + {order.amount} + + + @ {formatPrice(order.spread)} USD + + + ))} + + + + )) + ) : ( + No orderbook + )} + + + ); +} + +export function OrderBookWithSelectors() { + const [originChainId, setOriginChainId] = React.useState(1); + const [destinationChainId, setDestinationChainId] = + React.useState(10); + const [inputTokenSymbol, setInputTokenSymbol] = + React.useState("USDC"); + const [outputTokenSymbol, setOutputTokenSymbol] = + React.useState("USDC"); + + const inputTokenAddress = config.getTokenInfoBySymbolSafe( + originChainId, + inputTokenSymbol + )?.address; + const outputTokenAddress = config.getTokenInfoBySymbolSafe( + destinationChainId, + outputTokenSymbol + )?.address; + + const { + data: orderBookData, + isLoading, + error, + } = useOrderBook({ + originChainId, + destinationChainId, + inputToken: inputTokenAddress, + outputToken: outputTokenAddress, + }); + + if (isLoading) { + return
Loading...
; + } + + if (error) { + return
Error: {error.message}
; + } + + if (!orderBookData) { + return
No orderbook data
; + } + + return ( + + + + + + + Origin Chain + + + setOriginChainId(Number(e.target.value))} + > + {availableChains.map((chain) => ( + + ))} + + + + + + + Destination Chain + + + setDestinationChainId(Number(e.target.value))} + > + {availableChains.map((chain) => ( + + ))} + + + + + + + + + Input Token + + + setInputTokenSymbol(e.target.value)} + > + {availableTokens.map((token) => ( + + ))} + + + + + + + Output Token + + + setOutputTokenSymbol(e.target.value)} + > + {availableTokens.map((token) => ( + + ))} + + + + + + {orderBookData && } + + ); +} + +const SelectorsWrapper = styled.div` + display: flex; + flex-direction: row; + gap: 16px; +`; + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: 12px; + width: 100%; + gap: 24px; +`; + +const SelectorsContainer = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 24px; +`; + +const SelectorGroup = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + min-width: 150px; +`; + +const SelectorLabel = styled.div` + margin-bottom: 4px; +`; + +const Selector = styled.select` + padding: 8px 12px; + background-color: ${COLORS["black-700"]}; + border: 1px solid ${COLORS["grey-600"]}; + border-radius: 4px; + color: ${COLORS["light-200"]}; + font-size: 14px; + + &:focus { + outline: none; + border-color: ${COLORS["aqua"]}; + } + + option { + background-color: ${COLORS["black-700"]}; + color: ${COLORS["light-200"]}; + } +`; + +const SpreadsContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const OrderBookContainer = styled.div` + display: flex; + flex-direction: column; + gap: 24px; + width: 100%; + max-width: 600px; + margin-bottom: 24px; +`; + +const OrderBookList = styled.div` + border: 1px solid ${COLORS["grey-600"]}; + border-radius: 0 0 4px 4px; + background-color: ${COLORS["black-700"]}; + overflow-y: auto; +`; + +const OrderBookItem = styled.div<{ side: "buy" | "sell" }>` + padding: 8px 12px; + border-bottom: 1px solid ${COLORS["grey-600"]}; + + &:last-child { + border-bottom: none; + } +`; + +const OrderRow = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const PriceAmount = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 8px; +`; + +const EmptyState = styled.div` + padding: 20px; + text-align: center; + color: ${COLORS["grey-400"]}; + font-size: 12px; +`; + +export default OrderBookWithSelectors; diff --git a/src/views/RelayerConfigs/hooks/useOrderBook.ts b/src/views/RelayerConfigs/hooks/useOrderBook.ts new file mode 100644 index 000000000..4e09a0520 --- /dev/null +++ b/src/views/RelayerConfigs/hooks/useOrderBook.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import getApiEndpoint from "utils/serverless-api"; + +export function useOrderBook(params: { + originChainId: number; + destinationChainId: number; + inputToken?: string; + outputToken?: string; +}) { + return useQuery({ + queryKey: ["orderbook", params], + queryFn: () => { + if (!params.inputToken || !params.outputToken) { + throw new Error("Input and output token are required"); + } + return getApiEndpoint().orderBook({ + originChainId: params.originChainId, + destinationChainId: params.destinationChainId, + inputToken: params.inputToken, + outputToken: params.outputToken, + }); + }, + refetchInterval: 1000, + enabled: !!params.inputToken && !!params.outputToken, + }); +} diff --git a/src/views/RelayerConfigs/hooks/useRelayerConfigs.ts b/src/views/RelayerConfigs/hooks/useRelayerConfigs.ts new file mode 100644 index 000000000..89ad217c5 --- /dev/null +++ b/src/views/RelayerConfigs/hooks/useRelayerConfigs.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import getApiEndpoint from "utils/serverless-api"; + +export function useRelayerConfigs() { + return useQuery({ + queryKey: ["relayer-configs"], + queryFn: getApiEndpoint().relayerConfigs, + refetchInterval: 2000, + }); +} diff --git a/src/views/RelayerConfigs/index.ts b/src/views/RelayerConfigs/index.ts new file mode 100644 index 000000000..c6cf74df4 --- /dev/null +++ b/src/views/RelayerConfigs/index.ts @@ -0,0 +1 @@ +export { default } from "./RelayerConfigs"; diff --git a/src/views/index.ts b/src/views/index.ts index 10ab782a8..5cfefbb18 100644 --- a/src/views/index.ts +++ b/src/views/index.ts @@ -3,3 +3,4 @@ export { default as Transactions } from "./Transactions"; export { default as Rewards } from "./Rewards"; export { default as NotFound } from "./NotFound"; export { default as Staking } from "./Staking"; +export { default as RelayerConfigs } from "./RelayerConfigs"; diff --git a/test/api/_relayer/orderbook.test.ts b/test/api/_relayer/orderbook.test.ts new file mode 100644 index 000000000..45c3a6219 --- /dev/null +++ b/test/api/_relayer/orderbook.test.ts @@ -0,0 +1,175 @@ +import orderbookHandler from "../../../api/relayer/orderbook"; +import { TypedVercelRequest } from "../../../api/_types"; +import { redisCache } from "../../../api/_cache"; +import { getTokenByAddress } from "../../../api/_utils"; +import { getBaseCurrency } from "../../../api/relayer/_utils"; + +jest.mock("../../../api/_cache", () => ({ + redisCache: { + getAll: jest.fn(), + }, +})); + +jest.mock("../../../api/_utils", () => ({ + ...jest.requireActual("../../../api/_utils"), + getTokenByAddress: jest.fn(), +})); + +jest.mock("../../../api/relayer/_utils", () => ({ + getBaseCurrency: jest.fn(), +})); + +const getMockedResponse = () => { + const response: any = {}; + response.status = jest.fn().mockReturnValue(response); + response.send = jest.fn(); + response.setHeader = jest.fn(); + response.json = jest.fn(); + return response; +}; + +const createMockRequest = (query: any) => { + return { + query, + } as unknown as TypedVercelRequest; +}; + +describe("Orderbook Handler", () => { + let response: any; + let mockRedisCache: jest.Mocked; + let mockGetTokenByAddress: jest.MockedFunction; + + beforeEach(() => { + response = getMockedResponse(); + mockRedisCache = redisCache as jest.Mocked; + mockGetTokenByAddress = getTokenByAddress as jest.MockedFunction< + typeof getTokenByAddress + >; + + jest.clearAllMocks(); + }); + + describe("Successful orderbook generation", () => { + beforeEach(() => { + mockGetTokenByAddress + .mockReturnValueOnce({ + symbol: "USDC", + decimals: 6, + name: "USD Coin", + addresses: { 1: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }, + coingeckoId: "usd-coin", + }) + .mockReturnValueOnce({ + symbol: "USDT", + decimals: 6, + name: "Tether USD", + addresses: { 10: "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58" }, + coingeckoId: "tether", + }); + + ( + getBaseCurrency as jest.MockedFunction + ).mockReturnValue("usd"); + + mockRedisCache.getAll.mockResolvedValue([ + { + prices: { + "1": { + origin: { + usd: { + USDC: { + "100": 0.99, + "50": 0.98, + }, + }, + }, + destination: { + usd: { + USDT: { + "150": 1.02, + }, + }, + }, + messageExecution: true, + }, + "10": { + origin: { + usd: { + USDC: { + "100": 0.99, + "50": 0.98, + }, + }, + }, + destination: { + usd: { + USDT: { + "150": 1.02, + }, + }, + }, + messageExecution: true, + }, + }, + minExclusivityPeriods: { + default: 10, + }, + authentication: { + address: "0x000000000000000000000000000000000000000A", + method: "signature", + payload: {}, + }, + }, + { + prices: { + "1": { + origin: { + usd: { + USDC: { + "100": 0.97, + "50": 0.96, + }, + }, + }, + destination: { + usd: { + USDT: { + "150": 1.01, + }, + }, + }, + messageExecution: true, + }, + }, + minExclusivityPeriods: { + default: 12, + }, + authentication: { + address: "0x000000000000000000000000000000000000000B", + method: "signature", + payload: {}, + }, + }, + ]); + }); + + test("should generate orderbook with spreads for matching relayers", async () => { + const request = createMockRequest({ + originChainId: 1, + destinationChainId: 10, + originToken: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + destinationToken: "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", + }); + + await orderbookHandler(request, response); + + expect(response.status).toHaveBeenCalledWith(200); + expect(response.json).toHaveBeenCalledWith({ + "0x000000000000000000000000000000000000000A": [ + { amount: 100, spread: 0.030000000000000027 }, + { amount: 50, spread: 0.040000000000000036 }, + ], + }); + }); + }); +});