UNPKG

@openocean.finance/widget-sdk

Version:

OpenOcean Any-to-Any Cross-Chain-Swap SDK

643 lines (586 loc) 20.6 kB
import type { ExtendedChain, OpenOceanStep, SignedTypedData, } from '@openocean.finance/widget-types' import type { Address, Client, GetAddressesReturnType, Hash, Hex, SendTransactionParameters, TransactionReceipt, } from 'viem' import { estimateGas, getAddresses, sendTransaction, signTypedData, } from 'viem/actions' import { sendCalls } from 'viem/experimental' import { getAction } from 'viem/utils' import { config } from '../../config.js' import { OpenOceanErrorCode } from '../../errors/constants.js' import { TransactionError } from '../../errors/errors.js' import { getRelayerQuote, getStepTransaction, relayTransaction, } from '../../services/api.js' import { BaseStepExecutor } from '../BaseStepExecutor.js' import { checkBalance } from '../checkBalance.js' import { stepComparison } from '../stepComparison.js' import type { OpenOceanStepExtended, Process, StepExecutorOptions, TransactionParameters, } from '../types.js' import { waitForDestinationChainTransaction } from '../waitForDestinationChainTransaction.js' import { checkAllowance } from './checkAllowance.js' import { isBatchingSupported } from './isBatchingSupported.js' import { parseEVMErrors } from './parseEVMErrors.js' import { encodeNativePermitData } from './permits/encodeNativePermitData.js' import { encodePermit2Data } from './permits/encodePermit2Data.js' import { signPermit2Message } from './permits/signPermit2Message.js' import { switchChain } from './switchChain.js' import { isRelayerStep } from './typeguards.js' import type { Call, TransactionMethodType } from './types.js' import { convertExtendedChain, getMaxPriorityFeePerGas } from './utils.js' import { type WalletCallReceipt, 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: OpenOceanStepExtended, process?: Process) => { const updatedClient = await switchChain( this.client, this.statusManager, step, this.allowUserInteraction, this.executionOptions?.switchChainHook ) 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() ) { let processToUpdate = process if (!processToUpdate) { // We need to create some process if we don't have one so we can show the error processToUpdate = this.statusManager.findOrCreateProcess({ step, type: 'TRANSACTION', }) } const errorMessage = 'The wallet address that requested the quote does not match the wallet address attempting to sign the transaction.' this.statusManager.updateProcess(step, processToUpdate.type, 'FAILED', { error: { code: OpenOceanErrorCode.WalletChangedDuringExecution, message: errorMessage, }, }) this.statusManager.updateExecution(step, 'FAILED') throw await parseEVMErrors( new TransactionError( OpenOceanErrorCode.WalletChangedDuringExecution, errorMessage ), step, process ) } return updatedClient } waitForTransaction = async ({ step, process, fromChain, toChain, txType, txHash, isBridgeExecution, }: { step: OpenOceanStepExtended process: Process fromChain: ExtendedChain toChain: ExtendedChain txType: TransactionMethodType txHash: Hash isBridgeExecution: boolean }) => { let transactionReceipt: TransactionReceipt | WalletCallReceipt | undefined switch (txType) { case 'batched': transactionReceipt = await waitForBatchTransactionReceipt( this.client, txHash ) break case 'relayed': transactionReceipt = await waitForRelayedTransactionReceipt( txHash, step ) break default: transactionReceipt = await waitForTransactionReceipt({ client: this.client, chainId: fromChain.id, txHash, onReplaced: (response) => { this.statusManager.updateProcess(step, process.type, 'PENDING', { txHash: response.transaction.hash, txLink: `${fromChain.metamask.blockExplorerUrls[0]}tx/${response.transaction.hash}`, }) }, }) } // Update pending process if the transaction hash from the receipt is different. // This might happen if the transaction was replaced. if ( transactionReceipt?.transactionHash && transactionReceipt.transactionHash !== txHash ) { process = this.statusManager.updateProcess( step, process.type, 'PENDING', { txHash: transactionReceipt.transactionHash, txLink: `${fromChain.metamask.blockExplorerUrls[0]}tx/${transactionReceipt.transactionHash}`, } ) } if (isBridgeExecution) { process = this.statusManager.updateProcess(step, process.type, 'DONE') } await waitForDestinationChainTransaction( step, process, fromChain, toChain, this.statusManager ) } executeStep = async ( step: OpenOceanStepExtended ): Promise<OpenOceanStepExtended> => { 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?.substatus !== 'WAIT_DESTINATION_TRANSACTION') { const updatedClient = await this.checkClient(step) 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[] = [] const batchingSupported = 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 // Check if step requires permit signature and will be used with relayer service const isRelayerTransaction = isRelayerStep(step) // Check if the wallet supports message signing - useful for smart contract wallets const disableMessageSigning = this.executionOptions?.disableMessageSigning // Check if chain has Permit2 contract deployed. Permit2 should not be available for atomic batch. const permit2Supported = !!fromChain.permit2 && !!fromChain.permit2Proxy && !batchingSupported && !isFromNativeToken && !disableMessageSigning const checkForAllowance = // No existing swap/bridge transaction is pending !existingProcess?.txHash && // Token is not native (address is not zero) !isFromNativeToken let signedNativePermitTypedData: SignedTypedData | undefined if (checkForAllowance) { // Check if token needs approval and get approval transaction or message data when available const allowanceResult = await checkAllowance({ client: this.client, chain: fromChain, step, statusManager: this.statusManager, executionOptions: this.executionOptions, allowUserInteraction: this.allowUserInteraction, batchingSupported, permit2Supported, disableMessageSigning, }) if (allowanceResult.status === 'BATCH_APPROVAL') { // Create approval transaction call // No value needed since we're only approving ERC20 tokens if (batchingSupported) { calls.push(allowanceResult.data) } } if (allowanceResult.status === 'NATIVE_PERMIT') { signedNativePermitTypedData = allowanceResult.data } if ( allowanceResult.status === 'ACTION_REQUIRED' && !this.allowUserInteraction ) { return step } } let process = this.statusManager.findProcess(step, currentProcessType) if (process?.status === 'DONE') { await waitForDestinationChainTransaction( step, process, fromChain, toChain, this.statusManager ) return step } try { if (process?.txHash) { // Make sure that the chain is still correct const updatedClient = await this.checkClient(step, process) if (!updatedClient) { return step } // Wait for exiting transaction const txHash = process.txHash as Hash const txType = process.txType as TransactionMethodType await this.waitForTransaction({ step, process, fromChain, toChain, txType, txHash, isBridgeExecution, }) return step } const permitRequired = !batchingSupported && !signedNativePermitTypedData && permit2Supported process = this.statusManager.findOrCreateProcess({ step, type: permitRequired ? 'PERMIT' : currentProcessType, status: 'STARTED', chainId: fromChain.id, }) // Check balance await checkBalance(this.client.account!.address, step) // Create new transaction request if (!step.transactionRequest) { const { execution, ...stepBase } = step let updatedStep: OpenOceanStep if (isRelayerTransaction) { 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 { updatedStep = await getStepTransaction(stepBase) } const comparedStep = await stepComparison( this.statusManager, step, updatedStep, this.allowUserInteraction, this.executionOptions ) Object.assign(step, { ...comparedStep, execution: step.execution, }) } if (!step.transactionRequest) { throw new TransactionError( OpenOceanErrorCode.TransactionUnprepared, 'Unable to prepare transaction.' ) } let transactionRequest: TransactionParameters = { 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) { const customizedTransactionRequest: TransactionParameters = await this.executionOptions.updateTransactionRequestHook({ requestType: 'transaction', ...transactionRequest, }) transactionRequest = { ...transactionRequest, ...customizedTransactionRequest, } } // 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 let txType: TransactionMethodType = 'standard' if (batchingSupported) { 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, }) txHash = id as Hash txType = 'batched' } else if (isRelayerTransaction) { const relayerTypedData = step.typedData.find( (p) => p.primaryType === 'PermitWitnessTransferFrom' || p.primaryType === 'Order' ) if (!relayerTypedData) { throw new TransactionError( OpenOceanErrorCode.TransactionUnprepared, 'Unable to prepare transaction. Typed data for transfer is not found.' ) } const signature = await getAction( this.client, signTypedData, 'signTypedData' )({ account: this.client.account!, primaryType: relayerTypedData.primaryType, domain: relayerTypedData.domain, types: relayerTypedData.types, message: relayerTypedData.message, }) this.statusManager.updateProcess(step, process.type, 'DONE') process = this.statusManager.findOrCreateProcess({ step, type: currentProcessType, status: 'PENDING', chainId: fromChain.id, }) const signedTypedData: SignedTypedData[] = [ { ...relayerTypedData, signature: signature, }, ] // Add native permit if available as first element, order is important if (signedNativePermitTypedData) { signedTypedData.unshift(signedNativePermitTypedData) } const { execution, ...stepBase } = step const relayedTransaction = await relayTransaction({ ...stepBase, typedData: signedTypedData, }) txHash = relayedTransaction.taskId as Hash txType = 'relayed' } else { 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) { 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, }) this.statusManager.updateProcess(step, process.type, 'DONE') process = this.statusManager.findOrCreateProcess({ step, type: currentProcessType, status: 'PENDING', chainId: fromChain.id, }) 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 ) } if (signedNativePermitTypedData || permit2Supported) { try { // Target address should be the Permit2 proxy contract in case of native permit or Permit2 transactionRequest.to = fromChain.permit2Proxy // Try to re-estimate the gas due to additional Permit data const estimatedGas = await estimateGas(this.client, { 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 { // Let the wallet estimate the gas in case of failure transactionRequest.gas = undefined } finally { this.statusManager.updateProcess(step, process.type, 'DONE') } } process = this.statusManager.updateProcess( step, process.type, 'ACTION_REQUIRED' ) 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, txType, txLink: txType === 'standard' ? `${fromChain.metamask.blockExplorerUrls[0]}tx/${txHash}` : undefined, } ) await this.waitForTransaction({ step, process, fromChain, toChain, txHash, txType, isBridgeExecution, }) // DONE return step } catch (e: any) { 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 } } }