diff --git a/src/integration/blockchain/shared/evm/abi/layerzero-oft-adapter.abi.json b/src/integration/blockchain/shared/evm/abi/layerzero-oft-adapter.abi.json new file mode 100644 index 0000000000..cb295ada5a --- /dev/null +++ b/src/integration/blockchain/shared/evm/abi/layerzero-oft-adapter.abi.json @@ -0,0 +1,109 @@ +[ + { + "inputs": [ + { + "components": [ + { "internalType": "uint32", "name": "dstEid", "type": "uint32" }, + { "internalType": "bytes32", "name": "to", "type": "bytes32" }, + { "internalType": "uint256", "name": "amountLD", "type": "uint256" }, + { "internalType": "uint256", "name": "minAmountLD", "type": "uint256" }, + { "internalType": "bytes", "name": "extraOptions", "type": "bytes" }, + { "internalType": "bytes", "name": "composeMsg", "type": "bytes" }, + { "internalType": "bytes", "name": "oftCmd", "type": "bytes" } + ], + "internalType": "struct SendParam", + "name": "_sendParam", + "type": "tuple" + }, + { "internalType": "bool", "name": "_payInLzToken", "type": "bool" } + ], + "name": "quoteSend", + "outputs": [ + { + "components": [ + { "internalType": "uint256", "name": "nativeFee", "type": "uint256" }, + { "internalType": "uint256", "name": "lzTokenFee", "type": "uint256" } + ], + "internalType": "struct MessagingFee", + "name": "msgFee", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { "internalType": "uint32", "name": "dstEid", "type": "uint32" }, + { "internalType": "bytes32", "name": "to", "type": "bytes32" }, + { "internalType": "uint256", "name": "amountLD", "type": "uint256" }, + { "internalType": "uint256", "name": "minAmountLD", "type": "uint256" }, + { "internalType": "bytes", "name": "extraOptions", "type": "bytes" }, + { "internalType": "bytes", "name": "composeMsg", "type": "bytes" }, + { "internalType": "bytes", "name": "oftCmd", "type": "bytes" } + ], + "internalType": "struct SendParam", + "name": "_sendParam", + "type": "tuple" + }, + { + "components": [ + { "internalType": "uint256", "name": "nativeFee", "type": "uint256" }, + { "internalType": "uint256", "name": "lzTokenFee", "type": "uint256" } + ], + "internalType": "struct MessagingFee", + "name": "_fee", + "type": "tuple" + }, + { "internalType": "address", "name": "_refundAddress", "type": "address" } + ], + "name": "send", + "outputs": [ + { + "components": [ + { "internalType": "bytes32", "name": "guid", "type": "bytes32" }, + { "internalType": "uint64", "name": "nonce", "type": "uint64" }, + { + "components": [ + { "internalType": "uint256", "name": "nativeFee", "type": "uint256" }, + { "internalType": "uint256", "name": "lzTokenFee", "type": "uint256" } + ], + "internalType": "struct MessagingFee", + "name": "fee", + "type": "tuple" + } + ], + "internalType": "struct MessagingReceipt", + "name": "msgReceipt", + "type": "tuple" + }, + { + "components": [ + { "internalType": "uint256", "name": "amountSentLD", "type": "uint256" }, + { "internalType": "uint256", "name": "amountReceivedLD", "type": "uint256" } + ], + "internalType": "struct OFTReceipt", + "name": "oftReceipt", + "type": "tuple" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "token", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "approvalRequired", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function" + } +] diff --git a/src/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index c5b1d0397e..618c1095fc 100644 --- a/src/integration/blockchain/shared/evm/evm-client.ts +++ b/src/integration/blockchain/shared/evm/evm-client.ts @@ -447,7 +447,7 @@ export abstract class EvmClient extends BlockchainClient { return EvmUtil.getGasPriceLimitFromHex(txHex, currentGasPrice); } - async approveContract(asset: Asset, contractAddress: string): Promise { + async approveContract(asset: Asset, contractAddress: string, wait = false): Promise { const contract = this.getERC20ContractForDex(asset.chainId); const transaction = await contract.populateTransaction.approve(contractAddress, ethers.constants.MaxInt256); @@ -464,9 +464,20 @@ export abstract class EvmClient extends BlockchainClient { this.setNonce(this.walletAddress, nonce + 1); + if (wait) await tx.wait(); + return tx.hash; } + async checkAndApproveContract(asset: Asset, contractAddress: string, amount: EthersNumber): Promise { + const contract = this.getERC20ContractForDex(asset.chainId); + const allowance = await contract.allowance(this.walletAddress, contractAddress); + + if (allowance.gte(amount)) return null; + + return this.approveContract(asset, contractAddress, true); + } + // --- PUBLIC API - SWAPS --- // async getUniswapLiquidity(nftContract: string, positionId: number): Promise<[number, number]> { diff --git a/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts new file mode 100644 index 0000000000..4fdd7d8988 --- /dev/null +++ b/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts @@ -0,0 +1,269 @@ +import { Injectable } from '@nestjs/common'; +import { ethers } from 'ethers'; +import { CitreaClient } from 'src/integration/blockchain/citrea/citrea-client'; +import { CitreaService } from 'src/integration/blockchain/citrea/citrea.service'; +import { EthereumClient } from 'src/integration/blockchain/ethereum/ethereum-client'; +import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import LAYERZERO_OFT_ADAPTER_ABI from 'src/integration/blockchain/shared/evm/abi/layerzero-oft-adapter.abi.json'; +import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; +import { isAsset } from 'src/shared/models/active'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { LiquidityManagementOrder } from '../../entities/liquidity-management-order.entity'; +import { LiquidityManagementSystem } from '../../enums'; +import { OrderFailedException } from '../../exceptions/order-failed.exception'; +import { OrderNotProcessableException } from '../../exceptions/order-not-processable.exception'; +import { Command, CorrelationId } from '../../interfaces'; +import { LiquidityActionAdapter } from './base/liquidity-action.adapter'; + +/** + * LayerZero OFT Adapter contract addresses for bridging from Ethereum to Citrea + */ +const LAYERZERO_OFT_ADAPTERS: Record = { + // USDC: Ethereum SourceOFTAdapter -> Citrea DestinationOUSDC + USDC: { + ethereum: '0xdaa289CC487Cf95Ba99Db62f791c7E2d2a4b868E', + citrea: '0x41710804caB0974638E1504DB723D7bddec22e30', + }, + // USDT: Ethereum SourceOFTAdapter -> Citrea DestinationOUSDT + USDT: { + ethereum: '0x6925ccD29e3993c82a574CED4372d8737C6dbba6', + citrea: '0xF8b5983BFa11dc763184c96065D508AE1502C030', + }, + // WBTC: Ethereum WBTCOFTAdapter -> Citrea WBTCOFT + WBTC: { + ethereum: '0x2c01390E10e44C968B73A7BcFF7E4b4F50ba76Ed', + citrea: '0xDF240DC08B0FdaD1d93b74d5048871232f6BEA3d', + }, +}; + +// Citrea LayerZero Endpoint ID +const CITREA_LZ_ENDPOINT_ID = 30291; + +export enum LayerZeroBridgeCommands { + DEPOSIT = 'deposit', // Ethereum -> Citrea +} + +@Injectable() +export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { + protected commands = new Map(); + + private readonly ethereumClient: EthereumClient; + private readonly citreaClient: CitreaClient; + + constructor( + ethereumService: EthereumService, + citreaService: CitreaService, + private readonly assetService: AssetService, + ) { + super(LiquidityManagementSystem.LAYERZERO_BRIDGE); + + this.ethereumClient = ethereumService.getDefaultClient(); + this.citreaClient = citreaService.getDefaultClient(); + + this.commands.set(LayerZeroBridgeCommands.DEPOSIT, this.deposit.bind(this)); + } + + async checkCompletion(order: LiquidityManagementOrder): Promise { + const { + pipeline: { + rule: { target: asset }, + }, + } = order; + + if (!isAsset(asset)) { + throw new Error('LayerZeroBridgeAdapter.checkCompletion(...) supports only Asset instances as an input.'); + } + + try { + // Step 1: Verify the Ethereum transaction succeeded + const txReceipt = await this.ethereumClient.getTxReceipt(order.correlationId); + + if (!txReceipt) { + return false; + } + + if (txReceipt.status !== 1) { + throw new OrderFailedException(`LayerZero TX failed on Ethereum: ${order.correlationId}`); + } + + // Step 2: Search for incoming token transfer on Citrea from the OFT contract + const baseTokenName = this.getBaseTokenName(asset.name); + const oftAdapter = LAYERZERO_OFT_ADAPTERS[baseTokenName]; + if (!oftAdapter) { + throw new OrderFailedException(`LayerZero OFT adapter not found for ${asset.name}`); + } + + const currentBlock = await this.citreaClient.getCurrentBlock(); + const blocksPerDay = (24 * 3600) / 2; // ~2 second block time on Citrea + const fromBlock = Math.max(0, currentBlock - blocksPerDay); + + const transfers = await this.citreaClient.getERC20Transactions(this.citreaClient.walletAddress, fromBlock); + + // Find transfer from the Citrea OFT contract matching the expected amount (with 5% tolerance) + const expectedAmount = order.inputAmount; + const matchingTransfer = transfers.find((t) => { + const receivedAmount = EvmUtil.fromWeiAmount(t.value, asset.decimals); + return ( + t.contractAddress?.toLowerCase() === asset.chainId.toLowerCase() && + t.from?.toLowerCase() === oftAdapter.citrea.toLowerCase() && + Math.abs(receivedAmount - expectedAmount) / expectedAmount < 0.05 + ); + }); + + if (matchingTransfer) { + order.outputAmount = EvmUtil.fromWeiAmount(matchingTransfer.value, asset.decimals); + return true; + } + + return false; + } catch (e) { + throw e instanceof OrderFailedException ? e : new OrderFailedException(e.message); + } + } + + validateParams(_command: string, _params: Record): boolean { + // LayerZero bridge doesn't require additional params + return true; + } + + //*** COMMANDS IMPLEMENTATIONS ***// + + /** + * Deposit tokens from Ethereum to Citrea via LayerZero + */ + private async deposit(order: LiquidityManagementOrder): Promise { + const { + pipeline: { + rule: { targetAsset: citreaAsset }, + }, + minAmount, + maxAmount, + } = order; + + // Only support tokens, not native coins + if (citreaAsset.type !== AssetType.TOKEN) { + throw new OrderNotProcessableException('LayerZero bridge only supports TOKEN type assets'); + } + + // Find adapter address + const baseTokenName = this.getBaseTokenName(citreaAsset.name); + const oftAdapter = LAYERZERO_OFT_ADAPTERS[baseTokenName]; + if (!oftAdapter) { + throw new OrderNotProcessableException( + `LayerZero bridge not configured for token: ${citreaAsset.name} (base: ${baseTokenName})`, + ); + } + + // Find the corresponding Ethereum asset + const ethereumAsset = await this.assetService.getAssetByQuery({ + name: baseTokenName, + type: AssetType.TOKEN, + blockchain: Blockchain.ETHEREUM, + }); + + if (!ethereumAsset) { + throw new OrderNotProcessableException(`Could not find Ethereum asset for ${baseTokenName}`); + } + + // Check Ethereum balance + const ethereumBalance = await this.ethereumClient.getTokenBalance(ethereumAsset); + if (ethereumBalance < minAmount) { + throw new OrderNotProcessableException( + `Not enough ${baseTokenName} on Ethereum (balance: ${ethereumBalance}, min. requested: ${minAmount}, max. requested: ${maxAmount})`, + ); + } + + const amount = Math.min(maxAmount, ethereumBalance); + const amountWei = EvmUtil.toWeiAmount(amount, ethereumAsset.decimals); + + // Update order + order.inputAmount = amount; + order.inputAsset = ethereumAsset.name; + order.outputAsset = citreaAsset.name; + + // Execute the bridge transaction + return this.executeBridge(ethereumAsset, oftAdapter.ethereum, amountWei); + } + + /** + * Execute the LayerZero bridge transaction + */ + private async executeBridge( + ethereumAsset: Asset, + oftAdapterAddress: string, + amountWei: ethers.BigNumber, + ): Promise { + const wallet = this.ethereumClient.wallet; + const recipientAddress = this.citreaClient.walletAddress; + + // Create OFT adapter contract instance + const oftAdapter = new ethers.Contract(oftAdapterAddress, LAYERZERO_OFT_ADAPTER_ABI, wallet); + + // Check if approval is required and handle it + await this.ensureTokenApproval(ethereumAsset, oftAdapterAddress, amountWei, oftAdapter); + + // Prepare send parameters + // Convert recipient address to bytes32 format (left-padded with zeros) + const recipientBytes32 = ethers.utils.hexZeroPad(recipientAddress, 32); + + const sendParam = { + dstEid: CITREA_LZ_ENDPOINT_ID, + to: recipientBytes32, + amountLD: amountWei, + minAmountLD: amountWei.mul(99).div(100), // 1% slippage tolerance + extraOptions: '0x', // No extra options + composeMsg: '0x', // No compose message + oftCmd: '0x', // No OFT command + }; + + // Get quote for LayerZero fees + const messagingFee = await oftAdapter.quoteSend(sendParam, false); + const nativeFee = messagingFee.nativeFee; + const nativeFeeEth = EvmUtil.fromWeiAmount(nativeFee.toString()); + + // Verify sufficient ETH balance for LayerZero fee + gas + const ethBalance = await this.ethereumClient.getNativeCoinBalance(); + const estimatedGasCost = 0.05; // Conservative estimate for gas costs + const requiredEth = nativeFeeEth + estimatedGasCost; + + if (ethBalance < requiredEth) { + throw new OrderNotProcessableException( + `Insufficient ETH for LayerZero fee (balance: ${ethBalance} ETH, required: ~${requiredEth} ETH)`, + ); + } + + // Execute the send transaction + const sendTx = await oftAdapter.send(sendParam, { nativeFee, lzTokenFee: 0 }, wallet.address, { + value: nativeFee, + gasLimit: 500000, // Set a reasonable gas limit for OFT transfers + }); + + return sendTx.hash; + } + + /** + * Ensure token approval for the OFT adapter + */ + private async ensureTokenApproval( + ethereumAsset: Asset, + oftAdapterAddress: string, + amountWei: ethers.BigNumber, + oftAdapter: ethers.Contract, + ): Promise { + const approvalRequired = await oftAdapter.approvalRequired(); + if (!approvalRequired) return; + + await this.ethereumClient.checkAndApproveContract(ethereumAsset, oftAdapterAddress, amountWei); + } + + /** + * Extract base token name from bridged token name + * e.g., "USDC.e" -> "USDC", "USDT.e" -> "USDT", "WBTC.e" -> "WBTC" + */ + private getBaseTokenName(tokenName: string): string { + // Remove common bridge suffixes + return tokenName.replace(/\.e$/i, '').replace(/\.b$/i, ''); + } +} diff --git a/src/subdomains/core/liquidity-management/enums/index.ts b/src/subdomains/core/liquidity-management/enums/index.ts index b2005fc968..82befa642f 100644 --- a/src/subdomains/core/liquidity-management/enums/index.ts +++ b/src/subdomains/core/liquidity-management/enums/index.ts @@ -15,6 +15,7 @@ export enum LiquidityManagementSystem { OPTIMISM_L2_BRIDGE = 'OptimismL2Bridge', POLYGON_L2_BRIDGE = 'PolygonL2Bridge', BASE_L2_BRIDGE = 'BaseL2Bridge', + LAYERZERO_BRIDGE = 'LayerZeroBridge', LIQUIDITY_PIPELINE = 'LiquidityPipeline', FRANKENCOIN = 'Frankencoin', DEURO = 'dEURO', @@ -66,4 +67,5 @@ export const LiquidityManagementBridges = [ LiquidityManagementSystem.POLYGON_L2_BRIDGE, LiquidityManagementSystem.ARBITRUM_L2_BRIDGE, LiquidityManagementSystem.OPTIMISM_L2_BRIDGE, + LiquidityManagementSystem.LAYERZERO_BRIDGE, ]; diff --git a/src/subdomains/core/liquidity-management/factories/liquidity-action-integration.factory.ts b/src/subdomains/core/liquidity-management/factories/liquidity-action-integration.factory.ts index 097372ecfd..403d43b1ba 100644 --- a/src/subdomains/core/liquidity-management/factories/liquidity-action-integration.factory.ts +++ b/src/subdomains/core/liquidity-management/factories/liquidity-action-integration.factory.ts @@ -7,6 +7,7 @@ import { DfxDexAdapter } from '../adapters/actions/dfx-dex.adapter'; import { FrankencoinAdapter } from '../adapters/actions/frankencoin.adapter'; import { JuiceAdapter } from '../adapters/actions/juice.adapter'; import { KrakenAdapter } from '../adapters/actions/kraken.adapter'; +import { LayerZeroBridgeAdapter } from '../adapters/actions/layerzero-bridge.adapter'; import { LiquidityPipelineAdapter } from '../adapters/actions/liquidity-pipeline.adapter'; import { MexcAdapter } from '../adapters/actions/mexc.adapter'; import { OptimismL2BridgeAdapter } from '../adapters/actions/optimism-l2-bridge.adapter'; @@ -27,6 +28,7 @@ export class LiquidityActionIntegrationFactory { readonly optimismL2BridgeAdapter: OptimismL2BridgeAdapter, readonly polygonL2BridgeAdapter: PolygonL2BridgeAdapter, readonly baseL2BridgeAdapter: BaseL2BridgeAdapter, + readonly layerZeroBridgeAdapter: LayerZeroBridgeAdapter, readonly krakenAdapter: KrakenAdapter, readonly binanceAdapter: BinanceAdapter, readonly mexcAdapter: MexcAdapter, @@ -42,6 +44,7 @@ export class LiquidityActionIntegrationFactory { this.adapters.set(LiquidityManagementSystem.OPTIMISM_L2_BRIDGE, optimismL2BridgeAdapter); this.adapters.set(LiquidityManagementSystem.POLYGON_L2_BRIDGE, polygonL2BridgeAdapter); this.adapters.set(LiquidityManagementSystem.BASE_L2_BRIDGE, baseL2BridgeAdapter); + this.adapters.set(LiquidityManagementSystem.LAYERZERO_BRIDGE, layerZeroBridgeAdapter); this.adapters.set(LiquidityManagementSystem.KRAKEN, krakenAdapter); this.adapters.set(LiquidityManagementSystem.BINANCE, binanceAdapter); this.adapters.set(LiquidityManagementSystem.MEXC, mexcAdapter); diff --git a/src/subdomains/core/liquidity-management/liquidity-management.module.ts b/src/subdomains/core/liquidity-management/liquidity-management.module.ts index e9f3043d6b..eddec14c15 100644 --- a/src/subdomains/core/liquidity-management/liquidity-management.module.ts +++ b/src/subdomains/core/liquidity-management/liquidity-management.module.ts @@ -13,6 +13,7 @@ import { PricingModule } from 'src/subdomains/supporting/pricing/pricing.module' import { ArbitrumL2BridgeAdapter } from './adapters/actions/arbitrum-l2-bridge.adapter'; import { BaseL2BridgeAdapter } from './adapters/actions/base-l2-bridge.adapter'; import { BinanceAdapter } from './adapters/actions/binance.adapter'; +import { LayerZeroBridgeAdapter } from './adapters/actions/layerzero-bridge.adapter'; import { DEuroAdapter } from './adapters/actions/deuro.adapter'; import { DfxDexAdapter } from './adapters/actions/dfx-dex.adapter'; import { FrankencoinAdapter } from './adapters/actions/frankencoin.adapter'; @@ -96,6 +97,7 @@ import { LiquidityManagementService } from './services/liquidity-management.serv OptimismL2BridgeAdapter, PolygonL2BridgeAdapter, BaseL2BridgeAdapter, + LayerZeroBridgeAdapter, BinanceAdapter, MexcAdapter, ScryptAdapter,