UNPKG

@lifi/sdk

Version:

LI.FI Any-to-Any Cross-Chain-Swap SDK

577 lines 29.9 kB
import { estimateGas, getAddresses, sendCalls, sendTransaction, signTypedData, } from 'viem/actions'; import { getAction, isHex } from 'viem/utils'; import { config } from '../../config.js'; import { PatcherMagicNumber } from '../../constants.js'; import { LiFiErrorCode } from '../../errors/constants.js'; import { TransactionError } from '../../errors/errors.js'; import { getContractCallsQuote, getRelayerQuote, getStepTransaction, patchContractCalls, relayTransaction, } from '../../services/api.js'; import { convertQuoteToRoute } from '../../utils/convertQuoteToRoute.js'; import { isZeroAddress } from '../../utils/isZeroAddress.js'; import { BaseStepExecutor } from '../BaseStepExecutor.js'; import { checkBalance } from '../checkBalance.js'; import { stepComparison } from '../stepComparison.js'; import { waitForDestinationChainTransaction } from '../waitForDestinationChainTransaction.js'; import { checkAllowance } from './checkAllowance.js'; import { getActionWithFallback } from './getActionWithFallback.js'; import { isBatchingSupported } from './isBatchingSupported.js'; import { isAtomicReadyWalletRejectedUpgradeError, parseEVMErrors, } from './parseEVMErrors.js'; import { encodeNativePermitData } from './permits/encodeNativePermitData.js'; import { encodePermit2Data } from './permits/encodePermit2Data.js'; import { isNativePermitValid } from './permits/isNativePermitValid.js'; import { signPermit2Message } from './permits/signPermit2Message.js'; import { switchChain } from './switchChain.js'; import { isContractCallStep, isGaslessStep, isRelayerStep, } from './typeguards.js'; import { convertExtendedChain, getDomainChainId, getMaxPriorityFeePerGas, } from './utils.js'; import { waitForBatchTransactionReceipt } from './waitForBatchTransactionReceipt.js'; import { waitForRelayedTransactionReceipt } from './waitForRelayedTransactionReceipt.js'; import { waitForTransactionReceipt } from './waitForTransactionReceipt.js'; export class EVMStepExecutor extends BaseStepExecutor { client; constructor(options) { super(options); this.client = options.client; } // Ensure that we are using the right chain and wallet when executing transactions. checkClient = async (step, process, targetChainId) => { const updatedClient = await switchChain(this.client, this.statusManager, step, process, targetChainId ?? step.action.fromChainId, this.allowUserInteraction, this.executionOptions); if (updatedClient) { this.client = updatedClient; } // Prevent execution of the quote by wallet different from the one which requested the quote let accountAddress = this.client.account?.address; if (!accountAddress) { const accountAddresses = (await getAction(this.client, getAddresses, 'getAddresses')(undefined)); accountAddress = accountAddresses?.[0]; } if (accountAddress?.toLowerCase() !== step.action.fromAddress?.toLowerCase()) { const errorMessage = 'The wallet address that requested the quote does not match the wallet address attempting to sign the transaction.'; this.statusManager.updateProcess(step, process.type, 'FAILED', { error: { code: LiFiErrorCode.WalletChangedDuringExecution, message: errorMessage, }, }); this.statusManager.updateExecution(step, 'FAILED'); throw await parseEVMErrors(new TransactionError(LiFiErrorCode.WalletChangedDuringExecution, errorMessage), step, process); } return updatedClient; }; waitForTransaction = async ({ step, process, fromChain, toChain, isBridgeExecution, }) => { const updateProcessWithReceipt = (transactionReceipt) => { // Update pending process if the transaction hash from the receipt is different. // This might happen if the transaction was replaced or we used taskId instead of txHash. if (transactionReceipt?.transactionHash && transactionReceipt.transactionHash !== process.txHash) { // Validate if transaction hash is a valid hex string that can be used on-chain // Some custom integrations may return non-hex identifiers to support custom status tracking const txHash = isHex(transactionReceipt.transactionHash, { strict: true, }) ? transactionReceipt.transactionHash : undefined; process = this.statusManager.updateProcess(step, process.type, 'PENDING', { txHash: txHash, txLink: transactionReceipt.transactionLink || (txHash ? `${fromChain.metamask.blockExplorerUrls[0]}tx/${txHash}` : undefined), }); } }; let transactionReceipt; switch (process.txType) { case 'batched': transactionReceipt = await waitForBatchTransactionReceipt(this.client, process.taskId, (result) => { const receipt = result.receipts?.find((r) => r.status === 'reverted'); if (receipt) { updateProcessWithReceipt(receipt); } }); break; case 'relayed': transactionReceipt = await waitForRelayedTransactionReceipt(process.taskId, step); break; default: transactionReceipt = await waitForTransactionReceipt({ client: this.client, chainId: fromChain.id, txHash: process.txHash, onReplaced: (response) => { this.statusManager.updateProcess(step, process.type, 'PENDING', { txHash: response.transaction.hash, txLink: `${fromChain.metamask.blockExplorerUrls[0]}tx/${response.transaction.hash}`, }); }, }); } updateProcessWithReceipt(transactionReceipt); if (isBridgeExecution) { process = this.statusManager.updateProcess(step, process.type, 'DONE'); } await waitForDestinationChainTransaction(step, process, fromChain, toChain, this.statusManager); }; prepareUpdatedStep = async (step, process, signedTypedData) => { // biome-ignore lint/correctness/noUnusedVariables: destructuring const { execution, ...stepBase } = step; const relayerStep = isRelayerStep(step); const gaslessStep = isGaslessStep(step); const contractCallStep = isContractCallStep(step); let updatedStep; if (contractCallStep) { const contractCallsResult = await this.executionOptions?.getContractCalls?.({ fromAddress: stepBase.action.fromAddress, fromAmount: BigInt(stepBase.action.fromAmount), fromChainId: stepBase.action.fromChainId, fromTokenAddress: stepBase.action.fromToken.address, slippage: stepBase.action.slippage, toAddress: stepBase.action.toAddress, toAmount: BigInt(stepBase.estimate.toAmount), toChainId: stepBase.action.toChainId, toTokenAddress: stepBase.action.toToken.address, }); if (!contractCallsResult?.contractCalls?.length) { throw new TransactionError(LiFiErrorCode.TransactionUnprepared, 'Unable to prepare transaction. Contract calls are not found.'); } if (contractCallsResult.patcher) { const patchedContractCalls = await patchContractCalls(contractCallsResult.contractCalls.map((call) => ({ chainId: stepBase.action.toChainId, fromTokenAddress: call.fromTokenAddress, targetContractAddress: call.toContractAddress, callDataToPatch: call.toContractCallData, delegateCall: false, patches: [ { amountToReplace: PatcherMagicNumber.toString(), }, ], }))); contractCallsResult.contractCalls.forEach((call, index) => { call.toContractAddress = patchedContractCalls[index].target; call.toContractCallData = patchedContractCalls[index].callData; }); } /** * Limitations of the retry logic for contract calls: * - denyBridges and denyExchanges are not supported * - allowBridges and allowExchanges are not supported * - fee is not supported * - toAmount is not supported */ const contractCallQuote = await getContractCallsQuote({ // Contract calls are enabled only when fromAddress is set fromAddress: stepBase.action.fromAddress, fromChain: stepBase.action.fromChainId, fromToken: stepBase.action.fromToken.address, fromAmount: stepBase.action.fromAmount, toChain: stepBase.action.toChainId, toToken: stepBase.action.toToken.address, contractCalls: contractCallsResult.contractCalls, toFallbackAddress: stepBase.action.toAddress, slippage: stepBase.action.slippage, }); contractCallQuote.action.toToken = stepBase.action.toToken; const customStep = contractCallQuote.includedSteps?.find((step) => step.type === 'custom'); if (customStep && contractCallsResult?.contractTool) { const toolDetails = { key: contractCallsResult.contractTool.name, name: contractCallsResult.contractTool.name, logoURI: contractCallsResult.contractTool.logoURI, }; customStep.toolDetails = toolDetails; contractCallQuote.toolDetails = toolDetails; } const route = convertQuoteToRoute(contractCallQuote, { adjustZeroOutputFromPreviousStep: this.executionOptions?.adjustZeroOutputFromPreviousStep, }); updatedStep = { ...route.steps[0], id: stepBase.id, }; } else if (relayerStep && gaslessStep) { const updatedRelayedStep = await getRelayerQuote({ fromChain: stepBase.action.fromChainId, fromToken: stepBase.action.fromToken.address, fromAddress: stepBase.action.fromAddress, fromAmount: stepBase.action.fromAmount, toChain: stepBase.action.toChainId, toToken: stepBase.action.toToken.address, slippage: stepBase.action.slippage, toAddress: stepBase.action.toAddress, allowBridges: [stepBase.tool], }); updatedStep = { ...updatedRelayedStep, id: stepBase.id, }; } else { const filteredSignedTypedData = signedTypedData?.filter((item) => item.signature); const { typedData: _, ...restStepBase } = stepBase; const params = filteredSignedTypedData?.length ? { ...restStepBase, typedData: filteredSignedTypedData } : restStepBase; updatedStep = await getStepTransaction(params); } const comparedStep = await stepComparison(this.statusManager, step, updatedStep, this.allowUserInteraction, this.executionOptions); Object.assign(step, { ...comparedStep, execution: step.execution, typedData: updatedStep.typedData ?? step.typedData, }); if (!step.transactionRequest && !step.typedData?.length) { throw new TransactionError(LiFiErrorCode.TransactionUnprepared, 'Unable to prepare transaction.'); } let transactionRequest; if (step.transactionRequest) { // Only call checkClient for local accounts when we need to get maxPriorityFeePerGas let maxPriorityFeePerGas; if (this.client.account?.type === 'local') { const updatedClient = await this.checkClient(step, process); if (!updatedClient) { return null; } maxPriorityFeePerGas = await getMaxPriorityFeePerGas(updatedClient); } else { maxPriorityFeePerGas = step.transactionRequest.maxPriorityFeePerGas ? BigInt(step.transactionRequest.maxPriorityFeePerGas) : undefined; } transactionRequest = { chainId: step.transactionRequest.chainId, to: step.transactionRequest.to, from: step.transactionRequest.from, data: step.transactionRequest.data, value: step.transactionRequest.value ? BigInt(step.transactionRequest.value) : undefined, gas: step.transactionRequest.gasLimit ? BigInt(step.transactionRequest.gasLimit) : undefined, // gasPrice: step.transactionRequest.gasPrice // ? BigInt(step.transactionRequest.gasPrice as string) // : undefined, // maxFeePerGas: step.transactionRequest.maxFeePerGas // ? BigInt(step.transactionRequest.maxFeePerGas as string) // : undefined, maxPriorityFeePerGas, }; } if (this.executionOptions?.updateTransactionRequestHook && transactionRequest) { const customizedTransactionRequest = await this.executionOptions.updateTransactionRequestHook({ requestType: 'transaction', ...transactionRequest, }); transactionRequest = { ...transactionRequest, ...customizedTransactionRequest, }; } return { transactionRequest, // We should always check against the updated step, // because the step may be updated with typed data from the previously signed typed data isRelayerTransaction: isRelayerStep(updatedStep), }; }; estimateTransactionRequest = async (client, transactionRequest) => { try { // Try to re-estimate the gas due to additional Permit data const estimatedGas = await getActionWithFallback(client, estimateGas, 'estimateGas', { account: client.account, to: transactionRequest.to, data: transactionRequest.data, value: transactionRequest.value, }); // Use the higher of estimated vs original, then add buffer const baseGas = transactionRequest.gas && transactionRequest.gas > estimatedGas ? transactionRequest.gas : estimatedGas; transactionRequest.gas = baseGas + 300000n; } catch (_) { // If estimation fails, add 300K buffer to existing gas limit if (transactionRequest.gas) { transactionRequest.gas = transactionRequest.gas + 300000n; } } return transactionRequest; }; executeStep = async (step, // Explicitly set to true if the wallet rejected the upgrade to 7702 account, based on the EIP-5792 capabilities atomicityNotReady = false) => { step.execution = this.statusManager.initExecutionObject(step); // Find if it's bridging and the step is waiting for a transaction on the destination chain const destinationChainProcess = step.execution?.process.find((process) => process.type === 'RECEIVING_CHAIN'); // Make sure that the chain is still correct // If the step is waiting for a transaction on the destination chain, we do not switch the chain // All changes are already done from the source chain // Return the step if (destinationChainProcess && destinationChainProcess.substatus !== 'WAIT_DESTINATION_TRANSACTION') { const updatedClient = await this.checkClient(step, destinationChainProcess); if (!updatedClient) { return step; } } const fromChain = await config.getChainById(step.action.fromChainId); const toChain = await config.getChainById(step.action.toChainId); // Check if the wallet supports atomic batch transactions (EIP-5792) const calls = []; // Signed typed data for native permits and other messages let signedTypedData = []; // Batching via EIP-5792 is disabled in the next cases: // 1. When atomicity is not ready or the wallet rejected the upgrade to 7702 account (atomicityNotReady is true) // 2. When the step is using thorswap tool (temporary disabled) // 3. When using relayer transactions const batchingSupported = atomicityNotReady || step.tool === 'thorswap' || isRelayerStep(step) ? false : await isBatchingSupported({ client: this.client, chainId: fromChain.id, }); const isBridgeExecution = fromChain.id !== toChain.id; const currentProcessType = isBridgeExecution ? 'CROSS_CHAIN' : 'SWAP'; // Find existing swap/bridge process const existingProcess = step.execution.process.find((p) => p.type === currentProcessType); const isFromNativeToken = fromChain.nativeToken.address === step.action.fromToken.address && isZeroAddress(step.action.fromToken.address); // Check if message signing is disabled - useful for smart contract wallets // We also disable message signing for custom steps const disableMessageSigning = this.executionOptions?.disableMessageSigning || step.type !== 'lifi'; // Check if chain has Permit2 contract deployed. Permit2 should not be available for atomic batch. const permit2Supported = !!fromChain.permit2 && !!fromChain.permit2Proxy && !batchingSupported && !isFromNativeToken && !disableMessageSigning && // Approval address is not required for Permit2 per se, but we use it to skip allowance checks for direct transfers !!step.estimate.approvalAddress && !step.estimate.skipApproval && !step.estimate.skipPermit; const checkForAllowance = // No existing swap/bridge transaction is pending !existingProcess?.txHash && // No existing swap/bridge batch/order is pending !existingProcess?.taskId && // Token is not native (address is not zero) !isFromNativeToken && // Approval address is required for allowance checks, but may be null in special cases (e.g. direct transfers) !!step.estimate.approvalAddress && !step.estimate.skipApproval; if (checkForAllowance) { // Check if token needs approval and get approval transaction or message data when available const allowanceResult = await checkAllowance({ checkClient: this.checkClient, chain: fromChain, step, statusManager: this.statusManager, executionOptions: this.executionOptions, allowUserInteraction: this.allowUserInteraction, batchingSupported, permit2Supported, disableMessageSigning, }); switch (allowanceResult.status) { case 'BATCH_APPROVAL': calls.push(...allowanceResult.data.calls); signedTypedData = allowanceResult.data.signedTypedData; break; case 'NATIVE_PERMIT': signedTypedData = allowanceResult.data; break; case 'DONE': signedTypedData = allowanceResult.data; break; default: if (!this.allowUserInteraction) { return step; } break; } } let process = this.statusManager.findProcess(step, currentProcessType); try { if (process?.status === 'DONE') { await waitForDestinationChainTransaction(step, process, fromChain, toChain, this.statusManager); return step; } if (process?.txHash || process?.taskId) { // Make sure that the chain is still correct const updatedClient = await this.checkClient(step, process); if (!updatedClient) { return step; } await this.waitForTransaction({ step, process, fromChain, toChain, isBridgeExecution, }); return step; } process = this.statusManager.findOrCreateProcess({ step, type: currentProcessType, status: 'STARTED', chainId: fromChain.id, }); await checkBalance(this.client.account.address, step); // Try to prepare a new transaction request and update the step with typed data const preparedStep = await this.prepareUpdatedStep(step, process, signedTypedData); if (!preparedStep) { return step; } let { transactionRequest, isRelayerTransaction } = preparedStep; process = this.statusManager.updateProcess(step, process.type, 'ACTION_REQUIRED'); if (!this.allowUserInteraction) { return step; } let txHash; let taskId; let txType = 'standard'; let txLink; if (batchingSupported && transactionRequest) { // Make sure that the chain is still correct const updatedClient = await this.checkClient(step, process); if (!updatedClient) { return step; } const transferCall = { chainId: fromChain.id, data: transactionRequest.data, to: transactionRequest.to, value: transactionRequest.value, }; calls.push(transferCall); const { id } = await getAction(this.client, sendCalls, 'sendCalls')({ account: this.client.account, calls, }); taskId = id; txType = 'batched'; } else if (isRelayerTransaction) { const intentTypedData = step.typedData?.filter((typedData) => !signedTypedData.some((signedPermit) => isNativePermitValid(signedPermit, typedData))); if (!intentTypedData?.length) { throw new TransactionError(LiFiErrorCode.TransactionUnprepared, 'Unable to prepare transaction. Typed data for transfer is not found.'); } this.statusManager.updateProcess(step, process.type, 'MESSAGE_REQUIRED'); for (const typedData of intentTypedData) { if (!this.allowUserInteraction) { return step; } const typedDataChainId = getDomainChainId(typedData.domain) || fromChain.id; // Switch to the typed data's chain if needed const updatedClient = await this.checkClient(step, process, typedDataChainId); if (!updatedClient) { return step; } const signature = await getAction(updatedClient, signTypedData, 'signTypedData')({ account: updatedClient.account, primaryType: typedData.primaryType, domain: typedData.domain, types: typedData.types, message: typedData.message, }); signedTypedData.push({ ...typedData, signature: signature, }); } this.statusManager.updateProcess(step, process.type, 'PENDING'); // biome-ignore lint/correctness/noUnusedVariables: destructuring const { execution, ...stepBase } = step; const relayedTransaction = await relayTransaction({ ...stepBase, typedData: signedTypedData, }); taskId = relayedTransaction.taskId; txType = 'relayed'; txLink = relayedTransaction.txLink; } else { if (!transactionRequest) { throw new TransactionError(LiFiErrorCode.TransactionUnprepared, 'Unable to prepare transaction. Transaction request is not found.'); } // Make sure that the chain is still correct const updatedClient = await this.checkClient(step, process); if (!updatedClient) { return step; } const signedNativePermitTypedData = signedTypedData.find((p) => p.primaryType === 'Permit' && getDomainChainId(p.domain) === fromChain.id); if (signedNativePermitTypedData) { transactionRequest.data = encodeNativePermitData(step.action.fromToken.address, BigInt(step.action.fromAmount), signedNativePermitTypedData.message.deadline, signedNativePermitTypedData.signature, transactionRequest.data); } else if (permit2Supported) { this.statusManager.updateProcess(step, process.type, 'MESSAGE_REQUIRED'); const permit2Signature = await signPermit2Message({ client: updatedClient, chain: fromChain, tokenAddress: step.action.fromToken.address, amount: BigInt(step.action.fromAmount), data: transactionRequest.data, }); transactionRequest.data = encodePermit2Data(step.action.fromToken.address, BigInt(step.action.fromAmount), permit2Signature.message.nonce, permit2Signature.message.deadline, transactionRequest.data, permit2Signature.signature); this.statusManager.updateProcess(step, process.type, 'ACTION_REQUIRED'); } if (signedNativePermitTypedData || permit2Supported) { // Target address should be the Permit2 proxy contract in case of native permit or Permit2 transactionRequest.to = fromChain.permit2Proxy; transactionRequest = await this.estimateTransactionRequest(updatedClient, transactionRequest); } txHash = await getAction(updatedClient, sendTransaction, 'sendTransaction')({ to: transactionRequest.to, account: updatedClient.account, data: transactionRequest.data, value: transactionRequest.value, gas: transactionRequest.gas, gasPrice: transactionRequest.gasPrice, maxFeePerGas: transactionRequest.maxFeePerGas, maxPriorityFeePerGas: transactionRequest.maxPriorityFeePerGas, chain: convertExtendedChain(fromChain), }); } process = this.statusManager.updateProcess(step, process.type, 'PENDING', // When atomic batch or relayer are supported, txHash represents the batch hash or taskId rather than an individual transaction hash { txHash, taskId, txType, txLink: txType === 'standard' && txHash ? `${fromChain.metamask.blockExplorerUrls[0]}tx/${txHash}` : txLink, }); await this.waitForTransaction({ step, process, fromChain, toChain, isBridgeExecution, }); // DONE return step; } catch (e) { // If the wallet rejected the upgrade to 7702 account, we need to try again with the standard flow if (isAtomicReadyWalletRejectedUpgradeError(e) && !atomicityNotReady) { step.execution = undefined; return this.executeStep(step, true); } const error = await parseEVMErrors(e, step, process); process = this.statusManager.updateProcess(step, process?.type || currentProcessType, 'FAILED', { error: { message: error.cause.message, code: error.code, }, }); this.statusManager.updateExecution(step, 'FAILED'); throw error; } }; } //# sourceMappingURL=EVMStepExecutor.js.map