From 5f819fcc573a5527ba8e406bfb3acbe8946ed9fd Mon Sep 17 00:00:00 2001 From: james-a-morris Date: Wed, 22 Jan 2025 10:48:57 -0500 Subject: [PATCH] feat(hyperliquid): estimate fees properly Signed-off-by: james-a-morris --- src/hooks/useBridgeFees.ts | 26 ++++-- src/utils/bridge.ts | 85 +++++++++++++++++- src/utils/hyperliquid.ts | 78 ++++++++++++++++- src/utils/query-keys.ts | 8 +- .../mocked/suggested-fees.mocked.ts | 3 +- .../prod/suggested-fees.prod.ts | 4 +- src/utils/serverless-api/types.ts | 3 +- src/views/Bridge/hooks/useBridgeAction.ts | 87 ++----------------- src/views/Bridge/hooks/useTransferQuote.ts | 1 + 9 files changed, 199 insertions(+), 96 deletions(-) diff --git a/src/hooks/useBridgeFees.ts b/src/hooks/useBridgeFees.ts index d80615bc4..f3188f046 100644 --- a/src/hooks/useBridgeFees.ts +++ b/src/hooks/useBridgeFees.ts @@ -1,6 +1,11 @@ import { useQuery } from "@tanstack/react-query"; import { BigNumber, ethers } from "ethers"; -import { bridgeFeesQueryKey, getBridgeFees, ChainId } from "utils"; +import { + bridgeFeesQueryKey, + getBridgeFees, + ChainId, + getBridgeFeesWithExternalProjectId, +} from "utils"; import { AxiosError } from "axios"; /** @@ -10,6 +15,8 @@ import { AxiosError } from "axios"; * @param toChainId The chain Id of the receiving chain, its timestamp will be used to calculate the fees. * @param inputTokenSymbol - The input token symbol to check bridge fees for. * @param outputTokenSymbol - The output token symbol to check bridge fees for. + * @param externalProjectId - The external project id to check bridge fees for. + * @param recipientAddress - The recipient address to check bridge fees for. * @returns The bridge fees for the given amount and token symbol and the UseQueryResult object. */ export function useBridgeFees( @@ -18,6 +25,7 @@ export function useBridgeFees( toChainId: ChainId, inputTokenSymbol: string, outputTokenSymbol: string, + externalProjectId?: string, recipientAddress?: string ) { const queryKey = bridgeFeesQueryKey( @@ -25,7 +33,9 @@ export function useBridgeFees( inputTokenSymbol, outputTokenSymbol, fromChainId, - toChainId + toChainId, + externalProjectId, + recipientAddress ); const { data: fees, ...delegated } = useQuery({ queryKey, @@ -37,16 +47,22 @@ export function useBridgeFees( amountToQuery, fromChainIdToQuery, toChainIdToQuery, + externalProjectIdToQuery, + recipientAddressToQuery, ] = queryKey; - return getBridgeFees({ + const feeArgs = { amount: BigNumber.from(amountToQuery), inputTokenSymbol: inputTokenSymbolToQuery, outputTokenSymbol: outputTokenSymbolToQuery, toChainId: toChainIdToQuery, fromChainId: fromChainIdToQuery, - recipientAddress, - }); + recipientAddress: recipientAddressToQuery, + }; + + return externalProjectIdToQuery + ? getBridgeFeesWithExternalProjectId(externalProjectIdToQuery, feeArgs) + : getBridgeFees(feeArgs); }, enabled: Boolean(amount.gt(0)), refetchInterval: 5000, diff --git a/src/utils/bridge.ts b/src/utils/bridge.ts index d60e15127..1be8f5250 100644 --- a/src/utils/bridge.ts +++ b/src/utils/bridge.ts @@ -1,17 +1,25 @@ -import { ethers, BigNumber } from "ethers"; +import { ethers, BigNumber, utils } from "ethers"; import { + acrossPlusMulticallHandler, ChainId, fixedPointAdjustment, + getToken, + hyperLiquidBridge2Address, referrerDelimiterHex, } from "./constants"; import { DOMAIN_CALLDATA_DELIMITER, tagAddress, tagHex } from "./format"; import { getProvider } from "./providers"; -import { getConfig, isContractDeployedToAddress } from "utils"; +import { + generateHyperLiquidPayload, + getConfig, + isContractDeployedToAddress, +} from "utils"; import getApiEndpoint from "./serverless-api"; import { BridgeLimitInterface } from "./serverless-api/types"; import { DepositNetworkMismatchProperties } from "ampli"; import { SwapQuoteApiResponse } from "./serverless-api/prod/swap-quote"; import { SpokePool, SpokePoolVerifier } from "./typechain"; +import { CHAIN_IDs } from "@across-protocol/constants"; export type Fee = { total: ethers.BigNumber; @@ -41,12 +49,81 @@ type GetBridgeFeesArgs = { fromChainId: ChainId; toChainId: ChainId; recipientAddress?: string; + message?: string; }; export type GetBridgeFeesResult = BridgeFees & { isAmountTooLow: boolean; }; +/** + * + */ +export async function getBridgeFeesWithExternalProjectId( + externalProjectId: string, + args: GetBridgeFeesArgs +) { + let message = undefined; + let recipientAddress = args.recipientAddress; + + if (externalProjectId === "hyper-liquid") { + const arbitrumProvider = getProvider(CHAIN_IDs.ARBITRUM); + + const wallet = ethers.Wallet.createRandom(); + + const signer = new ethers.Wallet(wallet.privateKey, arbitrumProvider); + + const recipient = await signer.getAddress(); + + // Build the payload + const hyperLiquidPayload = await generateHyperLiquidPayload( + signer, + recipient, + args.amount + ); + // Create a txn calldata for transfering amount to recipient + const erc20Interface = new utils.Interface([ + "function transfer(address to, uint256 amount) returns (bool)", + ]); + + const transferCalldata = erc20Interface.encodeFunctionData("transfer", [ + recipient, + args.amount, + ]); + + // Encode Instructions struct directly + message = utils.defaultAbiCoder.encode( + [ + "tuple(tuple(address target, bytes callData, uint256 value)[] calls, address fallbackRecipient)", + ], + [ + { + calls: [ + { + target: getToken("USDC").addresses![CHAIN_IDs.ARBITRUM], + callData: transferCalldata, + value: 0, + }, + { + target: hyperLiquidBridge2Address, + callData: hyperLiquidPayload, + value: 0, + }, + ], + fallbackRecipient: args.recipientAddress!, + }, + ] + ); + recipientAddress = acrossPlusMulticallHandler[args.toChainId]; + } + + return getBridgeFees({ + ...args, + recipientAddress, + message, + }); +} + /** * * @param amount - amount to bridge @@ -63,6 +140,7 @@ export async function getBridgeFees({ fromChainId, toChainId, recipientAddress, + message, }: GetBridgeFeesArgs): Promise { const timeBeforeRequests = Date.now(); const { @@ -84,7 +162,8 @@ export async function getBridgeFees({ getConfig().getTokenInfoBySymbol(toChainId, outputTokenSymbol).address, toChainId, fromChainId, - recipientAddress + recipientAddress, + message ); const timeAfterRequests = Date.now(); diff --git a/src/utils/hyperliquid.ts b/src/utils/hyperliquid.ts index 2e75d1e25..9f3cedf61 100644 --- a/src/utils/hyperliquid.ts +++ b/src/utils/hyperliquid.ts @@ -1,8 +1,8 @@ import { Deposit } from "hooks/useDeposits"; import { CHAIN_IDs } from "@across-protocol/constants"; -import { utils } from "ethers"; +import { BigNumber, Contract, providers, Signer, utils } from "ethers"; import { compareAddressesSimple } from "./sdk"; -import { hyperLiquidBridge2Address } from "./constants"; +import { getToken, hyperLiquidBridge2Address } from "./constants"; export function isHyperLiquidBoundDeposit(deposit: Deposit) { if (deposit.destinationChainId !== CHAIN_IDs.ARBITRUM || !deposit.message) { @@ -32,3 +32,77 @@ export function isHyperLiquidBoundDeposit(deposit: Deposit) { return false; } } + +/** + * Creates a payload that will be ingested by Bridge2/batchedDepositWithPermit of a single deposit + */ +export async function generateHyperLiquidPayload( + signer: Signer, + recipient: string, + amount: BigNumber +) { + const source = await signer.getAddress(); + + if (!compareAddressesSimple(source, recipient)) { + throw new Error("Source and recipient must be the same"); + } + + const timestamp = Date.now(); + const deadline = Math.floor(timestamp / 1000) + 3600; + + // Create USDC contract interface + const usdcInterface = new utils.Interface([ + "function nonces(address owner) view returns (uint256)", + "function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)", + ]); + + const usdcContract = new Contract( + getToken("USDC").addresses![CHAIN_IDs.ARBITRUM], + usdcInterface, + signer + ); + + // USDC permit signature with verified domain parameters + const usdcDomain = { + name: "USD Coin", + version: "2", + chainId: CHAIN_IDs.ARBITRUM, + verifyingContract: getToken("USDC").addresses![CHAIN_IDs.ARBITRUM]!, + }; + + const permitTypes = { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }; + + const permitValue = { + owner: source, + spender: hyperLiquidBridge2Address, + value: amount, + nonce: await usdcContract.nonces(source), + deadline, + }; + + const permitSignature = await ( + signer as providers.JsonRpcSigner + )._signTypedData(usdcDomain, permitTypes, permitValue); + const { r, s, v } = utils.splitSignature(permitSignature); + + const deposit = { + user: source, + usd: amount, + deadline, + signature: { r: BigNumber.from(r), s: BigNumber.from(s), v }, + }; + + const iface = new utils.Interface([ + "function batchedDepositWithPermit(tuple(address user, uint64 usd, uint64 deadline, tuple(uint256 r, uint256 s, uint8 v) signature)[] deposits)", + ]); + + return iface.encodeFunctionData("batchedDepositWithPermit", [[deposit]]); +} diff --git a/src/utils/query-keys.ts b/src/utils/query-keys.ts index d9a873b4e..af1655493 100644 --- a/src/utils/query-keys.ts +++ b/src/utils/query-keys.ts @@ -31,6 +31,8 @@ export function balanceQueryKey( * @param amount The amount to check bridge fees for. * @param fromChainId The origin chain of this bridge action * @param toChainId The destination chain of this bridge action + * @param externalProjectId The external project id to check bridge fees for. + * @param recipientAddress The recipient address to check bridge fees for. * @returns An array of query keys for @tanstack/react-query `useQuery` hook. */ export function bridgeFeesQueryKey( @@ -38,7 +40,9 @@ export function bridgeFeesQueryKey( inputToken: string, outputToken: string, fromChainId: ChainId, - toChainId: ChainId + toChainId: ChainId, + externalProjectId?: string, + recipientAddress?: string ) { return [ "bridgeFees", @@ -47,6 +51,8 @@ export function bridgeFeesQueryKey( amount.toString(), fromChainId, toChainId, + externalProjectId, + recipientAddress, ] as const; } diff --git a/src/utils/serverless-api/mocked/suggested-fees.mocked.ts b/src/utils/serverless-api/mocked/suggested-fees.mocked.ts index b02d76bfe..36b5bd238 100644 --- a/src/utils/serverless-api/mocked/suggested-fees.mocked.ts +++ b/src/utils/serverless-api/mocked/suggested-fees.mocked.ts @@ -17,7 +17,8 @@ export async function suggestedFeesMockedApiCall( _outputToken: string, _toChainid: ChainId, _fromChainid: ChainId, - _recipientAddress?: string + _recipientAddress?: string, + _message?: string ): Promise { const token = getTokenByAddress(_inputToken); const decimals = token?.decimals ?? 18; diff --git a/src/utils/serverless-api/prod/suggested-fees.prod.ts b/src/utils/serverless-api/prod/suggested-fees.prod.ts index 3e5ea2bf5..6113d8b56 100644 --- a/src/utils/serverless-api/prod/suggested-fees.prod.ts +++ b/src/utils/serverless-api/prod/suggested-fees.prod.ts @@ -17,7 +17,8 @@ export async function suggestedFeesApiCall( outputToken: string, toChainid: ChainId, fromChainid: ChainId, - recipientAddress?: string + recipientAddress?: string, + message?: string ): Promise { const response = await axios.get(`${vercelApiBaseUrl}/api/suggested-fees`, { params: { @@ -29,6 +30,7 @@ export async function suggestedFeesApiCall( amount: amount.toString(), skipAmountLimit: true, depositMethod: "depositExclusive", + message, }, }); const result = response.data; diff --git a/src/utils/serverless-api/types.ts b/src/utils/serverless-api/types.ts index c85d4b2d5..ae3e398b3 100644 --- a/src/utils/serverless-api/types.ts +++ b/src/utils/serverless-api/types.ts @@ -67,7 +67,8 @@ export type SuggestedApiFeeType = ( outputToken: string, toChainid: ChainId, fromChainid: ChainId, - recipientAddress?: string + recipientAddress?: string, + message?: string ) => Promise; export type RetrieveLinkedWalletType = ( diff --git a/src/views/Bridge/hooks/useBridgeAction.ts b/src/views/Bridge/hooks/useBridgeAction.ts index b654281e5..3cbdfd855 100644 --- a/src/views/Bridge/hooks/useBridgeAction.ts +++ b/src/views/Bridge/hooks/useBridgeAction.ts @@ -3,7 +3,7 @@ import { TransferQuoteReceivedProperties, ampli, } from "ampli"; -import { BigNumber, constants, providers, Signer, utils } from "ethers"; +import { BigNumber, constants, providers, utils } from "ethers"; import { useConnection, useApprove, @@ -23,11 +23,11 @@ import { sendSpokePoolVerifierDepositTx, sendDepositV3Tx, sendSwapAndBridgeTx, - compareAddressesSimple, getToken, acrossPlusMulticallHandler, hyperLiquidBridge2Address, externalProjectNameToId, + generateHyperLiquidPayload, } from "utils"; import { TransferQuote } from "./useTransferQuote"; import { SelectedRoute } from "../utils"; @@ -35,7 +35,6 @@ import useReferrer from "hooks/useReferrer"; import { SwapQuoteApiResponse } from "utils/serverless-api/prod/swap-quote"; import { BridgeLimitInterface } from "utils/serverless-api/types"; import { CHAIN_IDs } from "@across-protocol/constants"; -import { Contract } from "ethers"; const config = getConfig(); @@ -128,13 +127,11 @@ export function useBridgeAction( // 4. We must construct a payload to send to HL's Bridge2 contract // 5. The user must sign this signature - // Estimated fee for this HL deposit. Sum of the relayer capital fee, - // the lp fee, and 2x the relayer gas fee. + // Estimated fee for this HL deposit. Sum of the fees plus a 2 unit buffer const estimatedFee = frozenFeeQuote.relayerCapitalFee.total .add(frozenFeeQuote.lpFee.total) - .add(frozenFeeQuote.relayerGasFee.total.mul(2)); - - const amount = frozenDepositArgs.amount.sub(estimatedFee); + .add(frozenFeeQuote.relayerGasFee.total); + const amount = frozenDepositArgs.amount.sub(estimatedFee).sub(2); // Build the payload const hyperLiquidPayload = await generateHyperLiquidPayload( @@ -431,77 +428,3 @@ function getButtonLabel(args: { } return "Confirm transaction"; } - -/** - * Creates a payload that will be ingested by Bridge2/batchedDepositWithPermit of a single deposit - */ -export async function generateHyperLiquidPayload( - signer: Signer, - recipient: string, - amount: BigNumber -) { - const source = await signer.getAddress(); - - if (!compareAddressesSimple(source, recipient)) { - throw new Error("Source and recipient must be the same"); - } - - const timestamp = Date.now(); - const deadline = Math.floor(timestamp / 1000) + 3600; - - // Create USDC contract interface - const usdcInterface = new utils.Interface([ - "function nonces(address owner) view returns (uint256)", - "function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)", - ]); - - const usdcContract = new Contract( - getToken("USDC").addresses![CHAIN_IDs.ARBITRUM], - usdcInterface, - signer - ); - - // USDC permit signature with verified domain parameters - const usdcDomain = { - name: "USD Coin", - version: "2", - chainId: CHAIN_IDs.ARBITRUM, - verifyingContract: getToken("USDC").addresses![CHAIN_IDs.ARBITRUM]!, - }; - - const permitTypes = { - Permit: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - { name: "value", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" }, - ], - }; - - const permitValue = { - owner: source, - spender: hyperLiquidBridge2Address, - value: amount, - nonce: await usdcContract.nonces(source), - deadline, - }; - - const permitSignature = await ( - signer as providers.JsonRpcSigner - )._signTypedData(usdcDomain, permitTypes, permitValue); - const { r, s, v } = utils.splitSignature(permitSignature); - - const deposit = { - user: source, - usd: amount, - deadline, - signature: { r: BigNumber.from(r), s: BigNumber.from(s), v }, - }; - - const iface = new utils.Interface([ - "function batchedDepositWithPermit(tuple(address user, uint64 usd, uint64 deadline, tuple(uint256 r, uint256 s, uint8 v) signature)[] deposits)", - ]); - - return iface.encodeFunctionData("batchedDepositWithPermit", [[deposit]]); -} diff --git a/src/views/Bridge/hooks/useTransferQuote.ts b/src/views/Bridge/hooks/useTransferQuote.ts index 50c71a707..f91697e66 100644 --- a/src/views/Bridge/hooks/useTransferQuote.ts +++ b/src/views/Bridge/hooks/useTransferQuote.ts @@ -51,6 +51,7 @@ export function useTransferQuote( selectedRoute.toChain, selectedRoute.fromTokenSymbol, selectedRoute.toTokenSymbol, + selectedRoute.externalProjectId, toAddress ); const limitsQuery = useBridgeLimits(