From 82fb20082c2e3437194a7d3975ce48668c874bcc Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:58:41 +0100 Subject: [PATCH 1/4] [DEV-4536] notification mail for older unassignedTx (#2970) --- .../bank-tx/services/bank-tx.service.ts | 5 ++ .../transaction-notification.service.ts | 74 ++++++++++--------- .../supporting/payment/transaction.module.ts | 2 +- 3 files changed, 46 insertions(+), 35 deletions(-) diff --git a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts index 09b0b720b3..72df8ba579 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts @@ -25,6 +25,7 @@ import { IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto'; import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; import { SpecialExternalAccount } from 'src/subdomains/supporting/payment/entities/special-external-account.entity'; +import { TransactionNotificationService } from 'src/subdomains/supporting/payment/services/transaction-notification.service'; import { DeepPartial, FindOptionsRelations, @@ -108,6 +109,8 @@ export class BankTxService implements OnModuleInit { private readonly sepaParser: SepaParser, private readonly bankDataService: BankDataService, private readonly virtualIbanService: VirtualIbanService, + @Inject(forwardRef(() => TransactionNotificationService)) + private readonly transactionNotificationService: TransactionNotificationService, ) {} onModuleInit() { @@ -566,6 +569,8 @@ export class BankTxService implements OnModuleInit { if (bankTx.transaction.userData) continue; await this.transactionService.updateInternal(bankTx.transaction, { userData }); + + await this.transactionNotificationService.sendUnassignedTxMail(bankTx.transaction, userData); } } diff --git a/src/subdomains/supporting/payment/services/transaction-notification.service.ts b/src/subdomains/supporting/payment/services/transaction-notification.service.ts index 586b37569b..5c92b7f2b1 100644 --- a/src/subdomains/supporting/payment/services/transaction-notification.service.ts +++ b/src/subdomains/supporting/payment/services/transaction-notification.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { DisabledProcess, Process } from 'src/shared/services/process.service'; @@ -6,13 +6,14 @@ import { DfxCron } from 'src/shared/utils/cron'; import { Util } from 'src/shared/utils/util'; import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { In, IsNull, MoreThan } from 'typeorm'; import { BankTxIndicator, BankTxUnassignedTypes } from '../../bank-tx/bank-tx/entities/bank-tx.entity'; import { BankTxService } from '../../bank-tx/bank-tx/services/bank-tx.service'; import { MailContext, MailType } from '../../notification/enums'; import { MailKey, MailTranslationKey } from '../../notification/factories/mail.factory'; import { NotificationService } from '../../notification/services/notification.service'; -import { TransactionTypeInternal } from '../entities/transaction.entity'; +import { Transaction, TransactionTypeInternal } from '../entities/transaction.entity'; import { TransactionRepository } from '../repositories/transaction.repository'; @Injectable() @@ -22,6 +23,7 @@ export class TransactionNotificationService { constructor( private readonly repo: TransactionRepository, private readonly notificationService: NotificationService, + @Inject(forwardRef(() => BankTxService)) private readonly bankTxService: BankTxService, ) {} @@ -120,41 +122,45 @@ export class TransactionNotificationService { if (entities.length === 0) return; for (const entity of entities) { - try { - const userData = await this.bankTxService.getUserDataForBankTx(entity.bankTx); - if (!userData) continue; + const userData = await this.bankTxService.getUserDataForBankTx(entity.bankTx); + if (!userData) continue; - if (userData.mail) { - await this.notificationService.sendMail({ - type: MailType.USER_V2, - context: MailContext.UNASSIGNED_TX, - input: { - userData, - wallet: userData.wallet, - title: `${MailTranslationKey.UNASSIGNED_FIAT_INPUT}.title`, - salutation: { key: `${MailTranslationKey.UNASSIGNED_FIAT_INPUT}.salutation` }, - texts: [ - { - key: `${MailTranslationKey.UNASSIGNED_FIAT_INPUT}.transaction_button`, - params: { url: entity.url, button: 'true' }, - }, - { - key: `${MailTranslationKey.GENERAL}.link`, - params: { url: entity.url, urlText: entity.url }, - }, - { key: MailKey.SPACE, params: { value: '4' } }, - { key: MailKey.DFX_TEAM_CLOSING }, - ], - }, - }); + await this.sendUnassignedTxMail(entity, userData); + } + } - await this.repo.update(...entity.mailSent(userData)); - } else { - await this.repo.update(entity.id, { userData }); - } - } catch (e) { - this.logger.error(`Failed to send tx unassigned mail for ${entity.id}:`, e); + async sendUnassignedTxMail(entity: Transaction, userData: UserData) { + try { + if (userData.mail) { + await this.notificationService.sendMail({ + type: MailType.USER_V2, + context: MailContext.UNASSIGNED_TX, + input: { + userData, + wallet: userData.wallet, + title: `${MailTranslationKey.UNASSIGNED_FIAT_INPUT}.title`, + salutation: { key: `${MailTranslationKey.UNASSIGNED_FIAT_INPUT}.salutation` }, + texts: [ + { + key: `${MailTranslationKey.UNASSIGNED_FIAT_INPUT}.transaction_button`, + params: { url: entity.url, button: 'true' }, + }, + { + key: `${MailTranslationKey.GENERAL}.link`, + params: { url: entity.url, urlText: entity.url }, + }, + { key: MailKey.SPACE, params: { value: '4' } }, + { key: MailKey.DFX_TEAM_CLOSING }, + ], + }, + }); + + await this.repo.update(...entity.mailSent(userData)); + } else { + await this.repo.update(entity.id, { userData }); } + } catch (e) { + this.logger.error(`Failed to send tx unassigned mail for ${entity.id}:`, e); } } } diff --git a/src/subdomains/supporting/payment/transaction.module.ts b/src/subdomains/supporting/payment/transaction.module.ts index 9b229f5ade..529d6d1437 100644 --- a/src/subdomains/supporting/payment/transaction.module.ts +++ b/src/subdomains/supporting/payment/transaction.module.ts @@ -35,6 +35,6 @@ import { TransactionService } from './services/transaction.service'; SpecialExternalAccountRepository, TransactionNotificationService, ], - exports: [TransactionService, SpecialExternalAccountService], + exports: [TransactionService, SpecialExternalAccountService, TransactionNotificationService], }) export class TransactionModule {} From 81138e0bc69887affcdbbe2a4416414d6e5445ef Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:47:57 +0100 Subject: [PATCH 2/4] [DEV-4545] dfxApproval kyc bug (#3074) * [DEV-4545] dfxApproval kyc bug * [DEV-4545] Refactoring --- src/subdomains/generic/kyc/services/kyc.service.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index d98549825a..2290bb2c05 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -391,8 +391,18 @@ export class KycService { if (approvalStep?.isOnHold) { await this.kycStepRepo.update(...approvalStep.manualReview()); } else if (!approvalStep) { - const newStep = await this.initiateStep(kycStep.userData, KycStepName.DFX_APPROVAL); - await this.kycStepRepo.update(...newStep.manualReview()); + const newStep = await this.initiateStep(kycStep.userData, KycStepName.DFX_APPROVAL).catch((e) => { + if (e.message.includes('Cannot insert duplicate key')) + return this.kycStepRepo.findOneBy({ + name: KycStepName.DFX_APPROVAL, + status: ReviewStatus.ON_HOLD, + userData: { id: kycStep.userData.id }, + }); + + throw e; + }); + + if (newStep) await this.kycStepRepo.update(...newStep.manualReview()); } } } From 27cf4742639960fa0124f7d474b321c979444bd2 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 30 Jan 2026 17:12:13 +0100 Subject: [PATCH 3/4] feat(liquidity): add LayerZero bridge adapter for Ethereum->Citrea bridging (#3054) * 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 * 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 * style: fix prettier formatting * 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) * 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 * 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) * fix(liquidity): prevent double-wrapping OrderFailedException in approval * chore: refactoring/fixes --------- Co-authored-by: David May --- .../evm/abi/layerzero-oft-adapter.abi.json | 109 +++++++ .../blockchain/shared/evm/evm-client.ts | 13 +- .../actions/layerzero-bridge.adapter.ts | 269 ++++++++++++++++++ .../core/liquidity-management/enums/index.ts | 2 + .../liquidity-action-integration.factory.ts | 3 + .../liquidity-management.module.ts | 2 + 6 files changed, 397 insertions(+), 1 deletion(-) 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/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index 23e1f47742..5c5cd775b9 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, From a8d1118f98092a9f6cb88e6e1922a601eb059915 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 31 Jan 2026 10:02:47 +0100 Subject: [PATCH 4/4] Allow kycFileId column in debug endpoint (#3082) Remove kycFileId from DebugBlockedCols to enable querying user_data.kycFileId via the debug SQL endpoint. --- src/subdomains/generic/gs/dto/gs.dto.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/subdomains/generic/gs/dto/gs.dto.ts b/src/subdomains/generic/gs/dto/gs.dto.ts index b51db8ee49..54d9bdada8 100644 --- a/src/subdomains/generic/gs/dto/gs.dto.ts +++ b/src/subdomains/generic/gs/dto/gs.dto.ts @@ -43,7 +43,6 @@ export const DebugBlockedCols: Record = { 'legalEntity', 'signatoryPower', 'kycHash', - 'kycFileId', 'apiKeyCT', 'totpSecret', 'internalAmlNote',