@lifi/sdk
Version:
LI.FI Any-to-Any Cross-Chain-Swap SDK
748 lines (692 loc) • 24.5 kB
text/typescript
import type { ExtendedChain, LiFiStep, SignedTypedData } from '@lifi/types'
import type {
Address,
Client,
GetAddressesReturnType,
Hash,
Hex,
SendTransactionParameters,
TransactionReceipt,
} from 'viem'
import {
estimateGas,
getAddresses,
sendCalls,
sendTransaction,
signTypedData,
} from 'viem/actions'
import { getAction, isHex } from 'viem/utils'
import { config } from '../../config.js'
import { LiFiErrorCode } from '../../errors/constants.js'
import { TransactionError } from '../../errors/errors.js'
import {
getRelayerQuote,
getStepTransaction,
relayTransaction,
} from '../../services/api.js'
import { isZeroAddress } from '../../utils/isZeroAddress.js'
import { BaseStepExecutor } from '../BaseStepExecutor.js'
import { checkBalance } from '../checkBalance.js'
import { stepComparison } from '../stepComparison.js'
import type {
LiFiStepExtended,
Process,
StepExecutorOptions,
TransactionMethodType,
TransactionParameters,
} from '../types.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 { isGaslessStep, isRelayerStep } from './typeguards.js'
import type { Call, WalletCallReceipt } from './types.js'
import {
convertExtendedChain,
getDomainChainId,
getMaxPriorityFeePerGas,
} from './utils.js'
import { waitForBatchTransactionReceipt } from './waitForBatchTransactionReceipt.js'
import { waitForRelayedTransactionReceipt } from './waitForRelayedTransactionReceipt.js'
import { waitForTransactionReceipt } from './waitForTransactionReceipt.js'
export interface EVMStepExecutorOptions extends StepExecutorOptions {
client: Client
}
export class EVMStepExecutor extends BaseStepExecutor {
private client: Client
constructor(options: EVMStepExecutorOptions) {
super(options)
this.client = options.client
}
// Ensure that we are using the right chain and wallet when executing transactions.
checkClient = async (
step: LiFiStepExtended,
process: Process,
targetChainId?: number
) => {
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)) as GetAddressesReturnType
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,
}: {
step: LiFiStepExtended
process: Process
fromChain: ExtendedChain
toChain: ExtendedChain
isBridgeExecution: boolean
}) => {
const updateProcessWithReceipt = (
transactionReceipt: TransactionReceipt | WalletCallReceipt | undefined
) => {
// 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 as WalletCallReceipt).transactionLink ||
(txHash
? `${fromChain.metamask.blockExplorerUrls[0]}tx/${txHash}`
: undefined),
}
)
}
}
let transactionReceipt: TransactionReceipt | WalletCallReceipt | undefined
switch (process.txType) {
case 'batched':
transactionReceipt = await waitForBatchTransactionReceipt(
this.client,
process.taskId as Hash,
(result) => {
const receipt = result.receipts?.find(
(r) => r.status === 'reverted'
) as WalletCallReceipt | undefined
if (receipt) {
updateProcessWithReceipt(receipt)
}
}
)
break
case 'relayed':
transactionReceipt = await waitForRelayedTransactionReceipt(
process.taskId as Hash,
step
)
break
default:
transactionReceipt = await waitForTransactionReceipt({
client: this.client,
chainId: fromChain.id,
txHash: process.txHash as Hash,
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
)
}
private prepareUpdatedStep = async (
step: LiFiStepExtended,
signedTypedData?: SignedTypedData[]
) => {
// biome-ignore lint/correctness/noUnusedVariables: destructuring
const { execution, ...stepBase } = step
const relayerStep = isRelayerStep(step)
const gaslessStep = isGaslessStep(step)
let updatedStep: LiFiStep
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 params = filteredSignedTypedData?.length
? {
...stepBase,
typedData: filteredSignedTypedData,
}
: stepBase
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: TransactionParameters | undefined
if (step.transactionRequest) {
transactionRequest = {
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:
this.client.account?.type === 'local'
? await getMaxPriorityFeePerGas(this.client)
: step.transactionRequest.maxPriorityFeePerGas
? BigInt(step.transactionRequest.maxPriorityFeePerGas)
: undefined,
}
}
if (
this.executionOptions?.updateTransactionRequestHook &&
transactionRequest
) {
const customizedTransactionRequest: TransactionParameters =
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),
}
}
private estimateTransactionRequest = async (
transactionRequest: TransactionParameters,
fromChain: ExtendedChain
) => {
// Target address should be the Permit2 proxy contract in case of native permit or Permit2
transactionRequest.to = fromChain.permit2Proxy
try {
// Try to re-estimate the gas due to additional Permit data
const estimatedGas = await getActionWithFallback(
this.client,
estimateGas,
'estimateGas',
{
account: this.client.account!,
to: transactionRequest.to as Address,
data: transactionRequest.data as Hex,
value: transactionRequest.value,
}
)
transactionRequest.gas =
transactionRequest.gas && transactionRequest.gas > estimatedGas
? transactionRequest.gas
: estimatedGas
} catch (_) {
// If we fail to estimate the gas, we add 80_000 gas units Permit buffer to the gas limit
if (transactionRequest.gas) {
transactionRequest.gas = transactionRequest.gas + 80_000n
}
}
return transactionRequest
}
executeStep = async (
step: LiFiStepExtended,
// Explicitly set to true if the wallet rejected the upgrade to 7702 account, based on the EIP-5792 capabilities
atomicityNotReady = false
): Promise<LiFiStepExtended> => {
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: Call[] = []
// Signed typed data for native permits and other messages
let signedTypedData: 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
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
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.call)
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
let { transactionRequest, isRelayerTransaction } =
await this.prepareUpdatedStep(step, signedTypedData)
// Make sure that the chain is still correct
const updatedClient = await this.checkClient(step, process)
if (!updatedClient) {
return step
}
process = this.statusManager.updateProcess(
step,
process.type,
'ACTION_REQUIRED'
)
if (!this.allowUserInteraction) {
return step
}
let txHash: Hash | undefined
let taskId: Hash | undefined
let txType: TransactionMethodType = 'standard'
let txLink: string | undefined
if (batchingSupported && transactionRequest) {
const transferCall: Call = {
chainId: fromChain.id,
data: transactionRequest.data as Hex,
to: transactionRequest.to as Address,
value: transactionRequest.value,
}
calls.push(transferCall)
const { id } = await getAction(
this.client,
sendCalls,
'sendCalls'
)({
account: this.client.account!,
calls,
})
taskId = id as Hash
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 as Hash
txType = 'relayed'
txLink = relayedTransaction.txLink
} else {
if (!transactionRequest) {
throw new TransactionError(
LiFiErrorCode.TransactionUnprepared,
'Unable to prepare transaction. Transaction request is not found.'
)
}
const signedNativePermitTypedData = signedTypedData.find(
(p) =>
p.primaryType === 'Permit' &&
getDomainChainId(p.domain) === fromChain.id
)
if (signedNativePermitTypedData) {
transactionRequest.data = encodeNativePermitData(
step.action.fromToken.address as Address,
BigInt(step.action.fromAmount),
signedNativePermitTypedData.message.deadline,
signedNativePermitTypedData.signature,
transactionRequest.data as Hex
)
} else if (permit2Supported) {
this.statusManager.updateProcess(
step,
process.type,
'MESSAGE_REQUIRED'
)
const permit2Signature = await signPermit2Message({
client: this.client,
chain: fromChain,
tokenAddress: step.action.fromToken.address as Address,
amount: BigInt(step.action.fromAmount),
data: transactionRequest.data as Hex,
})
transactionRequest.data = encodePermit2Data(
step.action.fromToken.address as Address,
BigInt(step.action.fromAmount),
permit2Signature.message.nonce,
permit2Signature.message.deadline,
transactionRequest.data as Hex,
permit2Signature.signature
)
this.statusManager.updateProcess(
step,
process.type,
'ACTION_REQUIRED'
)
}
if (signedNativePermitTypedData || permit2Supported) {
transactionRequest = await this.estimateTransactionRequest(
transactionRequest,
fromChain
)
}
txHash = await getAction(
this.client,
sendTransaction,
'sendTransaction'
)({
to: transactionRequest.to as Address,
account: this.client.account!,
data: transactionRequest.data as Hex,
value: transactionRequest.value,
gas: transactionRequest.gas,
gasPrice: transactionRequest.gasPrice,
maxFeePerGas: transactionRequest.maxFeePerGas,
maxPriorityFeePerGas: transactionRequest.maxPriorityFeePerGas,
chain: convertExtendedChain(fromChain),
} as SendTransactionParameters)
}
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: any) {
// 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
}
}
}