diff --git a/api/_utils.ts b/api/_utils.ts index 8ab56cdae..2d0d6948b 100644 --- a/api/_utils.ts +++ b/api/_utils.ts @@ -32,6 +32,7 @@ import { TOKEN_SYMBOLS_MAP, maxRelayFeePct, relayerFeeCapitalCostConfig, + BLOCK_TAG_LAG, } from "./_constants"; import { PoolStateResult } from "./_types"; @@ -332,7 +333,6 @@ export const queries: Record< undefined, undefined, undefined, - undefined, REACT_APP_COINGECKO_PRO_API_KEY, getLogger(), getGasMarkup(CHAIN_IDs.MAINNET) @@ -343,7 +343,6 @@ export const queries: Record< undefined, undefined, undefined, - undefined, REACT_APP_COINGECKO_PRO_API_KEY, getLogger(), getGasMarkup(CHAIN_IDs.OPTIMISM) @@ -354,7 +353,6 @@ export const queries: Record< undefined, undefined, undefined, - undefined, REACT_APP_COINGECKO_PRO_API_KEY, getLogger(), getGasMarkup(CHAIN_IDs.POLYGON) @@ -365,7 +363,6 @@ export const queries: Record< undefined, undefined, undefined, - undefined, REACT_APP_COINGECKO_PRO_API_KEY, getLogger(), getGasMarkup(CHAIN_IDs.ARBITRUM) @@ -376,7 +373,6 @@ export const queries: Record< undefined, undefined, undefined, - undefined, REACT_APP_COINGECKO_PRO_API_KEY, getLogger(), getGasMarkup(CHAIN_IDs.ZK_SYNC) @@ -387,7 +383,6 @@ export const queries: Record< undefined, undefined, undefined, - undefined, REACT_APP_COINGECKO_PRO_API_KEY, getLogger(), getGasMarkup(CHAIN_IDs.BASE) @@ -399,7 +394,6 @@ export const queries: Record< undefined, undefined, undefined, - undefined, REACT_APP_COINGECKO_PRO_API_KEY, getLogger(), getGasMarkup(CHAIN_IDs.GOERLI) @@ -410,7 +404,6 @@ export const queries: Record< undefined, undefined, undefined, - undefined, REACT_APP_COINGECKO_PRO_API_KEY, getLogger(), getGasMarkup(CHAIN_IDs.ARBITRUM_GOERLI) @@ -421,7 +414,6 @@ export const queries: Record< undefined, undefined, undefined, - undefined, REACT_APP_COINGECKO_PRO_API_KEY, getLogger(), getGasMarkup(CHAIN_IDs.ZK_SYNC_GOERLI) @@ -432,7 +424,6 @@ export const queries: Record< undefined, undefined, undefined, - undefined, REACT_APP_COINGECKO_PRO_API_KEY, getLogger(), getGasMarkup(CHAIN_IDs.BASE_GOERLI) @@ -487,25 +478,58 @@ export const getTokenSymbol = (tokenAddress: string): string => { * @param amount The amount of funds that are requesting to be transferred * @param originChainId The origin chain that this token will be transferred from * @param destinationChainId The destination chain that this token will be transferred to + * @param recipientAddress The address that will receive the transferred funds * @param tokenPrice An optional overred price to prevent the SDK from creating its own call + * @param message An optional message to include in the transfer + * @param relayerAddress An optional relayer address to use for the transfer * @returns The a promise to the relayer fee for the given `amount` of transferring `l1Token` to `destinationChainId` */ -export const getRelayerFeeDetails = ( +export const getRelayerFeeDetails = async ( l1Token: string, amount: sdk.utils.BigNumberish, originChainId: number, destinationChainId: number, - tokenPrice?: number + recipientAddress: string, + tokenPrice?: number, + message?: string, + relayerAddress?: string ): Promise => { - const tokenSymbol = getTokenSymbol(l1Token); + const tokenAddresses = sdk.utils.getL2TokenAddresses(l1Token); + if (!tokenAddresses) { + throw new InputError( + `Could not resolve token address for token ${l1Token}` + ); + } + const originToken = tokenAddresses[originChainId]; + const destinationToken = tokenAddresses[destinationChainId]; + const relayFeeCalculator = getRelayerFeeCalculator(destinationChainId); - return relayFeeCalculator.relayerFeeDetails( - amount, - tokenSymbol, - tokenPrice, - originChainId.toString(), - destinationChainId.toString() - ); + try { + return await relayFeeCalculator.relayerFeeDetails( + { + amount: sdk.utils.toBN(amount), + depositId: sdk.utils.bnUint32Max.toNumber(), + depositor: recipientAddress, + destinationChainId, + originChainId, + relayerFeePct: sdk.utils.bnZero, + realizedLpFeePct: sdk.utils.bnZero, + recipient: recipientAddress, + message: message ?? sdk.constants.EMPTY_MESSAGE, + quoteTimestamp: sdk.utils.getCurrentTime(), + originToken, + destinationToken, + }, + amount, + relayerAddress, + tokenPrice + ); + } catch (_e: unknown) { + // Resolve and transform the error + const e = _e as Error; + // We want to mask this error as an Input error. + throw new InputError(e?.message); + } }; /** @@ -589,23 +613,52 @@ export const isRouteEnabled = ( }; /** - * Resolves the balance of a given ERC20 token at a provided address + * Resolves the balance of a given ERC20 token at a provided address. If no token is provided, the balance of the + * native currency will be returned. * @param chainId The blockchain Id to query against - * @param token The valid ERC20 token address on the given `chainId` * @param account A valid Web3 wallet address - * @param blockTag A blockTag to specify a historical balance date + * @param token The valid ERC20 token address on the given `chainId`. * @returns A promise that resolves to the BigNumber of the balance */ export const getBalance = ( chainId: string | number, - token: string, account: string, - blockTag: number | "latest" = "latest" + token: string ): Promise => { - return ERC20__factory.connect(token, getProvider(Number(chainId))).balanceOf( + return sdk.utils.getTokenBalance( account, - { blockTag } + token, + getProvider(Number(chainId)), + BLOCK_TAG_LAG + ); +}; + +/** + * Resolves the cached balance of a given ERC20 token at a provided address. If no token is provided, the balance of the + * native currency will be returned. + * @param chainId The blockchain Id to query against + * @param account A valid Web3 wallet address + * @param token The valid ERC20 token address on the given `chainId`. + * @returns A promise that resolves to the BigNumber of the balance + */ +export const getCachedTokenBalance = async ( + chainId: string | number, + account: string, + token: string +): Promise => { + // Make the request to the vercel API. + const response = await axios.get<{ balance: string }>( + `${resolveVercelEndpoint()}/api/account-balance`, + { + params: { + chainId, + account, + token, + }, + } ); + // Return the balance + return BigNumber.from(response.data.balance); }; /** diff --git a/api/account-balance.ts b/api/account-balance.ts new file mode 100644 index 000000000..56be95685 --- /dev/null +++ b/api/account-balance.ts @@ -0,0 +1,60 @@ +import { VercelResponse } from "@vercel/node"; +import { assert, Infer, type, string } from "superstruct"; +import { TypedVercelRequest } from "./_types"; +import { + getBalance, + getLogger, + handleErrorCondition, + validAddress, +} from "./_utils"; + +const AccountBalanceQueryParamsSchema = type({ + token: validAddress(), + account: validAddress(), + chainId: string(), +}); + +type AccountBalanceQueryParams = Infer; + +const handler = async ( + { query }: TypedVercelRequest, + response: VercelResponse +) => { + const logger = getLogger(); + logger.debug({ + at: "AccountBalance", + message: "Query data", + query, + }); + try { + // Validate the query parameters + assert(query, AccountBalanceQueryParamsSchema); + // Deconstruct the query parameters + let { token, account, chainId } = query; + // Rely on the utils to query the balance of the account for the token + const balance = await getBalance(chainId, account, token); + // Package the response + const result = { + balance: balance.toString(), + account: account, + token: token, + }; + // Log the response + logger.debug({ + at: "AccountBalance", + message: "Response data", + responseJson: result, + }); + // Set the caching headers that will be used by the CDN. + response.setHeader( + "Cache-Control", + "s-maxage=150, stale-while-revalidate=150" + ); + // Return the response + response.status(200).json(result); + } catch (error: unknown) { + return handleErrorCondition("account-balance", response, logger, error); + } +}; + +export default handler; diff --git a/api/limits.ts b/api/limits.ts index a052fc231..57959453b 100644 --- a/api/limits.ts +++ b/api/limits.ts @@ -18,7 +18,7 @@ import { getRelayerFeeDetails, getCachedTokenPrice, getTokenDetails, - getBalance, + getCachedTokenBalance, maxBN, minBN, isRouteEnabled, @@ -30,6 +30,7 @@ import { HUB_POOL_CHAIN_ID, ENABLED_ROUTES, } from "./_utils"; +import { constants } from "@across-protocol/sdk-v2"; const LimitsQueryParamsSchema = object({ token: validAddress(), @@ -149,34 +150,25 @@ const handler = async ( ethers.BigNumber.from("10").pow(18), computedOriginChainId, Number(destinationChainId), + constants.ZERO_ADDRESS, tokenPriceNative ), hubPool.callStatic.multicall(multicallInput, { blockTag: BLOCK_TAG_LAG }), Promise.all( fullRelayers.map((relayer) => - getBalance( - destinationChainId!, - destinationToken, - relayer, - BLOCK_TAG_LAG - ) + getCachedTokenBalance(destinationChainId!, relayer, destinationToken) ) ), Promise.all( transferRestrictedRelayers.map((relayer) => - getBalance( - destinationChainId!, - destinationToken, - relayer, - BLOCK_TAG_LAG - ) + getCachedTokenBalance(destinationChainId!, relayer, destinationToken) ) ), Promise.all( fullRelayers.map((relayer) => destinationChainId === "1" ? ethers.BigNumber.from("0") - : getBalance("1", l1Token, relayer, BLOCK_TAG_LAG) + : getCachedTokenBalance("1", relayer, l1Token) ) ), ]); diff --git a/api/suggested-fees.ts b/api/suggested-fees.ts index 3f1251c7c..6c7d7b13c 100644 --- a/api/suggested-fees.ts +++ b/api/suggested-fees.ts @@ -5,7 +5,7 @@ import * as sdk from "@across-protocol/sdk-v2"; import { BlockFinder } from "@uma/sdk"; import { VercelResponse } from "@vercel/node"; import { ethers } from "ethers"; -import { type, assert, Infer, optional } from "superstruct"; +import { type, assert, Infer, optional, string } from "superstruct"; import { disabledL1Tokens, DEFAULT_QUOTE_TIMESTAMP_BUFFER } from "./_constants"; import { TypedVercelRequest } from "./_types"; import { @@ -24,6 +24,7 @@ import { HUB_POOL_CHAIN_ID, ENABLED_ROUTES, getSpokePoolAddress, + getCachedTokenBalance, } from "./_utils"; const SuggestedFeesQueryParamsSchema = type({ @@ -33,6 +34,9 @@ const SuggestedFeesQueryParamsSchema = type({ originChainId: optional(positiveIntStr()), timestamp: optional(positiveIntStr()), skipAmountLimit: optional(boolStr()), + message: optional(string()), + recipientAddress: optional(validAddress()), + relayerAddress: optional(validAddress()), }); type SuggestedFeesQueryParams = Infer; @@ -64,12 +68,17 @@ const handler = async ( originChainId, timestamp, skipAmountLimit, + recipientAddress, + relayerAddress, + message, } = query; if (originChainId === destinationChainId) { throw new InputError("Origin and destination chains cannot be the same"); } + relayerAddress ??= sdk.constants.DEFAULT_SIMULATED_RELAYER_ADDRESS; + recipientAddress ??= sdk.constants.DEFAULT_SIMULATED_RELAYER_ADDRESS; token = ethers.utils.getAddress(token); const [latestBlock, tokenDetails] = await Promise.all([ @@ -78,6 +87,49 @@ const handler = async ( ]); const { l1Token, hubPool, chainId: computedOriginChainId } = tokenDetails; + if (sdk.utils.isDefined(message) && !sdk.utils.isMessageEmpty(message)) { + if (!ethers.utils.isHexString(message)) { + throw new InputError("Message must be a hex string"); + } + if (message.length % 2 !== 0) { + // Our message encoding is a hex string, so we need to check that the length is even. + throw new InputError("Message must be an even hex string"); + } + const isRecipientAContract = await sdk.utils.isContractDeployedToAddress( + recipientAddress, + provider + ); + if (!isRecipientAContract) { + throw new InputError( + "Recipient must be a contract when a message is provided" + ); + } else { + // If we're in this case, it's likely that we're going to have to simulate the execution of + // a complex message handling from the specified relayer to the specified recipient by calling + // the arbitrary function call `handleAcrossMessage` at the recipient. So that we can discern + // the difference between an OUT_OF_FUNDS error in either the transfer or through the execution + // of the `handleAcrossMessage` we will check that the balance of the relayer is sufficient to + // support this deposit. + const destinationToken = + sdk.utils.getL2TokenAddresses(l1Token)?.[Number(destinationChainId)]; + if (!sdk.utils.isDefined(destinationToken)) { + throw new InputError( + `Could not resolve token address on ${destinationChainId} for ${l1Token}` + ); + } + const balanceOfToken = await getCachedTokenBalance( + destinationChainId, + relayerAddress, + destinationToken + ); + if (balanceOfToken.lt(amountInput)) { + throw new InputError( + `Relayer Address (${relayerAddress}) doesn't have enough funds to support this deposit. For help, please reach out to https://discord.across.to` + ); + } + } + } + // Note: Add a buffer to "latest" timestamp so that it corresponds to a block older than HEAD. // This is to improve relayer UX who have heightened risk of sending inadvertent invalid fills // for quote times right at HEAD (or worst, in the future of HEAD). If timestamp is supplied as @@ -151,7 +203,10 @@ const handler = async ( amount, computedOriginChainId, Number(destinationChainId), - tokenPrice + recipientAddress, + tokenPrice, + message, + relayerAddress ); const skipAmountLimitEnabled = skipAmountLimit === "true"; diff --git a/package.json b/package.json index 4c40d7f48..04e4557a1 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "AGPL-3.0-only", "dependencies": { "@across-protocol/constants-v2": "^1.0.4", - "@across-protocol/sdk-v2": "^0.17.3", + "@across-protocol/sdk-v2": "^0.17.5", "@amplitude/analytics-browser": "^1.6.6", "@amplitude/marketing-analytics-browser": "^0.3.6", "@balancer-labs/sdk": "^1.1.3", diff --git a/yarn.lock b/yarn.lock index 2ab967192..efa79fb05 100644 --- a/yarn.lock +++ b/yarn.lock @@ -47,12 +47,13 @@ "@openzeppelin/contracts" "4.1.0" "@uma/core" "^2.18.0" -"@across-protocol/sdk-v2@^0.17.3": - version "0.17.3" - resolved "https://registry.yarnpkg.com/@across-protocol/sdk-v2/-/sdk-v2-0.17.3.tgz#182b3a0ed20291de367bd5b757618a0d1b3ced52" - integrity sha512-xn74oeBTy/5cGzrZL//7KSYuKf0kviduspLlbQudFqGQDlMUOV0+23sHY/jBLh4DmKLJhFL7jPwk+xbkn1WPFw== +"@across-protocol/sdk-v2@^0.17.5": + version "0.17.5" + resolved "https://registry.yarnpkg.com/@across-protocol/sdk-v2/-/sdk-v2-0.17.5.tgz#2163afb65092afa2f5d37eca511b1e4b89c0e2e6" + integrity sha512-C12ih8ZpX4Q+WLkPOCYqkR9K4GPEk9a8TGtPQdtdGszPS6/1t9mGGeU/dDJu52eMKRC8A7VmBNxwWndDcTdw/Q== dependencies: "@across-protocol/across-token" "^1.0.0" + "@across-protocol/constants-v2" "^1.0.4" "@across-protocol/contracts-v2" "^2.4.4" "@eth-optimism/sdk" "^2.1.0" "@pinata/sdk" "^2.1.0"