diff --git a/infrastructure/bicep/api/parameters/dev.json b/infrastructure/bicep/api/parameters/dev.json index 6fd3214cfe..0e61aa7478 100644 --- a/infrastructure/bicep/api/parameters/dev.json +++ b/infrastructure/bicep/api/parameters/dev.json @@ -276,7 +276,7 @@ "value": "xxx" }, "citreaTestnetGatewayUrl": { - "value": "https://rpc.testnet.citrea.xyz" + "value": "https://rpc.testnet.citreascan.com" }, "citreaTestnetApiKey": { "value": "100" diff --git a/infrastructure/bicep/api/parameters/loc.json b/infrastructure/bicep/api/parameters/loc.json index 9dd058e86a..1f4c78215e 100644 --- a/infrastructure/bicep/api/parameters/loc.json +++ b/infrastructure/bicep/api/parameters/loc.json @@ -270,7 +270,7 @@ "value": "xxx" }, "citreaTestnetGatewayUrl": { - "value": "https://rpc.testnet.citrea.xyz" + "value": "https://rpc.testnet.citreascan.com" }, "citreaTestnetApiKey": { "value": "100" diff --git a/infrastructure/bicep/api/parameters/prd.json b/infrastructure/bicep/api/parameters/prd.json index 6810b259ef..2fda2a3f14 100644 --- a/infrastructure/bicep/api/parameters/prd.json +++ b/infrastructure/bicep/api/parameters/prd.json @@ -270,7 +270,7 @@ "value": "xxx" }, "citreaTestnetGatewayUrl": { - "value": "https://rpc.testnet.citrea.xyz" + "value": "https://rpc.testnet.citreascan.com" }, "citreaTestnetApiKey": { "value": "100" diff --git a/infrastructure/citrea/citreascan/config/docker/docker-compose-citrea-testnet4.yml b/infrastructure/citrea/citreascan/config/docker/docker-compose-citrea-testnet4.yml index 00cba57de5..e1540d027d 100644 --- a/infrastructure/citrea/citreascan/config/docker/docker-compose-citrea-testnet4.yml +++ b/infrastructure/citrea/citreascan/config/docker/docker-compose-citrea-testnet4.yml @@ -60,7 +60,7 @@ services: - RPC_BATCH_REQUESTS_LIMIT=100 - RPC_ENABLE_SUBSCRIPTIONS=true - RPC_MAX_SUBSCRIPTIONS_PER_CONNECTION=10 - - SEQUENCER_CLIENT_URL=https://rpc.testnet.citrea.xyz + - SEQUENCER_CLIENT_URL=https://rpc.testnet.citreascan.com - INCLUDE_TX_BODY=false - SCAN_L1_START_HEIGHT=45496 - SYNC_BLOCKS_COUNT=10 diff --git a/src/integration/blockchain/shared/evm/citrea-base-client.ts b/src/integration/blockchain/shared/evm/citrea-base-client.ts index 5aeb929dd3..3bb5166cbd 100644 --- a/src/integration/blockchain/shared/evm/citrea-base-client.ts +++ b/src/integration/blockchain/shared/evm/citrea-base-client.ts @@ -1,7 +1,7 @@ import { CurrencyAmount, Token, TradeType } from '@uniswap/sdk-core'; -import IUniswapV3PoolABI from '@uniswap/v3-core/artifacts/contracts/interfaces/IUniswapV3Pool.sol/IUniswapV3Pool.json'; +import QuoterV2ABI from '@uniswap/v3-periphery/artifacts/contracts/lens/QuoterV2.sol/QuoterV2.json'; import { FeeAmount, Pool, Route, SwapQuoter } from '@uniswap/v3-sdk'; -import { Contract, ethers } from 'ethers'; +import { Contract, ethers, BigNumber as EthersNumber } from 'ethers'; import { BlockscoutTokenTransfer, BlockscoutTransaction, @@ -53,9 +53,7 @@ export abstract class CitreaBaseClient extends EvmClient { 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 { address: tokenAddress, isJusd } = await this.resolvePoolTokenAddress(asset); const contract = new ethers.Contract(tokenAddress, ERC20_ABI, this.provider); const balance = await contract.balanceOf(poolAddress); @@ -180,27 +178,19 @@ export abstract class CitreaBaseClient extends EvmClient { return new Contract(this.swapGatewayAddress, SWAP_GATEWAY_ABI, this.wallet); } - private async getGatewayTokenAddresses(): Promise<{ jusd: string; svJusd: string }> { + private async getGatewayTokenAddresses(): Promise<{ jusd: string; svJusd: string; wcbtc: string }> { const gateway = this.getSwapGatewayContract(); - const [jusd, svJusd] = await Promise.all([gateway.JUSD(), gateway.SV_JUSD()]); - return { jusd, svJusd }; + const [jusd, svJusd, wcbtc] = await Promise.all([gateway.JUSD(), gateway.SV_JUSD(), gateway.WCBTC()]); + return { jusd, svJusd, wcbtc }; } - private async resolvePoolTokenAddresses( - asset1: Asset, - asset2: Asset, - ): Promise<{ address1: string; address2: string; isJusd1: boolean; isJusd2: boolean }> { - const { jusd, svJusd } = await this.getGatewayTokenAddresses(); + private async resolvePoolTokenAddress(asset: Asset): Promise<{ address: string; isJusd: boolean }> { + const { jusd, svJusd, wcbtc } = await this.getGatewayTokenAddresses(); + const isJusd = asset.chainId.toLowerCase() === jusd.toLowerCase(); - 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, - }; + if (isJusd) return { address: svJusd, isJusd: true }; + if (asset.type === AssetType.COIN) return { address: wcbtc, isJusd: false }; + return { address: asset.chainId, isJusd: false }; } private async getGatewayPool(tokenA: string, tokenB: string, fee: FeeAmount): Promise { @@ -246,7 +236,10 @@ export abstract class CitreaBaseClient extends EvmClient { // --- TRADING INTEGRATION --- // override async getPoolAddress(asset1: Asset, asset2: Asset, poolFee: FeeAmount): Promise { - const { address1, address2 } = await this.resolvePoolTokenAddresses(asset1, asset2); + const [{ address: address1 }, { address: address2 }] = await Promise.all([ + this.resolvePoolTokenAddress(asset1), + this.resolvePoolTokenAddress(asset2), + ]); return this.getGatewayPool(address1, address2, poolFee); } @@ -259,20 +252,18 @@ export abstract class CitreaBaseClient extends EvmClient { return [new Token(this.chainId, address1, decimals1), new Token(this.chainId, address2, decimals2)]; } - override async testSwapPool( + protected override async assetPoolQuote( source: Asset, - sourceAmount: number, target: Asset, + sourceAmount: number, 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); + ): Promise<{ + amountOut: EthersNumber; + gasEstimate: EthersNumber; + priceImpact: number; + }> { + const [{ address: sourceAddress, isJusd: sourceIsJusd }, { address: targetAddress, isJusd: targetIsJusd }] = + await Promise.all([this.resolvePoolTokenAddress(source), this.resolvePoolTokenAddress(target)]); const gateway = this.getSwapGatewayContract(); // If source is JUSD, convert input amount to svJUSD equivalent @@ -286,11 +277,15 @@ export abstract class CitreaBaseClient extends EvmClient { 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 poolContract = this.getPoolContract(poolAddress); - const token0IsInToken = sourceToken.address.toLowerCase() === (await poolContract.token0()).toLowerCase(); - const [liquidity, slot0] = await Promise.all([poolContract.liquidity(), poolContract.slot0()]); - const sqrtPriceX96 = slot0.sqrtPriceX96; + const [liquidity, slot0, token0] = await Promise.all([ + poolContract.liquidity(), + poolContract.slot0(), + poolContract.token0(), + ]); + const sqrtPriceX96Before = slot0.sqrtPriceX96; + const token0IsInToken = sourceToken.address.toLowerCase() === token0.toLowerCase(); const pool = new Pool(sourceToken, targetToken, poolFee, slot0[0].toString(), liquidity.toString(), slot0[1]); const route = new Route([pool], sourceToken, targetToken); @@ -307,8 +302,10 @@ export abstract class CitreaBaseClient extends EvmClient { data: calldata, }); - const amountOutHex = ethers.utils.hexDataSlice(quoteCallReturnData, 0, 32); - const amountOut = ethers.BigNumber.from(amountOutHex); + const [amountOut, sqrtPriceX96After] = ethers.utils.defaultAbiCoder.decode( + QuoterV2ABI.abi.find((f) => f.name === 'quoteExactInputSingle')?.outputs.map((o) => o.type), + quoteCallReturnData, + ); // If target is JUSD, convert svJUSD output to JUSD let finalAmountOut = amountOut; @@ -316,19 +313,10 @@ export abstract class CitreaBaseClient extends EvmClient { 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, + amountOut: finalAmountOut, + gasEstimate: ethers.BigNumber.from(300000), + priceImpact: this.calcPriceImpact(sqrtPriceX96Before, sqrtPriceX96After, token0IsInToken), }; } diff --git a/src/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index 0bb8f9aa6b..b46e8afa75 100644 --- a/src/integration/blockchain/shared/evm/evm-client.ts +++ b/src/integration/blockchain/shared/evm/evm-client.ts @@ -9,11 +9,11 @@ import BigNumber from 'bignumber.js'; import { Contract, BigNumber as EthersNumber, ethers } from 'ethers'; import { hashMessage } from 'ethers/lib/utils'; import { AlchemyService, AssetTransfersParams } from 'src/integration/alchemy/services/alchemy.service'; +import { BlockscoutService } from 'src/integration/blockchain/shared/blockscout/blockscout.service'; import ERC1271_ABI from 'src/integration/blockchain/shared/evm/abi/erc1271.abi.json'; import ERC20_ABI from 'src/integration/blockchain/shared/evm/abi/erc20.abi.json'; import SIGNATURE_TRANSFER_ABI from 'src/integration/blockchain/shared/evm/abi/signature-transfer.abi.json'; import UNISWAP_V3_NFT_MANAGER_ABI from 'src/integration/blockchain/shared/evm/abi/uniswap-v3-nft-manager.abi.json'; -import { BlockscoutService } from 'src/integration/blockchain/shared/blockscout/blockscout.service'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { HttpService } from 'src/shared/services/http.service'; @@ -544,27 +544,13 @@ export abstract class EvmClient extends BlockchainClient { ): Promise<{ targetAmount: number; feeAmount: number; priceImpact: number }> { if (source.id === target.id) return { targetAmount: sourceAmount, feeAmount: 0, priceImpact: 0 }; - const [sourceToken, targetToken] = await this.getTokenPair(source, target); - - const poolContract = this.getPoolContract( - Pool.getAddress(sourceToken, targetToken, poolFee, undefined, this.swapFactoryAddress), - ); - - const token0IsInToken = sourceToken.address === (await poolContract.token0()); - const slot0 = await poolContract.slot0(); - const sqrtPriceX96 = slot0.sqrtPriceX96; - - const quote = await this.poolQuote(sourceToken, targetToken, sourceAmount, poolFee); - - let sqrtPriceRatio = +quote.sqrtPriceX96 / +sqrtPriceX96; - if (!token0IsInToken) sqrtPriceRatio = 1 / sqrtPriceRatio; - + const quote = await this.assetPoolQuote(source, target, sourceAmount, poolFee); const gasPrice = await this.getRecommendedGasPrice(); return { - targetAmount: EvmUtil.fromWeiAmount(quote.amountOut, targetToken.decimals), + targetAmount: EvmUtil.fromWeiAmount(quote.amountOut, target.decimals), feeAmount: EvmUtil.fromWeiAmount(quote.gasEstimate.mul(gasPrice)), - priceImpact: Math.abs(1 - sqrtPriceRatio), + priceImpact: quote.priceImpact, }; } @@ -599,14 +585,48 @@ export abstract class EvmClient extends BlockchainClient { return this.doSwap(parameters); } + protected async assetPoolQuote( + source: Asset, + target: Asset, + sourceAmount: number, + poolFee: FeeAmount, + ): Promise<{ + amountOut: EthersNumber; + gasEstimate: EthersNumber; + priceImpact: number; + }> { + const [sourceToken, targetToken] = await this.getTokenPair(source, target); + const quote = await this.poolQuote(sourceToken, targetToken, sourceAmount, poolFee); + + const token0IsIn = sourceToken.address.toLowerCase() === quote.pool.token0.address.toLowerCase(); + const sqrtPriceX96Before = ethers.BigNumber.from(quote.pool.sqrtRatioX96.toString()); + + return { + amountOut: quote.amountOut, + gasEstimate: quote.gasEstimate, + priceImpact: this.calcPriceImpact(sqrtPriceX96Before, quote.sqrtPriceX96After, token0IsIn), + }; + } + + protected calcPriceImpact( + sqrtPriceX96Before: EthersNumber, + sqrtPriceX96After: EthersNumber, + token0IsIn: boolean, + ): number { + let sqrtPriceRatio = +sqrtPriceX96After / +sqrtPriceX96Before; + if (!token0IsIn) sqrtPriceRatio = 1 / sqrtPriceRatio; + return Math.abs(1 - sqrtPriceRatio); + } + private async poolQuote( sourceToken: Token, targetToken: Token, sourceAmount: number, poolFee: FeeAmount, ): Promise<{ + pool: Pool; amountOut: EthersNumber; - sqrtPriceX96: EthersNumber; + sqrtPriceX96After: EthersNumber; gasEstimate: EthersNumber; route: Route; }> { @@ -632,12 +652,12 @@ export abstract class EvmClient extends BlockchainClient { data: calldata, }); - const [amountOut, sqrtPriceX96, _, gasEstimate] = ethers.utils.defaultAbiCoder.decode( + const [amountOut, sqrtPriceX96After, _, gasEstimate] = ethers.utils.defaultAbiCoder.decode( QuoterV2ABI.abi.find((f) => f.name === 'quoteExactInputSingle')?.outputs.map((o) => o.type), quoteCallReturnData, ); - return { amountOut, sqrtPriceX96, gasEstimate, route }; + return { pool, amountOut, sqrtPriceX96After, gasEstimate, route }; } async getSwapResult(txId: string, asset: Asset): Promise { diff --git a/src/subdomains/generic/kyc/dto/kyc-error.enum.ts b/src/subdomains/generic/kyc/dto/kyc-error.enum.ts index d77a5f076f..8f1eea01f7 100644 --- a/src/subdomains/generic/kyc/dto/kyc-error.enum.ts +++ b/src/subdomains/generic/kyc/dto/kyc-error.enum.ts @@ -27,6 +27,7 @@ export enum KycError { COUNTRY_NOT_ALLOWED = 'CountryNotAllowed', IP_COUNTRY_MISMATCH = 'IpCountryMismatch', COUNTRY_IP_COUNTRY_MISMATCH = 'CountryIpCountryMismatch', + RESIDENCE_PERMIT_CHECK_REQUIRED = 'ResidencePermitCheckRequired', // Recommendation errors EXPIRED_RECOMMENDATION = 'ExpiredRecommendation', @@ -87,6 +88,7 @@ export const KycErrorMap: Record = { [KycError.RECOMMENDER_BLOCKED]: 'Unknown error', [KycError.BANK_RECALL_FEE_NOT_PAID]: 'Recall fee not paid', [KycError.INCORRECT_INFO]: 'Incorrect response', + [KycError.RESIDENCE_PERMIT_CHECK_REQUIRED]: undefined, }; export const KycReasonMap: { [e in KycError]?: KycStepReason } = { diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 845b269bbc..d98549825a 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -36,7 +36,13 @@ import { KycLevel, KycType, UserDataStatus } from '../../user/models/user-data/u import { UserDataService } from '../../user/models/user-data/user-data.service'; import { WalletService } from '../../user/models/wallet/wallet.service'; import { WebhookService } from '../../user/services/webhook/webhook.service'; -import { IdentResultData, IdentType, NationalityDocType, ValidDocType } from '../dto/ident-result-data.dto'; +import { + IdentDocumentType, + IdentResultData, + IdentType, + NationalityDocType, + ValidDocType, +} from '../dto/ident-result-data.dto'; import { IdNowReason, IdNowResult, IdentShortResult, getIdNowIdentReason } from '../dto/ident-result.dto'; import { IdentDocument } from '../dto/ident.dto'; import { @@ -1431,8 +1437,14 @@ export class KycService { (NationalityDocType.includes(data.documentType) && nationalityStepResult.nationality.id !== nationality?.id) ) errors.push(KycError.NATIONALITY_NOT_MATCHING); - if (!nationality.isKycDocEnabled(data.documentType)) errors.push(KycError.DOCUMENT_TYPE_NOT_ALLOWED); - if (!nationality.nationalityEnable) errors.push(KycError.NATIONALITY_NOT_ALLOWED); + + if (Config.kyc.residencePermitCountries.includes(nationality.symbol)) { + errors.push(KycError.RESIDENCE_PERMIT_CHECK_REQUIRED); + if (data.documentType !== IdentDocumentType.PASSPORT) errors.push(KycError.DOCUMENT_TYPE_NOT_ALLOWED); + } else { + if (!nationality.isKycDocEnabled(data.documentType)) errors.push(KycError.DOCUMENT_TYPE_NOT_ALLOWED); + if (!nationality.nationalityEnable) errors.push(KycError.NATIONALITY_NOT_ALLOWED); + } } // Ident doc check