Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infrastructure/bicep/api/parameters/dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@
"value": "xxx"
},
"citreaTestnetGatewayUrl": {
"value": "https://rpc.testnet.citrea.xyz"
"value": "https://rpc.testnet.citreascan.com"
},
"citreaTestnetApiKey": {
"value": "100"
Expand Down
2 changes: 1 addition & 1 deletion infrastructure/bicep/api/parameters/loc.json
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@
"value": "xxx"
},
"citreaTestnetGatewayUrl": {
"value": "https://rpc.testnet.citrea.xyz"
"value": "https://rpc.testnet.citreascan.com"
},
"citreaTestnetApiKey": {
"value": "100"
Expand Down
2 changes: 1 addition & 1 deletion infrastructure/bicep/api/parameters/prd.json
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@
"value": "xxx"
},
"citreaTestnetGatewayUrl": {
"value": "https://rpc.testnet.citrea.xyz"
"value": "https://rpc.testnet.citreascan.com"
},
"citreaTestnetApiKey": {
"value": "100"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 40 additions & 52 deletions src/integration/blockchain/shared/evm/citrea-base-client.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -53,9 +53,7 @@ export abstract class CitreaBaseClient extends EvmClient {

protected async getPoolTokenBalance(asset: Asset, poolAddress: string): Promise<number> {
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);
Expand Down Expand Up @@ -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<string> {
Expand Down Expand Up @@ -246,7 +236,10 @@ export abstract class CitreaBaseClient extends EvmClient {
// --- TRADING INTEGRATION --- //

override async getPoolAddress(asset1: Asset, asset2: Asset, poolFee: FeeAmount): Promise<string> {
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);
}

Expand All @@ -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
Expand All @@ -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);
Expand All @@ -307,28 +302,21 @@ 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;
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,
amountOut: finalAmountOut,
gasEstimate: ethers.BigNumber.from(300000),
priceImpact: this.calcPriceImpact(sqrtPriceX96Before, sqrtPriceX96After, token0IsInToken),
};
}

Expand Down
62 changes: 41 additions & 21 deletions src/integration/blockchain/shared/evm/evm-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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<Token, Token>;
}> {
Expand All @@ -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<number> {
Expand Down
2 changes: 2 additions & 0 deletions src/subdomains/generic/kyc/dto/kyc-error.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -87,6 +88,7 @@ export const KycErrorMap: Record<KycError, string> = {
[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 } = {
Expand Down
18 changes: 15 additions & 3 deletions src/subdomains/generic/kyc/services/kyc.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading