From 7380f98659d848f7d810ef5d315bbef5007d5e59 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:58:07 +0100 Subject: [PATCH 1/3] fix: MEXC ZCHF trade check (#3064) --- src/integration/exchange/dto/mexc.dto.ts | 14 +++++ .../exchange/services/mexc.service.ts | 52 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/integration/exchange/dto/mexc.dto.ts b/src/integration/exchange/dto/mexc.dto.ts index 3f61364fc2..71646273da 100644 --- a/src/integration/exchange/dto/mexc.dto.ts +++ b/src/integration/exchange/dto/mexc.dto.ts @@ -137,3 +137,17 @@ export interface MexcOrderResponse { side: string; transactTime: number; } + +export interface MexcOrderQueryResponse { + symbol: string; + orderId: string; + price: string; + origQty: string; + executedQty: string; + cummulativeQuoteQty: string; + status: string; + type: string; + side: string; + time: number; + updateTime: number; +} diff --git a/src/integration/exchange/services/mexc.service.ts b/src/integration/exchange/services/mexc.service.ts index d551b7fe38..d9662dcf83 100644 --- a/src/integration/exchange/services/mexc.service.ts +++ b/src/integration/exchange/services/mexc.service.ts @@ -12,6 +12,7 @@ import { MexcExchangeInfo, MexcMyTrade, MexcOrderBook, + MexcOrderQueryResponse, MexcOrderResponse, MexcSymbol, MexcTrade, @@ -303,4 +304,55 @@ export class MexcService extends ExchangeService { return { id: response.orderId } as Order; } + + async getTrade(id: string, from: string, to: string): Promise { + const pair = await this.getPair(from, to); + const symbol = pair.replace('/', ''); + + const response = await this.request('GET', 'order', { + symbol, + orderId: id, + }); + + return { + id: response.orderId, + symbol: pair, + side: response.side.toLowerCase(), + type: response.type.toLowerCase(), + status: this.mapOrderStatus(response.status), + price: parseFloat(response.price), + amount: parseFloat(response.origQty), + filled: parseFloat(response.executedQty), + remaining: parseFloat(response.origQty) - parseFloat(response.executedQty), + cost: parseFloat(response.cummulativeQuoteQty), + timestamp: response.time, + datetime: new Date(response.time).toISOString(), + } as Order; + } + + private mapOrderStatus(status: string): string { + const statusMap: Record = { + NEW: 'open', + PARTIALLY_FILLED: 'open', + FILLED: 'closed', + CANCELED: 'canceled', + PENDING_CANCEL: 'open', + REJECTED: 'canceled', + EXPIRED: 'canceled', + }; + return statusMap[status] ?? 'open'; + } + + protected async updateOrderPrice(order: Order, amount: number, price: number): Promise { + // MEXC doesn't support editOrder, so cancel and create new + await this.cancelOrderById(order.id, order.symbol); + + const newOrder = await this.createOrder(order.symbol, order.side as OrderSide, amount, price); + return newOrder.id; + } + + private async cancelOrderById(orderId: string, pair: string): Promise { + const symbol = pair.replace('/', ''); + await this.request('DELETE', 'order', { symbol, orderId }); + } } From 632fe792015927245243f6a45dd04f25062d79e1 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:58:25 +0100 Subject: [PATCH 2/3] feat: JuiceSwap gateway integration (#3059) * feat: JuiceSwap gateway integration * fix: gas price fix * chore: refactoring * chore: refactoring * chore: refactoring 2 * fix: removed unnecessary check --- src/config/chains.config.ts | 9 + .../citrea-testnet/citrea-testnet-client.ts | 138 +------ .../citrea-testnet/citrea-testnet.service.ts | 8 + .../blockchain/citrea/citrea-client.ts | 138 +------ .../blockchain/citrea/citrea.service.ts | 17 +- .../shared/evm/abi/juiceswap-gateway.abi.json | 74 ++++ .../shared/evm/citrea-base-client.ts | 360 ++++++++++++++++++ .../blockchain/shared/evm/evm-client.ts | 3 + .../core/trading/services/trading.service.ts | 8 - 9 files changed, 477 insertions(+), 278 deletions(-) create mode 100644 src/integration/blockchain/shared/evm/abi/juiceswap-gateway.abi.json create mode 100644 src/integration/blockchain/shared/evm/citrea-base-client.ts diff --git a/src/config/chains.config.ts b/src/config/chains.config.ts index d1ecbc54cd..f578f64cbb 100644 --- a/src/config/chains.config.ts +++ b/src/config/chains.config.ts @@ -4,6 +4,7 @@ export interface EvmChainStaticConfig { swapContractAddress?: string; quoteContractAddress?: string; swapFactoryAddress?: string; + swapGatewayAddress?: string; } export const EVM_CHAINS = { @@ -57,10 +58,18 @@ export const EVM_CHAINS = { citreaTestnet: { chainId: 5115, gatewayUrl: 'https://rpc.testnet.citreascan.com', + swapContractAddress: '0x26C106BC45E0dd599cbDD871605497B2Fc87c185', + swapFactoryAddress: '0xdd6Db52dB41CE2C03002bB1adFdCC8E91C594238', + quoteContractAddress: '0x719a4C7B49E5361a39Dc83c23b353CA220D9B99d', + swapGatewayAddress: '0x8eE3Dd585752805A258ad3a963949a7c3fec44eB', }, citrea: { chainId: 4114, gatewayUrl: 'https://rpc.citreascan.com', + swapContractAddress: '0x565eD3D57fe40f78A46f348C220121AE093c3cF8', + swapFactoryAddress: '0xd809b1285aDd8eeaF1B1566Bf31B2B4C4Bba8e82', + quoteContractAddress: '0x428f20dd8926Eabe19653815Ed0BE7D6c36f8425', + swapGatewayAddress: '0xAFcfD58Fe17BEb0c9D15C51D19519682dFcdaab9', }, } satisfies Record; diff --git a/src/integration/blockchain/citrea-testnet/citrea-testnet-client.ts b/src/integration/blockchain/citrea-testnet/citrea-testnet-client.ts index 291b685c73..2ea656f926 100644 --- a/src/integration/blockchain/citrea-testnet/citrea-testnet-client.ts +++ b/src/integration/blockchain/citrea-testnet/citrea-testnet-client.ts @@ -1,141 +1,11 @@ -import { ethers } from 'ethers'; -import ERC20_ABI from 'src/integration/blockchain/shared/evm/abi/erc20.abi.json'; -import { - BlockscoutTokenTransfer, - BlockscoutTransaction, -} from 'src/integration/blockchain/shared/blockscout/blockscout.service'; -import { Asset } from 'src/shared/models/asset/asset.entity'; import { DfxLogger } from 'src/shared/services/dfx-logger'; -import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; -import { Direction, EvmClient, EvmClientParams } from '../shared/evm/evm-client'; -import { EvmUtil } from '../shared/evm/evm.util'; -import { EvmCoinHistoryEntry, EvmTokenHistoryEntry } from '../shared/evm/interfaces'; +import { CitreaBaseClient } from '../shared/evm/citrea-base-client'; +import { EvmClientParams } from '../shared/evm/evm-client'; -export class CitreaTestnetClient extends EvmClient { +export class CitreaTestnetClient extends CitreaBaseClient { protected override readonly logger = new DfxLogger(CitreaTestnetClient); constructor(params: EvmClientParams) { - super({ - ...params, - alchemyService: undefined, // Citrea not supported by Alchemy - }); - } - - // Alchemy method overrides - async getNativeCoinBalance(): Promise { - return this.getNativeCoinBalanceForAddress(this.walletAddress); - } - - async getNativeCoinBalanceForAddress(address: string): Promise { - const balance = await this.provider.getBalance(address); - return EvmUtil.fromWeiAmount(balance.toString()); - } - - async getTokenBalance(asset: Asset, address?: string): Promise { - const owner = address ?? this.walletAddress; - const contract = new ethers.Contract(asset.chainId, ERC20_ABI, this.provider); - - try { - const balance = await contract.balanceOf(owner); - const decimals = await contract.decimals(); - return EvmUtil.fromWeiAmount(balance.toString(), decimals); - } catch (error) { - this.logger.error(`Failed to get token balance for ${asset.chainId}:`, error); - return 0; - } - } - - async getTokenBalances(assets: Asset[], address?: string): Promise { - const owner = address ?? this.walletAddress; - const balances: BlockchainTokenBalance[] = []; - - for (const asset of assets) { - const balance = await this.getTokenBalance(asset, owner); - balances.push({ - owner, - contractAddress: asset.chainId, - balance, - }); - } - - return balances; - } - - // --- HISTORY --- // - - async getNativeCoinTransactions( - walletAddress: string, - fromBlock: number, - _toBlock?: number, - direction = Direction.BOTH, - ): Promise { - const transactions = await this.blockscoutService.getTransactions(this.blockscoutApiUrl, walletAddress, fromBlock); - return this.mapBlockscoutToEvmCoinHistory(transactions, walletAddress, direction); - } - - async getERC20Transactions( - walletAddress: string, - fromBlock: number, - _toBlock?: number, - direction = Direction.BOTH, - ): Promise { - const transfers = await this.blockscoutService.getTokenTransfers(this.blockscoutApiUrl, walletAddress, fromBlock); - return this.mapBlockscoutToEvmTokenHistory(transfers, walletAddress, direction); - } - - private mapBlockscoutToEvmCoinHistory( - transactions: BlockscoutTransaction[], - walletAddress: string, - direction: Direction, - ): EvmCoinHistoryEntry[] { - const lowerWallet = walletAddress.toLowerCase(); - - return transactions - .filter((tx) => { - if (direction === Direction.INCOMING) { - return tx.to?.hash.toLowerCase() === lowerWallet; - } else if (direction === Direction.OUTGOING) { - return tx.from.hash.toLowerCase() === lowerWallet; - } - return true; // Direction.BOTH - }) - .map((tx) => ({ - blockNumber: tx.block_number.toString(), - timeStamp: tx.timestamp, - hash: tx.hash, - from: tx.from.hash, - to: tx.to?.hash || '', - value: tx.value, - contractAddress: '', - })); - } - - private mapBlockscoutToEvmTokenHistory( - transfers: BlockscoutTokenTransfer[], - walletAddress: string, - direction: Direction, - ): EvmTokenHistoryEntry[] { - const lowerWallet = walletAddress.toLowerCase(); - - return transfers - .filter((tx) => { - if (direction === Direction.INCOMING) { - return tx.to.hash.toLowerCase() === lowerWallet; - } else if (direction === Direction.OUTGOING) { - return tx.from.hash.toLowerCase() === lowerWallet; - } - return true; // Direction.BOTH - }) - .map((tx) => ({ - blockNumber: tx.block_number.toString(), - timeStamp: tx.timestamp, - hash: tx.tx_hash, - from: tx.from.hash, - contractAddress: tx.token.address, - to: tx.to.hash, - value: tx.total.value, - tokenName: tx.token.name || tx.token.symbol, - tokenDecimal: tx.total.decimals || tx.token.decimals, - })); + super(params); } } diff --git a/src/integration/blockchain/citrea-testnet/citrea-testnet.service.ts b/src/integration/blockchain/citrea-testnet/citrea-testnet.service.ts index 19a2264526..0be35f831e 100644 --- a/src/integration/blockchain/citrea-testnet/citrea-testnet.service.ts +++ b/src/integration/blockchain/citrea-testnet/citrea-testnet.service.ts @@ -14,6 +14,10 @@ export class CitreaTestnetService extends EvmService { citreaTestnetWalletPrivateKey, citreaTestnetChainId, blockscoutApiUrl, + swapContractAddress, + swapFactoryAddress, + quoteContractAddress, + swapGatewayAddress, } = GetConfig().blockchain.citreaTestnet; super(CitreaTestnetClient, { @@ -24,6 +28,10 @@ export class CitreaTestnetService extends EvmService { chainId: citreaTestnetChainId, blockscoutService, blockscoutApiUrl, + swapContractAddress, + swapFactoryAddress, + quoteContractAddress, + swapGatewayAddress, }); } } diff --git a/src/integration/blockchain/citrea/citrea-client.ts b/src/integration/blockchain/citrea/citrea-client.ts index 824b3a6a68..ee2fa3f2e1 100644 --- a/src/integration/blockchain/citrea/citrea-client.ts +++ b/src/integration/blockchain/citrea/citrea-client.ts @@ -1,141 +1,11 @@ -import { ethers } from 'ethers'; -import { - BlockscoutTokenTransfer, - BlockscoutTransaction, -} from 'src/integration/blockchain/shared/blockscout/blockscout.service'; -import ERC20_ABI from 'src/integration/blockchain/shared/evm/abi/erc20.abi.json'; -import { Asset } from 'src/shared/models/asset/asset.entity'; import { DfxLogger } from 'src/shared/services/dfx-logger'; -import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; -import { Direction, EvmClient, EvmClientParams } from '../shared/evm/evm-client'; -import { EvmUtil } from '../shared/evm/evm.util'; -import { EvmCoinHistoryEntry, EvmTokenHistoryEntry } from '../shared/evm/interfaces'; +import { CitreaBaseClient } from '../shared/evm/citrea-base-client'; +import { EvmClientParams } from '../shared/evm/evm-client'; -export class CitreaClient extends EvmClient { +export class CitreaClient extends CitreaBaseClient { protected override readonly logger = new DfxLogger(CitreaClient); constructor(params: EvmClientParams) { - super({ - ...params, - alchemyService: undefined, // Citrea not supported by Alchemy - }); - } - - // Alchemy method overrides - async getNativeCoinBalance(): Promise { - return this.getNativeCoinBalanceForAddress(this.walletAddress); - } - - async getNativeCoinBalanceForAddress(address: string): Promise { - const balance = await this.provider.getBalance(address); - return EvmUtil.fromWeiAmount(balance.toString()); - } - - async getTokenBalance(asset: Asset, address?: string): Promise { - const owner = address ?? this.walletAddress; - const contract = new ethers.Contract(asset.chainId, ERC20_ABI, this.provider); - - try { - const balance = await contract.balanceOf(owner); - const decimals = await contract.decimals(); - return EvmUtil.fromWeiAmount(balance.toString(), decimals); - } catch (error) { - this.logger.error(`Failed to get token balance for ${asset.chainId}:`, error); - return 0; - } - } - - async getTokenBalances(assets: Asset[], address?: string): Promise { - const owner = address ?? this.walletAddress; - const balances: BlockchainTokenBalance[] = []; - - for (const asset of assets) { - const balance = await this.getTokenBalance(asset, owner); - balances.push({ - owner, - contractAddress: asset.chainId, - balance, - }); - } - - return balances; - } - - // --- HISTORY --- // - - async getNativeCoinTransactions( - walletAddress: string, - fromBlock: number, - _toBlock?: number, - direction = Direction.BOTH, - ): Promise { - const transactions = await this.blockscoutService.getTransactions(this.blockscoutApiUrl, walletAddress, fromBlock); - return this.mapBlockscoutToEvmCoinHistory(transactions, walletAddress, direction); - } - - async getERC20Transactions( - walletAddress: string, - fromBlock: number, - _toBlock?: number, - direction = Direction.BOTH, - ): Promise { - const transfers = await this.blockscoutService.getTokenTransfers(this.blockscoutApiUrl, walletAddress, fromBlock); - return this.mapBlockscoutToEvmTokenHistory(transfers, walletAddress, direction); - } - - private mapBlockscoutToEvmCoinHistory( - transactions: BlockscoutTransaction[], - walletAddress: string, - direction: Direction, - ): EvmCoinHistoryEntry[] { - const lowerWallet = walletAddress.toLowerCase(); - - return transactions - .filter((tx) => { - if (direction === Direction.INCOMING) { - return tx.to?.hash.toLowerCase() === lowerWallet; - } else if (direction === Direction.OUTGOING) { - return tx.from.hash.toLowerCase() === lowerWallet; - } - return true; // Direction.BOTH - }) - .map((tx) => ({ - blockNumber: tx.block_number.toString(), - timeStamp: tx.timestamp, - hash: tx.hash, - from: tx.from.hash, - to: tx.to?.hash || '', - value: tx.value, - contractAddress: '', - })); - } - - private mapBlockscoutToEvmTokenHistory( - transfers: BlockscoutTokenTransfer[], - walletAddress: string, - direction: Direction, - ): EvmTokenHistoryEntry[] { - const lowerWallet = walletAddress.toLowerCase(); - - return transfers - .filter((tx) => { - if (direction === Direction.INCOMING) { - return tx.to.hash.toLowerCase() === lowerWallet; - } else if (direction === Direction.OUTGOING) { - return tx.from.hash.toLowerCase() === lowerWallet; - } - return true; // Direction.BOTH - }) - .map((tx) => ({ - blockNumber: tx.block_number.toString(), - timeStamp: tx.timestamp, - hash: tx.tx_hash, - from: tx.from.hash, - contractAddress: tx.token.address, - to: tx.to.hash, - value: tx.total.value, - tokenName: tx.token.name || tx.token.symbol, - tokenDecimal: tx.total.decimals || tx.token.decimals, - })); + super(params); } } diff --git a/src/integration/blockchain/citrea/citrea.service.ts b/src/integration/blockchain/citrea/citrea.service.ts index 565f273b31..1688b2b2e5 100644 --- a/src/integration/blockchain/citrea/citrea.service.ts +++ b/src/integration/blockchain/citrea/citrea.service.ts @@ -8,8 +8,17 @@ import { CitreaClient } from './citrea-client'; @Injectable() export class CitreaService extends EvmService { constructor(http: HttpService, blockscoutService: BlockscoutService) { - const { citreaGatewayUrl, citreaApiKey, citreaWalletPrivateKey, citreaChainId, blockscoutApiUrl } = - GetConfig().blockchain.citrea; + const { + citreaGatewayUrl, + citreaApiKey, + citreaWalletPrivateKey, + citreaChainId, + blockscoutApiUrl, + swapContractAddress, + swapFactoryAddress, + quoteContractAddress, + swapGatewayAddress, + } = GetConfig().blockchain.citrea; super(CitreaClient, { http, @@ -19,6 +28,10 @@ export class CitreaService extends EvmService { chainId: citreaChainId, blockscoutService, blockscoutApiUrl, + swapContractAddress, + swapFactoryAddress, + quoteContractAddress, + swapGatewayAddress, }); } } diff --git a/src/integration/blockchain/shared/evm/abi/juiceswap-gateway.abi.json b/src/integration/blockchain/shared/evm/abi/juiceswap-gateway.abi.json new file mode 100644 index 0000000000..1e89b8c759 --- /dev/null +++ b/src/integration/blockchain/shared/evm/abi/juiceswap-gateway.abi.json @@ -0,0 +1,74 @@ +[ + { + "inputs": [ + { "internalType": "address", "name": "tokenIn", "type": "address" }, + { "internalType": "address", "name": "tokenOut", "type": "address" }, + { "internalType": "uint24", "name": "fee", "type": "uint24" }, + { "internalType": "uint256", "name": "amountIn", "type": "uint256" }, + { "internalType": "uint256", "name": "minAmountOut", "type": "uint256" }, + { "internalType": "address", "name": "to", "type": "address" }, + { "internalType": "uint256", "name": "deadline", "type": "uint256" } + ], + "name": "swapExactTokensForTokens", + "outputs": [{ "internalType": "uint256", "name": "amountOut", "type": "uint256" }], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "svJusdAmount", "type": "uint256" } + ], + "name": "svJusdToJusd", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "jusdAmount", "type": "uint256" } + ], + "name": "jusdToSvJusd", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "JUSD", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "SV_JUSD", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "WCBTC", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "DEFAULT_FEE", + "outputs": [{ "internalType": "uint24", "name": "", "type": "uint24" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "tokenA", "type": "address" }, + { "internalType": "address", "name": "tokenB", "type": "address" }, + { "internalType": "uint24", "name": "fee", "type": "uint24" } + ], + "name": "getPool", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + } +] diff --git a/src/integration/blockchain/shared/evm/citrea-base-client.ts b/src/integration/blockchain/shared/evm/citrea-base-client.ts new file mode 100644 index 0000000000..5aeb929dd3 --- /dev/null +++ b/src/integration/blockchain/shared/evm/citrea-base-client.ts @@ -0,0 +1,360 @@ +import { CurrencyAmount, Token, TradeType } from '@uniswap/sdk-core'; +import IUniswapV3PoolABI from '@uniswap/v3-core/artifacts/contracts/interfaces/IUniswapV3Pool.sol/IUniswapV3Pool.json'; +import { FeeAmount, Pool, Route, SwapQuoter } from '@uniswap/v3-sdk'; +import { Contract, ethers } from 'ethers'; +import { + BlockscoutTokenTransfer, + BlockscoutTransaction, +} from 'src/integration/blockchain/shared/blockscout/blockscout.service'; +import ERC20_ABI from 'src/integration/blockchain/shared/evm/abi/erc20.abi.json'; +import SWAP_GATEWAY_ABI from 'src/integration/blockchain/shared/evm/abi/juiceswap-gateway.abi.json'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { BlockchainTokenBalance } from '../dto/blockchain-token-balance.dto'; +import { Direction, EvmClient, EvmClientParams } from './evm-client'; +import { EvmUtil } from './evm.util'; +import { EvmCoinHistoryEntry, EvmTokenHistoryEntry } from './interfaces'; + +export abstract class CitreaBaseClient extends EvmClient { + protected readonly citreaQuoteContractAddress: string; + + constructor(params: EvmClientParams) { + super({ + ...params, + alchemyService: undefined, // Citrea not supported by Alchemy + swapContractAddress: undefined, // Prevent AlphaRouter init (no Multicall on Citrea) + }); + this.citreaQuoteContractAddress = params.quoteContractAddress; + } + + // --- BALANCE METHODS --- // + + async getNativeCoinBalance(): Promise { + return this.getNativeCoinBalanceForAddress(this.walletAddress); + } + + async getNativeCoinBalanceForAddress(address: string): Promise { + const balance = await this.provider.getBalance(address); + return EvmUtil.fromWeiAmount(balance.toString()); + } + + async getTokenBalance(asset: Asset, address?: string): Promise { + const owner = address ?? this.walletAddress; + const contract = new ethers.Contract(asset.chainId, ERC20_ABI, this.provider); + + try { + const balance = await contract.balanceOf(owner); + const decimals = await contract.decimals(); + return EvmUtil.fromWeiAmount(balance.toString(), decimals); + } catch (error) { + this.logger.error(`Failed to get token balance for ${asset.chainId}:`, error); + return 0; + } + } + + protected async getPoolTokenBalance(asset: Asset, poolAddress: string): Promise { + try { + const { jusd, svJusd } = await this.getGatewayTokenAddresses(); + const isJusd = asset.chainId.toLowerCase() === jusd.toLowerCase(); + const tokenAddress = isJusd ? svJusd : asset.chainId; + + const contract = new ethers.Contract(tokenAddress, ERC20_ABI, this.provider); + const balance = await contract.balanceOf(poolAddress); + const decimals = await contract.decimals(); + + if (isJusd) { + const gateway = this.getSwapGatewayContract(); + const jusdBalance = await gateway.svJusdToJusd(balance); + return EvmUtil.fromWeiAmount(jusdBalance.toString(), decimals); + } + + return EvmUtil.fromWeiAmount(balance.toString(), decimals); + } catch (error) { + this.logger.error(`Failed to get pool token balance for ${asset.chainId}:`, error); + return 0; + } + } + + override async getTokenBalances(assets: Asset[], address?: string): Promise { + const owner = address ?? this.walletAddress; + const isPoolBalance = address !== undefined && address !== this.walletAddress; + const balances: BlockchainTokenBalance[] = []; + + for (const asset of assets) { + const balance = isPoolBalance + ? await this.getPoolTokenBalance(asset, owner) + : await this.getTokenBalance(asset, owner); + balances.push({ + owner, + contractAddress: asset.chainId, + balance, + }); + } + + return balances; + } + + // --- HISTORY METHODS --- // + + async getNativeCoinTransactions( + walletAddress: string, + fromBlock: number, + _toBlock?: number, + direction = Direction.BOTH, + ): Promise { + const transactions = await this.blockscoutService.getTransactions(this.blockscoutApiUrl, walletAddress, fromBlock); + return this.mapBlockscoutToEvmCoinHistory(transactions, walletAddress, direction); + } + + async getERC20Transactions( + walletAddress: string, + fromBlock: number, + _toBlock?: number, + direction = Direction.BOTH, + ): Promise { + const transfers = await this.blockscoutService.getTokenTransfers(this.blockscoutApiUrl, walletAddress, fromBlock); + return this.mapBlockscoutToEvmTokenHistory(transfers, walletAddress, direction); + } + + private mapBlockscoutToEvmCoinHistory( + transactions: BlockscoutTransaction[], + walletAddress: string, + direction: Direction, + ): EvmCoinHistoryEntry[] { + const lowerWallet = walletAddress.toLowerCase(); + + return transactions + .filter((tx) => { + if (direction === Direction.INCOMING) { + return tx.to?.hash.toLowerCase() === lowerWallet; + } else if (direction === Direction.OUTGOING) { + return tx.from.hash.toLowerCase() === lowerWallet; + } + return true; + }) + .map((tx) => ({ + blockNumber: tx.block_number.toString(), + timeStamp: tx.timestamp, + hash: tx.hash, + from: tx.from.hash, + to: tx.to?.hash || '', + value: tx.value, + contractAddress: '', + })); + } + + private mapBlockscoutToEvmTokenHistory( + transfers: BlockscoutTokenTransfer[], + walletAddress: string, + direction: Direction, + ): EvmTokenHistoryEntry[] { + const lowerWallet = walletAddress.toLowerCase(); + + return transfers + .filter((tx) => { + if (direction === Direction.INCOMING) { + return tx.to.hash.toLowerCase() === lowerWallet; + } else if (direction === Direction.OUTGOING) { + return tx.from.hash.toLowerCase() === lowerWallet; + } + return true; + }) + .map((tx) => ({ + blockNumber: tx.block_number.toString(), + timeStamp: tx.timestamp, + hash: tx.tx_hash, + from: tx.from.hash, + contractAddress: tx.token.address, + to: tx.to.hash, + value: tx.total.value, + tokenName: tx.token.name || tx.token.symbol, + tokenDecimal: tx.total.decimals || tx.token.decimals, + })); + } + + // --- SWAP GATEWAY METHODS --- // + + private getSwapGatewayContract(): Contract { + if (!this.swapGatewayAddress) { + throw new Error('Swap Gateway address not configured'); + } + return new Contract(this.swapGatewayAddress, SWAP_GATEWAY_ABI, this.wallet); + } + + private async getGatewayTokenAddresses(): Promise<{ jusd: string; svJusd: string }> { + const gateway = this.getSwapGatewayContract(); + const [jusd, svJusd] = await Promise.all([gateway.JUSD(), gateway.SV_JUSD()]); + return { jusd, svJusd }; + } + + private async resolvePoolTokenAddresses( + asset1: Asset, + asset2: Asset, + ): Promise<{ address1: string; address2: string; isJusd1: boolean; isJusd2: boolean }> { + const { jusd, svJusd } = await this.getGatewayTokenAddresses(); + + const isJusd1 = asset1.chainId.toLowerCase() === jusd.toLowerCase(); + const isJusd2 = asset2.chainId.toLowerCase() === jusd.toLowerCase(); + + return { + address1: isJusd1 ? svJusd : asset1.chainId, + address2: isJusd2 ? svJusd : asset2.chainId, + isJusd1, + isJusd2, + }; + } + + private async getGatewayPool(tokenA: string, tokenB: string, fee: FeeAmount): Promise { + const gateway = this.getSwapGatewayContract(); + return gateway.getPool(tokenA, tokenB, fee); + } + + private async swapViaGateway( + tokenIn: string, + tokenOut: string, + amountIn: number, + minAmountOut: number, + fee: FeeAmount = FeeAmount.MEDIUM, + decimalsIn = 18, + decimalsOut = 18, + isInputNativeCoin = false, + isOutputNativeCoin = false, + ): Promise { + const gateway = this.getSwapGatewayContract(); + + const weiAmountIn = EvmUtil.toWeiAmount(amountIn, decimalsIn); + const weiMinAmountOut = EvmUtil.toWeiAmount(minAmountOut, decimalsOut); + const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes + const gasPrice = await this.getRecommendedGasPrice(); + + const actualTokenIn = isInputNativeCoin ? ethers.constants.AddressZero : tokenIn; + const actualTokenOut = isOutputNativeCoin ? ethers.constants.AddressZero : tokenOut; + + const tx = await gateway.swapExactTokensForTokens( + actualTokenIn, + actualTokenOut, + fee, + weiAmountIn, + weiMinAmountOut, + this.wallet.address, + deadline, + { gasPrice, ...(isInputNativeCoin ? { value: weiAmountIn } : {}) }, + ); + + return tx.hash; + } + + // --- TRADING INTEGRATION --- // + + override async getPoolAddress(asset1: Asset, asset2: Asset, poolFee: FeeAmount): Promise { + const { address1, address2 } = await this.resolvePoolTokenAddresses(asset1, asset2); + return this.getGatewayPool(address1, address2, poolFee); + } + + private async getTokenPairByAddresses(address1: string, address2: string): Promise<[Token, Token]> { + const contract1 = this.getERC20ContractForDex(address1); + const contract2 = this.getERC20ContractForDex(address2); + + const [decimals1, decimals2] = await Promise.all([contract1.decimals(), contract2.decimals()]); + + return [new Token(this.chainId, address1, decimals1), new Token(this.chainId, address2, decimals2)]; + } + + override async testSwapPool( + source: Asset, + sourceAmount: number, + target: Asset, + poolFee: FeeAmount, + ): Promise<{ targetAmount: number; feeAmount: number; priceImpact: number }> { + if (source.id === target.id) return { targetAmount: sourceAmount, feeAmount: 0, priceImpact: 0 }; + + const { + address1: sourceAddress, + address2: targetAddress, + isJusd1: sourceIsJusd, + isJusd2: targetIsJusd, + } = await this.resolvePoolTokenAddresses(source, target); + const gateway = this.getSwapGatewayContract(); + + // If source is JUSD, convert input amount to svJUSD equivalent + let poolSourceAmount = sourceAmount; + if (sourceIsJusd) { + const sourceAmountWei = EvmUtil.toWeiAmount(sourceAmount, source.decimals); + const svJusdAmountWei = await gateway.jusdToSvJusd(sourceAmountWei); + poolSourceAmount = EvmUtil.fromWeiAmount(svJusdAmountWei, 18); + } + + const [sourceToken, targetToken] = await this.getTokenPairByAddresses(sourceAddress, targetAddress); + + const poolAddress = await this.getGatewayPool(sourceAddress, targetAddress, poolFee); + const poolContract = new ethers.Contract(poolAddress, IUniswapV3PoolABI.abi, this.wallet); + + const token0IsInToken = sourceToken.address.toLowerCase() === (await poolContract.token0()).toLowerCase(); + const [liquidity, slot0] = await Promise.all([poolContract.liquidity(), poolContract.slot0()]); + const sqrtPriceX96 = slot0.sqrtPriceX96; + + const pool = new Pool(sourceToken, targetToken, poolFee, slot0[0].toString(), liquidity.toString(), slot0[1]); + const route = new Route([pool], sourceToken, targetToken); + + const sourceAmountWei = EvmUtil.toWeiAmount(poolSourceAmount, sourceToken.decimals); + const currencyAmount = CurrencyAmount.fromRawAmount(sourceToken, sourceAmountWei.toString()); + + const { calldata } = SwapQuoter.quoteCallParameters(route, currencyAmount, TradeType.EXACT_INPUT, { + useQuoterV2: true, + }); + + const quoteCallReturnData = await this.provider.call({ + to: this.citreaQuoteContractAddress, + data: calldata, + }); + + const amountOutHex = ethers.utils.hexDataSlice(quoteCallReturnData, 0, 32); + const amountOut = ethers.BigNumber.from(amountOutHex); + + // If target is JUSD, convert svJUSD output to JUSD + let finalAmountOut = amountOut; + if (targetIsJusd) { + finalAmountOut = await gateway.svJusdToJusd(amountOut); + } + + // Calculate price impact + const expectedOut = sourceAmountWei.mul(sqrtPriceX96).mul(sqrtPriceX96).div(ethers.BigNumber.from(2).pow(192)); + const priceImpact = token0IsInToken + ? Math.abs(1 - +amountOut / +expectedOut) + : Math.abs(1 - +expectedOut / +amountOut); + + const gasPrice = await this.getRecommendedGasPrice(); + const estimatedGas = ethers.BigNumber.from(300000); + + return { + targetAmount: EvmUtil.fromWeiAmount(finalAmountOut, target.decimals), + feeAmount: EvmUtil.fromWeiAmount(estimatedGas.mul(gasPrice)), + priceImpact, + }; + } + + override async swapPool( + source: Asset, + target: Asset, + sourceAmount: number, + poolFee: FeeAmount, + maxSlippage: number, + ): Promise { + const quote = await this.testSwapPool(source, sourceAmount, target, poolFee); + const minAmountOut = quote.targetAmount * (1 - maxSlippage); + + const isInputNativeCoin = source.type === AssetType.COIN; + const isOutputNativeCoin = target.type === AssetType.COIN; + + return this.swapViaGateway( + source.chainId, + target.chainId, + sourceAmount, + minAmountOut, + poolFee, + source.decimals, + target.decimals, + isInputNativeCoin, + isOutputNativeCoin, + ); + } +} diff --git a/src/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index 17a7a13dae..0bb8f9aa6b 100644 --- a/src/integration/blockchain/shared/evm/evm-client.ts +++ b/src/integration/blockchain/shared/evm/evm-client.ts @@ -39,6 +39,7 @@ export interface EvmClientParams { swapContractAddress?: string; quoteContractAddress?: string; swapFactoryAddress?: string; + swapGatewayAddress?: string; } interface UniswapPosition { @@ -74,6 +75,7 @@ export abstract class EvmClient extends BlockchainClient { private readonly swapContractAddress: string; private readonly swapFactoryAddress: string; private readonly quoteContractAddress: string; + protected readonly swapGatewayAddress: string; constructor(params: EvmClientParams) { super(); @@ -98,6 +100,7 @@ export abstract class EvmClient extends BlockchainClient { this.swapContractAddress = params.swapContractAddress; this.quoteContractAddress = params.quoteContractAddress; this.swapFactoryAddress = params.swapFactoryAddress; + this.swapGatewayAddress = params.swapGatewayAddress; } // --- PUBLIC API - GETTERS --- // diff --git a/src/subdomains/core/trading/services/trading.service.ts b/src/subdomains/core/trading/services/trading.service.ts index 9e70a7907c..630246a0ea 100644 --- a/src/subdomains/core/trading/services/trading.service.ts +++ b/src/subdomains/core/trading/services/trading.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { NativeCurrency } from '@uniswap/sdk-core'; import { ethers } from 'ethers'; import { EvmClient } from 'src/integration/blockchain/shared/evm/evm-client'; import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; @@ -105,13 +104,6 @@ export class TradingService { ): Promise { try { const client = this.blockchainRegistryService.getEvmClient(tradingInfo.assetIn.blockchain); - - const tokenIn = await client.getToken(tradingInfo.assetIn); - const tokenOut = await client.getToken(tradingInfo.assetOut); - - if (tokenIn instanceof NativeCurrency || tokenOut instanceof NativeCurrency) - throw new Error('Only tokens can be in a pool'); - const poolContract = await this.getPoolContract(client, tradingInfo); const usePriceImpact = Math.abs(tradingInfo.priceImpact) / 2; From 4dfe78d62c17bdf070b2940ebfe501df5475fc2d Mon Sep 17 00:00:00 2001 From: Lam Nguyen <32935491+xlamn@users.noreply.github.com> Date: Wed, 28 Jan 2026 18:08:36 +0100 Subject: [PATCH 3/3] feat: split RealUnit register endpoint (#3057) * feat: add register/email and register/complete endpoints. * chore: RegistrationStatus naming. * chore: remove duplicated code. * fix: check for merge string. --- .../controllers/realunit.controller.ts | 53 +++++++++- .../realunit/dto/realunit-registration.dto.ts | 21 ++++ .../supporting/realunit/realunit.service.ts | 96 ++++++++++++++++++- 3 files changed, 168 insertions(+), 2 deletions(-) diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index 91c73adf5e..597e5224b0 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -16,6 +16,7 @@ import { ApiAcceptedResponse, ApiBadRequestResponse, ApiBearerAuth, + ApiConflictResponse, ApiExcludeEndpoint, ApiOkResponse, ApiOperation, @@ -50,6 +51,8 @@ import { RealUnitSingleReceiptPdfDto, } from '../dto/realunit-pdf.dto'; import { + RealUnitEmailRegistrationDto, + RealUnitEmailRegistrationResponseDto, RealUnitRegistrationDto, RealUnitRegistrationResponseDto, RealUnitRegistrationStatus, @@ -319,7 +322,55 @@ export class RealUnitController { return this.realunitService.confirmSell(jwt.user, +id, dto); } - // --- Registration Endpoint --- + // --- Registration Endpoints --- + + @Post('register/email') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) + @ApiOperation({ + summary: 'Step 1: Register email for RealUnit', + description: + 'First step of RealUnit registration. Checks if email exists in DFX system. If exists and merge is possible, sends merge confirmation email. Otherwise registers email and sets KYC Level 10.', + }) + @ApiOkResponse({ type: RealUnitEmailRegistrationResponseDto }) + @ApiBadRequestResponse({ description: 'Email does not match verified email' }) + @ApiConflictResponse({ description: 'Account already exists and merge not possible' }) + async registerEmail( + @GetJwt() jwt: JwtPayload, + @Body() dto: RealUnitEmailRegistrationDto, + ): Promise { + const status = await this.realunitService.registerEmail(jwt.account, dto); + return { status }; + } + + @Post('register/complete') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) + @ApiOperation({ + summary: 'Step 2: Complete RealUnit registration', + description: + 'Second step of RealUnit registration. Requires email registration to be completed. Validates personal data against DFX system and forwards to Aktionariat.', + }) + @ApiOkResponse({ type: RealUnitRegistrationResponseDto }) + @ApiAcceptedResponse({ + type: RealUnitRegistrationResponseDto, + description: 'Registration accepted, manual review needed or forwarding to Aktionariat failed', + }) + @ApiBadRequestResponse({ + description: 'Invalid signature, wallet mismatch, email registration not completed, or data mismatch', + }) + async completeRegistration( + @GetJwt() jwt: JwtPayload, + @Body() dto: RealUnitRegistrationDto, + @Res() res: Response, + ): Promise { + const status = await this.realunitService.completeRegistration(jwt.account, dto); + const response: RealUnitRegistrationResponseDto = { + status: status, + }; + const statusCode = status === RealUnitRegistrationStatus.COMPLETED ? HttpStatus.CREATED : HttpStatus.ACCEPTED; + res.status(statusCode).json(response); + } @Post('register') @ApiBearerAuth() diff --git a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts index 9149055af0..a672dc2f87 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts @@ -26,6 +26,8 @@ export enum RealUnitUserType { export enum RealUnitRegistrationStatus { COMPLETED = 'completed', PENDING_REVIEW = 'pending_review', + MANUAL_REVIEW_DATA_MISMATCH = 'manual_review_data_mismatch', + FORWARDING_FAILED = 'forwarding_failed', } export class RealUnitRegistrationResponseDto { @@ -33,6 +35,25 @@ export class RealUnitRegistrationResponseDto { status: RealUnitRegistrationStatus; } +export enum RealUnitEmailRegistrationStatus { + EMAIL_REGISTERED = 'email_registered', + MERGE_REQUESTED = 'merge_requested', +} + +export class RealUnitEmailRegistrationDto { + @ApiProperty() + @IsNotEmpty() + @IsEmail() + @IsLowercase() + @Transform(Util.trim) + email: string; +} + +export class RealUnitEmailRegistrationResponseDto { + @ApiProperty({ enum: RealUnitEmailRegistrationStatus }) + status: RealUnitEmailRegistrationStatus; +} + export enum RealUnitLanguage { EN = 'EN', DE = 'DE', diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 8d6ff9eb94..0ff4d524e3 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -34,6 +34,7 @@ import { KycStep } from 'src/subdomains/generic/kyc/entities/kyc-step.entity'; import { KycStepName } from 'src/subdomains/generic/kyc/enums/kyc-step-name.enum'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; +import { AccountMergeService } from 'src/subdomains/generic/user/models/account-merge/account-merge.service'; import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { KycLevel } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; @@ -52,7 +53,14 @@ import { TokenInfoClientResponse, } from './dto/client.dto'; import { RealUnitDtoMapper } from './dto/realunit-dto.mapper'; -import { AktionariatRegistrationDto, RealUnitRegistrationDto, RealUnitUserType } from './dto/realunit-registration.dto'; +import { + AktionariatRegistrationDto, + RealUnitEmailRegistrationDto, + RealUnitEmailRegistrationStatus, + RealUnitRegistrationDto, + RealUnitRegistrationStatus, + RealUnitUserType, +} from './dto/realunit-registration.dto'; import { RealUnitSellConfirmDto, RealUnitSellDto, RealUnitSellPaymentInfoDto } from './dto/realunit-sell.dto'; import { AccountHistoryDto, @@ -98,6 +106,7 @@ export class RealUnitService { private readonly sellService: SellService, private readonly eip7702DelegationService: Eip7702DelegationService, private readonly transactionRequestService: TransactionRequestService, + private readonly accountMergeService: AccountMergeService, ) { this.ponderUrl = GetConfig().blockchain.realunit.graphUrl; } @@ -341,6 +350,91 @@ export class RealUnitService { return !success; } + async registerEmail(userDataId: number, dto: RealUnitEmailRegistrationDto): Promise { + const userData = await this.userDataService.getUserData(userDataId, { users: true }); + if (!userData) throw new NotFoundException('User not found'); + + if (!userData.mail) { + try { + await this.userDataService.trySetUserMail(userData, dto.email); + } catch (e) { + if (e instanceof ConflictException) { + if (e.message.includes('account merge request sent')) { + return RealUnitEmailRegistrationStatus.MERGE_REQUESTED; + } + } + throw e; + } + } else if (!Util.equalsIgnoreCase(dto.email, userData.mail)) { + throw new BadRequestException('Email does not match verified email'); + } + + if (userData.kycLevel < KycLevel.LEVEL_10) { + await this.kycService.initializeProcess(userData); + } + + return RealUnitEmailRegistrationStatus.EMAIL_REGISTERED; + } + + async completeRegistration(userDataId: number, dto: RealUnitRegistrationDto): Promise { + await this.validateRegistrationDto(dto); + + // get and validate user + const userData = await this.userService + .getUserByAddress(dto.walletAddress, { + userData: { kycSteps: true, users: true, country: true, organizationCountry: true }, + }) + .then((u) => u?.userData); + + if (!userData) throw new NotFoundException('User not found'); + if (userData.id !== userDataId) throw new BadRequestException('Wallet address does not belong to user'); + + if (userData.kycLevel < KycLevel.LEVEL_10 || !userData.mail) { + throw new BadRequestException('Email registration must be completed first'); + } + if (!Util.equalsIgnoreCase(dto.email, userData.mail)) { + throw new BadRequestException('Email does not match registered email'); + } + + // duplicate check + if (userData.getNonFailedStepWith(KycStepName.REALUNIT_REGISTRATION)) { + throw new BadRequestException('RealUnit registration already exists'); + } + + // store data with internal review + const kycStep = await this.kycService.createCustomKycStep( + userData, + KycStepName.REALUNIT_REGISTRATION, + ReviewStatus.INTERNAL_REVIEW, + dto, + ); + + const hasExistingData = userData.firstname != null; + if (hasExistingData) { + const dataMatches = this.isPersonalDataMatching(userData, dto); + if (!dataMatches) { + await this.kycService.saveKycStepUpdate(kycStep.manualReview('Existing KYC data does not match')); + return RealUnitRegistrationStatus.MANUAL_REVIEW_DATA_MISMATCH; + } + } else { + await this.userDataService.updatePersonalData(userData, dto.kycData); + } + + // forward to Aktionariat + const success = await this.forwardRegistration(kycStep, dto); + if (!success) return RealUnitRegistrationStatus.FORWARDING_FAILED; + + // only update after successful forward + await this.userDataService.updateUserDataInternal(userData, { + nationality: await this.countryService.getCountryWithSymbol(dto.nationality), + birthday: new Date(dto.birthday), + language: dto.lang && (await this.languageService.getLanguageBySymbol(dto.lang)), + tin: dto.countryAndTINs?.length ? JSON.stringify(dto.countryAndTINs) : undefined, + }); + + return RealUnitRegistrationStatus.COMPLETED; + } + private async validateRegistrationDto(dto: RealUnitRegistrationDto): Promise { // signature validation if (!this.verifyRealUnitRegistrationSignature(dto)) {