From f0407a32361125e5bee4693729c7a73beced43c8 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 27 Jan 2026 02:10:09 +0100 Subject: [PATCH 1/8] feat(liquidity): add LayerZero bridge adapter for Ethereum->Citrea bridging Add support for bridging USDC, USDT, and WBTC from Ethereum to Citrea using LayerZero OFT (Omnichain Fungible Token) protocol. - Add LAYERZERO_BRIDGE to LiquidityManagementSystem enum - Create LayerZeroBridgeAdapter with deposit command - Add LayerZero OFT adapter ABI (quoteSend, send, approvalRequired) - Configure OFT adapter addresses for USDC, USDT, WBTC - Support token approval and fee quoting --- .../evm/abi/layerzero-oft-adapter.abi.json | 109 ++++++++ .../actions/layerzero-bridge.adapter.ts | 251 ++++++++++++++++++ .../core/liquidity-management/enums/index.ts | 2 + .../liquidity-action-integration.factory.ts | 3 + .../liquidity-management.module.ts | 2 + 5 files changed, 367 insertions(+) create mode 100644 src/integration/blockchain/shared/evm/abi/layerzero-oft-adapter.abi.json create mode 100644 src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts 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/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..efd6bfc4bc --- /dev/null +++ b/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts @@ -0,0 +1,251 @@ +import { Injectable } from '@nestjs/common'; +import { ethers } from 'ethers'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { EthereumClient } from 'src/integration/blockchain/ethereum/ethereum-client'; +import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service'; +import { CitreaClient } from 'src/integration/blockchain/citrea/citrea-client'; +import { CitreaService } from 'src/integration/blockchain/citrea/citrea.service'; +import ERC20_ABI from 'src/integration/blockchain/shared/evm/abi/erc20.abi.json'; +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 { DfxLogger } from 'src/shared/services/dfx-logger'; +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; + +enum LayerZeroBridgeCommands { + DEPOSIT = 'deposit', // Ethereum -> Citrea +} + +@Injectable() +export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { + private readonly logger = new DfxLogger(LayerZeroBridgeAdapter); + + protected commands = new Map(); + + private readonly ethereumClient: EthereumClient; + private readonly citreaClient: CitreaClient; + + constructor( + private readonly ethereumService: EthereumService, + private readonly 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 { + // Check if the tokens have arrived on Citrea by comparing balances + // The correlationId contains the Ethereum TX hash + const txReceipt = await this.ethereumClient.getTxReceipt(order.correlationId); + + if (!txReceipt || txReceipt.status !== 1) { + return false; + } + + // For LayerZero, we check if the destination chain has received the tokens + // by verifying the balance increased on Citrea + // This is a simplified check - in production you might want to use LayerZero's message tracking API + const citreaBalance = await this.citreaClient.getTokenBalance(asset); + + // If we have balance on Citrea, consider it complete + // A more robust solution would track the LayerZero message GUID + return citreaBalance > 0; + } catch (e) { + this.logger.warn(`LayerZero checkCompletion failed: ${e.message}`); + return false; + } + } + + validateParams(command: string, params: Record): boolean { + // LayerZero bridge doesn't require additional params + return command === LayerZeroBridgeCommands.DEPOSIT && Object.keys(params).length === 0; + } + + //*** 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'); + } + + // Get the base token name (e.g., "USDC.e" -> "USDC", "USDT.e" -> "USDT") + const baseTokenName = this.getBaseTokenName(citreaAsset.name); + + // Get OFT adapter addresses + 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: ${minAmount})`, + ); + } + + const amount = Math.min(maxAmount, ethereumBalance); + const amountWei = EvmUtil.toWeiAmount(amount, ethereumAsset.decimals); + + // Set order amounts + order.inputAmount = amount; + order.inputAsset = ethereumAsset.name; + order.outputAmount = amount; + order.outputAsset = citreaAsset.name; + + this.logger.info( + `LayerZero bridge: ${amount} ${baseTokenName} from Ethereum to Citrea (adapter: ${oftAdapter.ethereum})`, + ); + + // 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 + const approvalRequired = await oftAdapter.approvalRequired(); + if (approvalRequired) { + // Approve the OFT adapter to spend tokens + const tokenContract = new ethers.Contract(ethereumAsset.chainId, ERC20_ABI, wallet); + const currentAllowance = await tokenContract.allowance(wallet.address, oftAdapterAddress); + + if (currentAllowance.lt(amountWei)) { + this.logger.info(`Approving ${oftAdapterAddress} to spend ${ethereumAsset.name}`); + const approveTx = await tokenContract.approve(oftAdapterAddress, ethers.constants.MaxUint256); + await approveTx.wait(); + } + } + + // 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; + + this.logger.info(`LayerZero fee: ${EvmUtil.fromWeiAmount(nativeFee.toString())} 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 + }); + + this.logger.info(`LayerZero bridge TX submitted: ${sendTx.hash}`); + + // Wait for confirmation + const receipt = await sendTx.wait(); + + if (receipt.status !== 1) { + throw new OrderFailedException('LayerZero bridge transaction failed'); + } + + return sendTx.hash; + } + + /** + * 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, From f2eb61dcf7deac91353c7d04ee6770699ff8d020 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 27 Jan 2026 02:14:00 +0100 Subject: [PATCH 2/8] fix(liquidity): improve LayerZero adapter robustness - Fix validateParams to handle undefined params - Fix quoteSend return value (returns tuple, not array) - Improve checkCompletion with better error handling and logging - Add output amount verification in completion check --- .../actions/layerzero-bridge.adapter.ts | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) 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 index efd6bfc4bc..db1d3e3d9f 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts @@ -74,6 +74,7 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { pipeline: { rule: { target: asset }, }, + outputAmount, } = order; if (!isAsset(asset)) { @@ -81,31 +82,45 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { } try { - // Check if the tokens have arrived on Citrea by comparing balances + // Step 1: Verify the Ethereum transaction succeeded // The correlationId contains the Ethereum TX hash const txReceipt = await this.ethereumClient.getTxReceipt(order.correlationId); - if (!txReceipt || txReceipt.status !== 1) { + if (!txReceipt) { + this.logger.verbose(`LayerZero TX not found: ${order.correlationId}`); return false; } - // For LayerZero, we check if the destination chain has received the tokens - // by verifying the balance increased on Citrea - // This is a simplified check - in production you might want to use LayerZero's message tracking API + if (txReceipt.status !== 1) { + this.logger.warn(`LayerZero TX failed on Ethereum: ${order.correlationId}`); + return false; + } + + // Step 2: Check if the tokens have arrived on Citrea + // Note: LayerZero message finality typically takes 2-5 minutes + // A more robust solution would use LayerZero Scan API to track the message GUID const citreaBalance = await this.citreaClient.getTokenBalance(asset); - // If we have balance on Citrea, consider it complete - // A more robust solution would track the LayerZero message GUID - return citreaBalance > 0; + // Verify we have at least the expected output amount on Citrea + // This is a heuristic check - if wallet had pre-existing balance, this may return true early + const hasExpectedBalance = citreaBalance >= (outputAmount ?? 0); + + if (hasExpectedBalance) { + this.logger.info( + `LayerZero bridge complete: ${order.correlationId}, balance: ${citreaBalance} ${asset.name}`, + ); + } + + return hasExpectedBalance; } catch (e) { - this.logger.warn(`LayerZero checkCompletion failed: ${e.message}`); + this.logger.warn(`LayerZero checkCompletion failed for ${order.correlationId}: ${e.message}`); return false; } } validateParams(command: string, params: Record): boolean { // LayerZero bridge doesn't require additional params - return command === LayerZeroBridgeCommands.DEPOSIT && Object.keys(params).length === 0; + return command === LayerZeroBridgeCommands.DEPOSIT && (!params || Object.keys(params).length === 0); } //*** COMMANDS IMPLEMENTATIONS ***// @@ -217,7 +232,7 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { }; // Get quote for LayerZero fees - const [messagingFee] = await oftAdapter.quoteSend(sendParam, false); + const messagingFee = await oftAdapter.quoteSend(sendParam, false); const nativeFee = messagingFee.nativeFee; this.logger.info(`LayerZero fee: ${EvmUtil.fromWeiAmount(nativeFee.toString())} ETH`); From c6d5cea041a53ac5b28a73c1574bdb55043c49b8 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 27 Jan 2026 02:22:37 +0100 Subject: [PATCH 3/8] style: fix prettier formatting --- .../adapters/actions/layerzero-bridge.adapter.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index db1d3e3d9f..6261914bb9 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts @@ -106,9 +106,7 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { const hasExpectedBalance = citreaBalance >= (outputAmount ?? 0); if (hasExpectedBalance) { - this.logger.info( - `LayerZero bridge complete: ${order.correlationId}, balance: ${citreaBalance} ${asset.name}`, - ); + this.logger.info(`LayerZero bridge complete: ${order.correlationId}, balance: ${citreaBalance} ${asset.name}`); } return hasExpectedBalance; From 03b3ae149c74a9d1b24ab262b523b302920de123 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 27 Jan 2026 02:25:32 +0100 Subject: [PATCH 4/8] refactor(liquidity): align LayerZero adapter with codebase conventions - Export LayerZeroBridgeCommands enum (consistent with other adapters) - Use switch statement in validateParams (consistent with DfxDexAdapter) - Throw error for unknown commands (consistent with other adapters) --- .../adapters/actions/layerzero-bridge.adapter.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 index 6261914bb9..a2b1b25cc2 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts @@ -43,7 +43,7 @@ const LAYERZERO_OFT_ADAPTERS: Record Citrea } @@ -117,8 +117,14 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { } validateParams(command: string, params: Record): boolean { - // LayerZero bridge doesn't require additional params - return command === LayerZeroBridgeCommands.DEPOSIT && (!params || Object.keys(params).length === 0); + switch (command) { + case LayerZeroBridgeCommands.DEPOSIT: + // LayerZero bridge doesn't require additional params + return !params || Object.keys(params).length === 0; + + default: + throw new Error(`Command ${command} not supported by LayerZeroBridgeAdapter`); + } } //*** COMMANDS IMPLEMENTATIONS ***// From 7e63b692ff307f89bbb356d9583f9304473aca9b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 27 Jan 2026 02:28:22 +0100 Subject: [PATCH 5/8] fix(liquidity): improve LayerZero adapter professionalism - Throw OrderFailedException for failed Ethereum TX (not just return false) - Add ETH balance check before bridge execution - Extract token approval into separate method with proper error handling - Re-throw OrderFailedException in checkCompletion (don't swallow it) - Add logging for approval confirmation - Add detailed error messages for insufficient funds --- .../actions/layerzero-bridge.adapter.ts | 71 ++++++++++++++----- 1 file changed, 54 insertions(+), 17 deletions(-) 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 index a2b1b25cc2..49c89f2336 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts @@ -92,8 +92,7 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { } if (txReceipt.status !== 1) { - this.logger.warn(`LayerZero TX failed on Ethereum: ${order.correlationId}`); - return false; + throw new OrderFailedException(`LayerZero TX failed on Ethereum: ${order.correlationId}`); } // Step 2: Check if the tokens have arrived on Citrea @@ -111,6 +110,9 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { return hasExpectedBalance; } catch (e) { + if (e instanceof OrderFailedException) { + throw e; + } this.logger.warn(`LayerZero checkCompletion failed for ${order.correlationId}: ${e.message}`); return false; } @@ -207,19 +209,8 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { // Create OFT adapter contract instance const oftAdapter = new ethers.Contract(oftAdapterAddress, LAYERZERO_OFT_ADAPTER_ABI, wallet); - // Check if approval is required - const approvalRequired = await oftAdapter.approvalRequired(); - if (approvalRequired) { - // Approve the OFT adapter to spend tokens - const tokenContract = new ethers.Contract(ethereumAsset.chainId, ERC20_ABI, wallet); - const currentAllowance = await tokenContract.allowance(wallet.address, oftAdapterAddress); - - if (currentAllowance.lt(amountWei)) { - this.logger.info(`Approving ${oftAdapterAddress} to spend ${ethereumAsset.name}`); - const approveTx = await tokenContract.approve(oftAdapterAddress, ethers.constants.MaxUint256); - await approveTx.wait(); - } - } + // Check if approval is required and handle it + await this.ensureTokenApproval(ethereumAsset, oftAdapterAddress, amountWei, oftAdapter, wallet); // Prepare send parameters // Convert recipient address to bytes32 format (left-padded with zeros) @@ -238,13 +229,25 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { // 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)`, + ); + } - this.logger.info(`LayerZero fee: ${EvmUtil.fromWeiAmount(nativeFee.toString())} ETH`); + this.logger.info(`LayerZero fee: ${nativeFeeEth} 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 + gasLimit: 500000, // Set a reasonable gas limit for OFT transfers }); this.logger.info(`LayerZero bridge TX submitted: ${sendTx.hash}`); @@ -259,6 +262,40 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { return sendTx.hash; } + /** + * Ensure token approval for the OFT adapter + */ + private async ensureTokenApproval( + ethereumAsset: Asset, + oftAdapterAddress: string, + amountWei: ethers.BigNumber, + oftAdapter: ethers.Contract, + wallet: ethers.Wallet, + ): Promise { + const approvalRequired = await oftAdapter.approvalRequired(); + if (!approvalRequired) return; + + const tokenContract = new ethers.Contract(ethereumAsset.chainId, ERC20_ABI, wallet); + const currentAllowance = await tokenContract.allowance(wallet.address, oftAdapterAddress); + + if (currentAllowance.gte(amountWei)) return; + + this.logger.info(`Approving ${oftAdapterAddress} to spend ${ethereumAsset.name}`); + + try { + const approveTx = await tokenContract.approve(oftAdapterAddress, ethers.constants.MaxUint256); + const approveReceipt = await approveTx.wait(); + + if (approveReceipt.status !== 1) { + throw new OrderFailedException(`Token approval failed for ${ethereumAsset.name}`); + } + + this.logger.info(`Approval confirmed for ${ethereumAsset.name}`); + } catch (e) { + throw new OrderFailedException(`Token approval failed: ${e.message}`); + } + } + /** * Extract base token name from bridged token name * e.g., "USDC.e" -> "USDC", "USDT.e" -> "USDT", "WBTC.e" -> "WBTC" From befab439ef6300b50219d646930cc14025411ebc Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 27 Jan 2026 03:30:16 +0100 Subject: [PATCH 6/8] refactor(liquidity): align LayerZero adapter with existing bridge patterns - Remove unnecessary service storage (only store clients, like ArbitrumL2BridgeAdapter) - Align checkCompletion error handling with EvmL2BridgeAdapter (throw OrderFailedException) - Simplify validateParams to match EvmL2BridgeAdapter pattern (return true) --- .../actions/layerzero-bridge.adapter.ts | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) 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 index 49c89f2336..044e5c179d 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts @@ -57,8 +57,8 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { private readonly citreaClient: CitreaClient; constructor( - private readonly ethereumService: EthereumService, - private readonly citreaService: CitreaService, + ethereumService: EthereumService, + citreaService: CitreaService, private readonly assetService: AssetService, ) { super(LiquidityManagementSystem.LAYERZERO_BRIDGE); @@ -110,23 +110,13 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { return hasExpectedBalance; } catch (e) { - if (e instanceof OrderFailedException) { - throw e; - } - this.logger.warn(`LayerZero checkCompletion failed for ${order.correlationId}: ${e.message}`); - return false; + throw e instanceof OrderFailedException ? e : new OrderFailedException(e.message); } } - validateParams(command: string, params: Record): boolean { - switch (command) { - case LayerZeroBridgeCommands.DEPOSIT: - // LayerZero bridge doesn't require additional params - return !params || Object.keys(params).length === 0; - - default: - throw new Error(`Command ${command} not supported by LayerZeroBridgeAdapter`); - } + validateParams(_command: string, _params: Record): boolean { + // LayerZero bridge doesn't require additional params + return true; } //*** COMMANDS IMPLEMENTATIONS ***// From 643544a4f9d4024abe01ba99454b53de591a81f6 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 27 Jan 2026 03:51:01 +0100 Subject: [PATCH 7/8] fix(liquidity): prevent double-wrapping OrderFailedException in approval --- .../adapters/actions/layerzero-bridge.adapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 044e5c179d..9db3a4fb64 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts @@ -282,7 +282,7 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { this.logger.info(`Approval confirmed for ${ethereumAsset.name}`); } catch (e) { - throw new OrderFailedException(`Token approval failed: ${e.message}`); + throw e instanceof OrderFailedException ? e : new OrderFailedException(`Token approval failed: ${e.message}`); } } From 027dc5825b30cda9573c48b8fe8fc96fd6a725d1 Mon Sep 17 00:00:00 2001 From: David May Date: Thu, 29 Jan 2026 12:24:34 +0100 Subject: [PATCH 8/8] chore: refactoring/fixes --- .../blockchain/shared/evm/evm-client.ts | 13 ++- .../actions/layerzero-bridge.adapter.ts | 98 +++++++------------ 2 files changed, 47 insertions(+), 64 deletions(-) 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 index 9db3a4fb64..4fdd7d8988 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/layerzero-bridge.adapter.ts @@ -1,17 +1,15 @@ import { Injectable } from '@nestjs/common'; import { ethers } from 'ethers'; -import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; -import { EthereumClient } from 'src/integration/blockchain/ethereum/ethereum-client'; -import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service'; import { CitreaClient } from 'src/integration/blockchain/citrea/citrea-client'; import { CitreaService } from 'src/integration/blockchain/citrea/citrea.service'; -import ERC20_ABI from 'src/integration/blockchain/shared/evm/abi/erc20.abi.json'; +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 { DfxLogger } from 'src/shared/services/dfx-logger'; import { LiquidityManagementOrder } from '../../entities/liquidity-management-order.entity'; import { LiquidityManagementSystem } from '../../enums'; import { OrderFailedException } from '../../exceptions/order-failed.exception'; @@ -49,8 +47,6 @@ export enum LayerZeroBridgeCommands { @Injectable() export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { - private readonly logger = new DfxLogger(LayerZeroBridgeAdapter); - protected commands = new Map(); private readonly ethereumClient: EthereumClient; @@ -74,7 +70,6 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { pipeline: { rule: { target: asset }, }, - outputAmount, } = order; if (!isAsset(asset)) { @@ -83,11 +78,9 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { try { // Step 1: Verify the Ethereum transaction succeeded - // The correlationId contains the Ethereum TX hash const txReceipt = await this.ethereumClient.getTxReceipt(order.correlationId); if (!txReceipt) { - this.logger.verbose(`LayerZero TX not found: ${order.correlationId}`); return false; } @@ -95,20 +88,36 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { throw new OrderFailedException(`LayerZero TX failed on Ethereum: ${order.correlationId}`); } - // Step 2: Check if the tokens have arrived on Citrea - // Note: LayerZero message finality typically takes 2-5 minutes - // A more robust solution would use LayerZero Scan API to track the message GUID - const citreaBalance = await this.citreaClient.getTokenBalance(asset); - - // Verify we have at least the expected output amount on Citrea - // This is a heuristic check - if wallet had pre-existing balance, this may return true early - const hasExpectedBalance = citreaBalance >= (outputAmount ?? 0); + // 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}`); + } - if (hasExpectedBalance) { - this.logger.info(`LayerZero bridge complete: ${order.correlationId}, balance: ${citreaBalance} ${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 hasExpectedBalance; + return false; } catch (e) { throw e instanceof OrderFailedException ? e : new OrderFailedException(e.message); } @@ -138,10 +147,8 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { throw new OrderNotProcessableException('LayerZero bridge only supports TOKEN type assets'); } - // Get the base token name (e.g., "USDC.e" -> "USDC", "USDT.e" -> "USDT") + // Find adapter address const baseTokenName = this.getBaseTokenName(citreaAsset.name); - - // Get OFT adapter addresses const oftAdapter = LAYERZERO_OFT_ADAPTERS[baseTokenName]; if (!oftAdapter) { throw new OrderNotProcessableException( @@ -164,23 +171,18 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { const ethereumBalance = await this.ethereumClient.getTokenBalance(ethereumAsset); if (ethereumBalance < minAmount) { throw new OrderNotProcessableException( - `Not enough ${baseTokenName} on Ethereum (balance: ${ethereumBalance}, min: ${minAmount})`, + `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); - // Set order amounts + // Update order order.inputAmount = amount; order.inputAsset = ethereumAsset.name; - order.outputAmount = amount; order.outputAsset = citreaAsset.name; - this.logger.info( - `LayerZero bridge: ${amount} ${baseTokenName} from Ethereum to Citrea (adapter: ${oftAdapter.ethereum})`, - ); - // Execute the bridge transaction return this.executeBridge(ethereumAsset, oftAdapter.ethereum, amountWei); } @@ -200,7 +202,7 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { 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, wallet); + await this.ensureTokenApproval(ethereumAsset, oftAdapterAddress, amountWei, oftAdapter); // Prepare send parameters // Convert recipient address to bytes32 format (left-padded with zeros) @@ -232,23 +234,12 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { ); } - this.logger.info(`LayerZero fee: ${nativeFeeEth} 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 }); - this.logger.info(`LayerZero bridge TX submitted: ${sendTx.hash}`); - - // Wait for confirmation - const receipt = await sendTx.wait(); - - if (receipt.status !== 1) { - throw new OrderFailedException('LayerZero bridge transaction failed'); - } - return sendTx.hash; } @@ -260,30 +251,11 @@ export class LayerZeroBridgeAdapter extends LiquidityActionAdapter { oftAdapterAddress: string, amountWei: ethers.BigNumber, oftAdapter: ethers.Contract, - wallet: ethers.Wallet, ): Promise { const approvalRequired = await oftAdapter.approvalRequired(); if (!approvalRequired) return; - const tokenContract = new ethers.Contract(ethereumAsset.chainId, ERC20_ABI, wallet); - const currentAllowance = await tokenContract.allowance(wallet.address, oftAdapterAddress); - - if (currentAllowance.gte(amountWei)) return; - - this.logger.info(`Approving ${oftAdapterAddress} to spend ${ethereumAsset.name}`); - - try { - const approveTx = await tokenContract.approve(oftAdapterAddress, ethers.constants.MaxUint256); - const approveReceipt = await approveTx.wait(); - - if (approveReceipt.status !== 1) { - throw new OrderFailedException(`Token approval failed for ${ethereumAsset.name}`); - } - - this.logger.info(`Approval confirmed for ${ethereumAsset.name}`); - } catch (e) { - throw e instanceof OrderFailedException ? e : new OrderFailedException(`Token approval failed: ${e.message}`); - } + await this.ethereumClient.checkAndApproveContract(ethereumAsset, oftAdapterAddress, amountWei); } /**