diff --git a/.env.example b/.env.example index 3868340064..925873eb71 100644 --- a/.env.example +++ b/.env.example @@ -206,10 +206,15 @@ CARDANO_WALLET_SEED= CARDANO_GATEWAY_URL= CARDANO_API_URL= +CITREA_WALLET_ADDRESS= +CITREA_WALLET_PRIVATE_KEY= +CITREA_API_KEY= +CITREA_BLOCKSCOUT_API_URL=https://api.citreascan.com + CITREA_TESTNET_WALLET_ADDRESS= CITREA_TESTNET_WALLET_PRIVATE_KEY= CITREA_TESTNET_API_KEY= -CITREA_TESTNET_GOLDSKY_SUBGRAPH_URL=https://api.goldsky.com/api/public/project_xyz/subgraphs/citrea-transfers/1.0.0/gn +CITREA_TESTNET_BLOCKSCOUT_API_URL=https://api.testnet.citreascan.com ZCHF_GRAPH_URL=https://api.thegraph.com/subgraphs/name/frankencoin-zchf/frankencoin-subgraph ZCHF_CONTRACT_ADDRESS=0xB58E61C3098d85632Df34EecfB899A1Ed80921cB @@ -221,6 +226,9 @@ ZCHF_FPS_WRAPPER_CONTRACT_ADDRESS= DEURO_GRAPH_URL= DEURO_API_URL= +JUSD_GRAPH_URL= +JUSD_API_URL= + EBEL2X_CONTRACT_ADDRESS= COIN_GECKO_API_KEY= diff --git a/package-lock.json b/package-lock.json index 5427a0c11f..c7f894de9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@emurgo/cardano-serialization-lib-nodejs": "^15.0.3", "@eth-optimism/sdk": "^3.3.3", "@frankencoin/zchf": "^0.2.36", + "@juicedollar/jusd": "^3.0.1", "@maticnetwork/maticjs": "^3.9.6", "@maticnetwork/maticjs-ethers": "^1.1.0", "@nestjs-modules/mailer": "^1.11.2", @@ -5522,6 +5523,29 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@juicedollar/jusd": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@juicedollar/jusd/-/jusd-3.0.1.tgz", + "integrity": "sha512-Vx7baRj8q7ucF9T4WGDhBlp76wiKu6cS9H/WH64xu/iHnetZynDKn9T9uOQn4ABkhTeXBs24OA59LgWYe+cERA==", + "license": "MIT", + "dependencies": { + "@openzeppelin/contracts": "^5.1.0", + "hardhat-abi-exporter": "^2.10.0", + "hardhat-contract-sizer": "^2.5.1", + "prettier": "^3.3.3", + "prettier-plugin-solidity": "^1.4.1", + "prompt": "^1.3.0", + "solhint": "^5.0.5", + "ts-node": "^10.9.1", + "typescript": "^5.2.2" + } + }, + "node_modules/@juicedollar/jusd/node_modules/@openzeppelin/contracts": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.4.0.tgz", + "integrity": "sha512-eCYgWnLg6WO+X52I16TZt8uEjbtdkgLC0SUX/xnAksjjrQI4Xfn4iBRoI5j55dmlOhDv1Y7BoR3cU7e3WWhC6A==", + "license": "MIT" + }, "node_modules/@lightsparkdev/core": { "version": "1.4.8", "resolved": "https://registry.npmjs.org/@lightsparkdev/core/-/core-1.4.8.tgz", diff --git a/package.json b/package.json index af1158c7c6..60bab8cd48 100644 --- a/package.json +++ b/package.json @@ -27,15 +27,16 @@ }, "dependencies": { "@arbitrum/sdk": "^3.7.3", - "@buildonspark/spark-sdk": "^0.3.5", "@azure/storage-blob": "^12.29.1", "@blockfrost/blockfrost-js": "^6.1.0", + "@buildonspark/spark-sdk": "^0.3.5", "@cardano-foundation/cardano-verify-datasignature": "^1.0.11", "@deuro/eurocoin": "^1.0.16", "@dhedge/v2-sdk": "^1.11.1", "@emurgo/cardano-serialization-lib-nodejs": "^15.0.3", "@eth-optimism/sdk": "^3.3.3", "@frankencoin/zchf": "^0.2.36", + "@juicedollar/jusd": "^3.0.1", "@maticnetwork/maticjs": "^3.9.6", "@maticnetwork/maticjs-ethers": "^1.1.0", "@nestjs-modules/mailer": "^1.11.2", diff --git a/src/config/chains.config.ts b/src/config/chains.config.ts index 86ef8869b9..d1ecbc54cd 100644 --- a/src/config/chains.config.ts +++ b/src/config/chains.config.ts @@ -56,11 +56,11 @@ export const EVM_CHAINS = { }, citreaTestnet: { chainId: 5115, - gatewayUrl: 'http://10.0.1.6:8085', + gatewayUrl: 'https://rpc.testnet.citreascan.com', }, citrea: { chainId: 4114, - gatewayUrl: 'http://10.0.1.6:8085', + gatewayUrl: 'https://rpc.citreascan.com', }, } satisfies Record; diff --git a/src/config/config.ts b/src/config/config.ts index a445dc34e9..2ac230df19 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -804,6 +804,7 @@ export class Configuration { citreaWalletAddress: process.env.CITREA_WALLET_ADDRESS, citreaWalletPrivateKey: process.env.CITREA_WALLET_PRIVATE_KEY, citreaApiKey: process.env.CITREA_API_KEY, + blockscoutApiUrl: process.env.CITREA_BLOCKSCOUT_API_URL, }, citreaTestnet: { ...EVM_CHAINS.citreaTestnet, @@ -812,7 +813,7 @@ export class Configuration { citreaTestnetWalletAddress: process.env.CITREA_TESTNET_WALLET_ADDRESS, citreaTestnetWalletPrivateKey: process.env.CITREA_TESTNET_WALLET_PRIVATE_KEY, citreaTestnetApiKey: process.env.CITREA_TESTNET_API_KEY, - goldskySubgraphUrl: process.env.CITREA_TESTNET_GOLDSKY_SUBGRAPH_URL, + blockscoutApiUrl: process.env.CITREA_TESTNET_BLOCKSCOUT_API_URL, }, lightning: { lnbits: { @@ -914,6 +915,10 @@ export class Configuration { graphUrl: process.env.DEURO_GRAPH_URL, apiUrl: process.env.DEURO_API_URL, }, + juice: { + graphUrl: process.env.JUSD_GRAPH_URL, + apiUrl: process.env.JUSD_API_URL, + }, realunit: { graphUrl: process.env.REALUNIT_GRAPH_URL, api: { diff --git a/src/integration/blockchain/blockchain.module.ts b/src/integration/blockchain/blockchain.module.ts index c866814226..f8eb2ba78d 100644 --- a/src/integration/blockchain/blockchain.module.ts +++ b/src/integration/blockchain/blockchain.module.ts @@ -8,9 +8,11 @@ import { ArweaveModule } from './arweave/arweave.module'; import { BlockchainApiModule } from './api/blockchain-api.module'; import { BaseModule } from './base/base.module'; import { BscModule } from './bsc/bsc.module'; +import { CitreaModule } from './citrea/citrea.module'; import { CitreaTestnetModule } from './citrea-testnet/citrea-testnet.module'; import { DEuroModule } from './deuro/deuro.module'; import { Ebel2xModule } from './ebel2x/ebel2x.module'; +import { JuiceModule } from './juice/juice.module'; import { EthereumModule } from './ethereum/ethereum.module'; import { FrankencoinModule } from './frankencoin/frankencoin.module'; import { GnosisModule } from './gnosis/gnosis.module'; @@ -50,12 +52,14 @@ import { ZanoModule } from './zano/zano.module'; ZanoModule, FrankencoinModule, DEuroModule, + JuiceModule, Ebel2xModule, ArweaveModule, RailgunModule, SolanaModule, TronModule, CardanoModule, + CitreaModule, CitreaTestnetModule, RealUnitBlockchainModule, Eip7702DelegationModule, @@ -78,11 +82,13 @@ import { ZanoModule } from './zano/zano.module'; ZanoModule, FrankencoinModule, DEuroModule, + JuiceModule, Ebel2xModule, RailgunModule, SolanaModule, TronModule, CardanoModule, + CitreaModule, CitreaTestnetModule, CryptoService, BlockchainRegistryService, diff --git a/src/integration/blockchain/citrea-testnet/citrea-testnet-client.ts b/src/integration/blockchain/citrea-testnet/citrea-testnet-client.ts index 6c434f3308..291b685c73 100644 --- a/src/integration/blockchain/citrea-testnet/citrea-testnet-client.ts +++ b/src/integration/blockchain/citrea-testnet/citrea-testnet-client.ts @@ -1,7 +1,9 @@ import { ethers } from 'ethers'; import ERC20_ABI from 'src/integration/blockchain/shared/evm/abi/erc20.abi.json'; -import { GoldskyService, GoldskyTokenTransfer, GoldskyTransfer } from 'src/integration/goldsky/goldsky.service'; -import { GoldskyNetwork } from 'src/integration/goldsky/goldsky.types'; +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'; @@ -12,15 +14,11 @@ import { EvmCoinHistoryEntry, EvmTokenHistoryEntry } from '../shared/evm/interfa export class CitreaTestnetClient extends EvmClient { protected override readonly logger = new DfxLogger(CitreaTestnetClient); - private readonly goldsky?: GoldskyService; - private readonly maxRpcBlockRange = 100; - constructor(params: EvmClientParams) { super({ ...params, alchemyService: undefined, // Citrea not supported by Alchemy }); - this.goldsky = params.goldskyService; } // Alchemy method overrides @@ -64,81 +62,56 @@ export class CitreaTestnetClient extends EvmClient { } // --- HISTORY --- // - // TODO: test & cleanup async getNativeCoinTransactions( walletAddress: string, fromBlock: number, - toBlock?: number, + _toBlock?: number, direction = Direction.BOTH, ): Promise { - try { - const transfers = await this.goldsky.getNativeCoinTransfers( - GoldskyNetwork.CITREA_TESTNET, - walletAddress, - fromBlock, - toBlock, - ); - return this.mapGoldskyToEvmCoinHistory(transfers, walletAddress, direction); - } catch (error) { - this.logger.warn(`Goldsky service failed, using RPC fallback: ${error.message}`); - } - - // fallback: get transactions using RPC (limited functionality) - return this.getNativeCoinTransactionsViaRPC(walletAddress, fromBlock, toBlock, direction); + const transactions = await this.blockscoutService.getTransactions(this.blockscoutApiUrl, walletAddress, fromBlock); + return this.mapBlockscoutToEvmCoinHistory(transactions, walletAddress, direction); } async getERC20Transactions( walletAddress: string, fromBlock: number, - toBlock?: number, + _toBlock?: number, direction = Direction.BOTH, ): Promise { - try { - const transfers = await this.goldsky.getTokenTransfers( - GoldskyNetwork.CITREA_TESTNET, - walletAddress, - fromBlock, - toBlock, - ); - return this.mapGoldskyToEvmTokenHistory(transfers, walletAddress, direction); - } catch (error) { - this.logger.warn(`Goldsky service failed, using RPC fallback: ${error.message}`); - } - - // fallback: get token transactions using RPC (limited functionality) - return this.getERC20TransactionsViaRPC(walletAddress, fromBlock, toBlock, direction); + const transfers = await this.blockscoutService.getTokenTransfers(this.blockscoutApiUrl, walletAddress, fromBlock); + return this.mapBlockscoutToEvmTokenHistory(transfers, walletAddress, direction); } - private mapGoldskyToEvmCoinHistory( - transfers: GoldskyTransfer[], + private mapBlockscoutToEvmCoinHistory( + transactions: BlockscoutTransaction[], walletAddress: string, direction: Direction, ): EvmCoinHistoryEntry[] { const lowerWallet = walletAddress.toLowerCase(); - return transfers + return transactions .filter((tx) => { if (direction === Direction.INCOMING) { - return tx.to.toLowerCase() === lowerWallet; + return tx.to?.hash.toLowerCase() === lowerWallet; } else if (direction === Direction.OUTGOING) { - return tx.from.toLowerCase() === lowerWallet; + return tx.from.hash.toLowerCase() === lowerWallet; } return true; // Direction.BOTH }) .map((tx) => ({ - blockNumber: tx.blockNumber.toString(), - timeStamp: tx.blockTimestamp.toString(), - hash: tx.transactionHash, - from: tx.from, - to: tx.to, + blockNumber: tx.block_number.toString(), + timeStamp: tx.timestamp, + hash: tx.hash, + from: tx.from.hash, + to: tx.to?.hash || '', value: tx.value, contractAddress: '', })); } - private mapGoldskyToEvmTokenHistory( - transfers: GoldskyTokenTransfer[], + private mapBlockscoutToEvmTokenHistory( + transfers: BlockscoutTokenTransfer[], walletAddress: string, direction: Direction, ): EvmTokenHistoryEntry[] { @@ -147,155 +120,22 @@ export class CitreaTestnetClient extends EvmClient { return transfers .filter((tx) => { if (direction === Direction.INCOMING) { - return tx.to.toLowerCase() === lowerWallet; + return tx.to.hash.toLowerCase() === lowerWallet; } else if (direction === Direction.OUTGOING) { - return tx.from.toLowerCase() === lowerWallet; + return tx.from.hash.toLowerCase() === lowerWallet; } return true; // Direction.BOTH }) .map((tx) => ({ - blockNumber: tx.blockNumber.toString(), - timeStamp: tx.blockTimestamp.toString(), - hash: tx.transactionHash, - from: tx.from, - contractAddress: tx.contractAddress, - to: tx.to, - value: tx.value, - tokenName: tx.tokenName || tx.tokenSymbol, - tokenDecimal: tx.tokenDecimals?.toString(), + 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, })); } - - private async getNativeCoinTransactionsViaRPC( - walletAddress: string, - fromBlock: number, - toBlock?: number, - direction = Direction.BOTH, - ): Promise { - // This is a simplified fallback that only gets recent block transactions - // It's not as efficient as the subgraph but provides basic functionality - const transactions: EvmCoinHistoryEntry[] = []; - const lowerWallet = walletAddress.toLowerCase(); - const endBlock = toBlock ?? (await this.provider.getBlockNumber()); - - // Limit the range to avoid overwhelming the RPC - const startBlock = Math.max(fromBlock, endBlock - this.maxRpcBlockRange); - - this.logger.info(`Fetching transactions via RPC from block ${startBlock} to ${endBlock}`); - - for (let blockNum = startBlock; blockNum <= endBlock; blockNum++) { - try { - const block = await this.provider.getBlockWithTransactions(blockNum); - if (!block) continue; - - for (const tx of block.transactions) { - const fromMatch = tx.from?.toLowerCase() === lowerWallet; - const toMatch = tx.to?.toLowerCase() === lowerWallet; - - if ( - (direction === Direction.INCOMING && !toMatch) || - (direction === Direction.OUTGOING && !fromMatch) || - (direction === Direction.BOTH && !fromMatch && !toMatch) - ) - continue; - - transactions.push({ - blockNumber: blockNum.toString(), - timeStamp: block.timestamp.toString(), - hash: tx.hash, - from: tx.from, - to: tx.to || '', - value: tx.value.toString(), - contractAddress: '', - }); - } - } catch (error) { - this.logger.warn(`Failed to fetch block ${blockNum}: ${error.message}`); - } - } - - return transactions; - } - - private async getERC20TransactionsViaRPC( - walletAddress: string, - fromBlock: number, - toBlock?: number, - direction = Direction.BOTH, - ): Promise { - // For ERC20 transactions, we need to use event logs - const transactions: EvmTokenHistoryEntry[] = []; - const lowerWallet = walletAddress.toLowerCase(); - const endBlock = toBlock ?? (await this.provider.getBlockNumber()); - - // Limit the range to avoid overwhelming the RPC - const startBlock = Math.max(fromBlock, endBlock - this.maxRpcBlockRange); - - this.logger.info(`Fetching ERC20 transfers via RPC from block ${startBlock} to ${endBlock}`); - - // ERC20 Transfer event signature - const transferEventSignature = ethers.utils.id('Transfer(address,address,uint256)'); - - try { - // Create filters for incoming and outgoing transfers - const filters = []; - - if (direction === Direction.INCOMING || direction === Direction.BOTH) { - filters.push({ - topics: [ - transferEventSignature, - null, // from (any) - ethers.utils.hexZeroPad(lowerWallet, 32), // to (our address) - ], - fromBlock: startBlock, - toBlock: endBlock, - }); - } - - if (direction === Direction.OUTGOING || direction === Direction.BOTH) { - filters.push({ - topics: [ - transferEventSignature, - ethers.utils.hexZeroPad(lowerWallet, 32), // from (our address) - null, // to (any) - ], - fromBlock: startBlock, - toBlock: endBlock, - }); - } - - for (const filter of filters) { - const logs = await this.provider.getLogs(filter); - - for (const log of logs) { - const block = await this.provider.getBlock(log.blockNumber); - - // Decode the transfer event - const decoded = ethers.utils.defaultAbiCoder.decode(['uint256'], log.data); - - transactions.push({ - blockNumber: log.blockNumber.toString(), - timeStamp: block.timestamp.toString(), - hash: log.transactionHash, - from: ethers.utils.getAddress('0x' + log.topics[1].slice(26)), - contractAddress: log.address, - to: ethers.utils.getAddress('0x' + log.topics[2].slice(26)), - value: decoded[0].toString(), - tokenName: '', - tokenDecimal: '', - }); - } - } - } catch (error) { - this.logger.error(`Failed to fetch ERC20 transfers: ${error.message}`); - } - - // Remove duplicates (same tx might appear in both incoming and outgoing) - const uniqueTransactions = transactions.filter( - (tx, index, self) => - index === self.findIndex((t) => t.hash === tx.hash && t.contractAddress === tx.contractAddress), - ); - - return uniqueTransactions; - } } diff --git a/src/integration/blockchain/citrea-testnet/citrea-testnet.module.ts b/src/integration/blockchain/citrea-testnet/citrea-testnet.module.ts index d657dea40b..4daca7d825 100644 --- a/src/integration/blockchain/citrea-testnet/citrea-testnet.module.ts +++ b/src/integration/blockchain/citrea-testnet/citrea-testnet.module.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common'; -import { GoldskyModule } from 'src/integration/goldsky/goldsky.module'; +import { BlockscoutModule } from 'src/integration/blockchain/shared/blockscout/blockscout.module'; import { SharedModule } from 'src/shared/shared.module'; import { CitreaTestnetService } from './citrea-testnet.service'; @Module({ - imports: [SharedModule, GoldskyModule], + imports: [SharedModule, BlockscoutModule], providers: [CitreaTestnetService], exports: [CitreaTestnetService], }) diff --git a/src/integration/blockchain/citrea-testnet/citrea-testnet.service.ts b/src/integration/blockchain/citrea-testnet/citrea-testnet.service.ts index 2cde282b08..19a2264526 100644 --- a/src/integration/blockchain/citrea-testnet/citrea-testnet.service.ts +++ b/src/integration/blockchain/citrea-testnet/citrea-testnet.service.ts @@ -1,15 +1,20 @@ import { Injectable } from '@nestjs/common'; import { GetConfig } from 'src/config/config'; -import { GoldskyService } from 'src/integration/goldsky/goldsky.service'; +import { BlockscoutService } from 'src/integration/blockchain/shared/blockscout/blockscout.service'; import { HttpService } from 'src/shared/services/http.service'; import { EvmService } from '../shared/evm/evm.service'; import { CitreaTestnetClient } from './citrea-testnet-client'; @Injectable() export class CitreaTestnetService extends EvmService { - constructor(http: HttpService, goldskyService: GoldskyService) { - const { citreaTestnetGatewayUrl, citreaTestnetApiKey, citreaTestnetWalletPrivateKey, citreaTestnetChainId } = - GetConfig().blockchain.citreaTestnet; + constructor(http: HttpService, blockscoutService: BlockscoutService) { + const { + citreaTestnetGatewayUrl, + citreaTestnetApiKey, + citreaTestnetWalletPrivateKey, + citreaTestnetChainId, + blockscoutApiUrl, + } = GetConfig().blockchain.citreaTestnet; super(CitreaTestnetClient, { http, @@ -17,7 +22,8 @@ export class CitreaTestnetService extends EvmService { apiKey: citreaTestnetApiKey, walletPrivateKey: citreaTestnetWalletPrivateKey, chainId: citreaTestnetChainId, - goldskyService, + blockscoutService, + blockscoutApiUrl, }); } } diff --git a/src/integration/blockchain/citrea/citrea-client.ts b/src/integration/blockchain/citrea/citrea-client.ts new file mode 100644 index 0000000000..824b3a6a68 --- /dev/null +++ b/src/integration/blockchain/citrea/citrea-client.ts @@ -0,0 +1,141 @@ +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'; + +export class CitreaClient extends EvmClient { + 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, + })); + } +} diff --git a/src/integration/blockchain/citrea/citrea.module.ts b/src/integration/blockchain/citrea/citrea.module.ts new file mode 100644 index 0000000000..c67e09f33b --- /dev/null +++ b/src/integration/blockchain/citrea/citrea.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { BlockscoutModule } from 'src/integration/blockchain/shared/blockscout/blockscout.module'; +import { SharedModule } from 'src/shared/shared.module'; +import { CitreaService } from './citrea.service'; + +@Module({ + imports: [SharedModule, BlockscoutModule], + providers: [CitreaService], + exports: [CitreaService], +}) +export class CitreaModule {} diff --git a/src/integration/blockchain/citrea/citrea.service.ts b/src/integration/blockchain/citrea/citrea.service.ts new file mode 100644 index 0000000000..565f273b31 --- /dev/null +++ b/src/integration/blockchain/citrea/citrea.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { GetConfig } from 'src/config/config'; +import { BlockscoutService } from 'src/integration/blockchain/shared/blockscout/blockscout.service'; +import { HttpService } from 'src/shared/services/http.service'; +import { EvmService } from '../shared/evm/evm.service'; +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; + + super(CitreaClient, { + http, + gatewayUrl: citreaGatewayUrl, + apiKey: citreaApiKey, + walletPrivateKey: citreaWalletPrivateKey, + chainId: citreaChainId, + blockscoutService, + blockscoutApiUrl, + }); + } +} diff --git a/src/integration/blockchain/juice/controllers/juice.controller.ts b/src/integration/blockchain/juice/controllers/juice.controller.ts new file mode 100644 index 0000000000..71884c7181 --- /dev/null +++ b/src/integration/blockchain/juice/controllers/juice.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { JuiceService } from '../juice.service'; +import { JuiceInfoDto } from '../dto/juice.dto'; + +@ApiTags('Juice') +@Controller('juice') +export class JuiceController { + constructor(private readonly service: JuiceService) {} + + @Get('info') + async getInfo(): Promise { + return this.service.getJuiceInfo(); + } +} diff --git a/src/integration/blockchain/juice/dto/juice.dto.ts b/src/integration/blockchain/juice/dto/juice.dto.ts new file mode 100644 index 0000000000..f22e409a3e --- /dev/null +++ b/src/integration/blockchain/juice/dto/juice.dto.ts @@ -0,0 +1,91 @@ +import { FrankencoinBasedCollateralDto } from '../../shared/frankencoin/frankencoin-based.dto'; + +export interface JuicePositionGraphDto extends FrankencoinBasedCollateralDto { + id: string; + position: string; + owner: string; + jusd: string; + price: string; + limitForClones: string; + availableForClones: string; + principal: string; + reserveContribution: number; + expiration: string; + closed: boolean; + denied: boolean; +} + +export interface JuiceEquityGraphDto { + id: string; + profits: string; + loss: string; + reserve: string; +} + +export interface JuiceLogDto { + positionV2s: JuicePositionDto[]; + poolShares: JuicePoolSharesDto; + savings: JuiceSavingsLogDto; + bridges: JuiceBridgeLogDto[]; + totalSupply: number; + totalValueLocked: number; + totalBorrowed: number; +} + +export interface JuiceInfoDto { + totalSupplyJusd: number; + totalValueLockedInUsd: number; + juiceMarketCapInUsd: number; +} + +export interface JuicePositionDto { + address: { + position: string; + jusd: string; + collateral: string; + owner: string; + }; + collateral: { + symbol: string; + amount: number; + }; + details: { + availableAmount: number; + totalBorrowed: number; + liquidationPrice: number; + virtualPrice: number; + retainedReserve: number; + limit: number; + expirationDate: Date; + }; +} + +export interface JuicePoolSharesDto { + juicePrice: number; + supply: number; + marketCap: number; + totalReserve: number; + equityCapital: number; + minterReserve: number; + totalIncome: number; + totalLosses: number; +} + +export interface JuiceSavingsInfoDto { + totalSaved: number; + totalWithdrawn: number; + totalBalance: number; + totalInterest: number; + rate: number; + ratioOfSupply: number; +} + +export interface JuiceSavingsLogDto { + totalSaved: number; + totalBalance: number; +} + +export interface JuiceBridgeLogDto { + symbol: string; + minted: number; +} diff --git a/src/integration/blockchain/juice/juice-client.ts b/src/integration/blockchain/juice/juice-client.ts new file mode 100644 index 0000000000..05e5dc6316 --- /dev/null +++ b/src/integration/blockchain/juice/juice-client.ts @@ -0,0 +1,217 @@ +import { ADDRESS, EquityABI, ERC20ABI, JuiceDollarABI, PositionV2ABI, StablecoinBridgeABI } from '@juicedollar/jusd'; +import { Contract, ethers } from 'ethers'; +import { gql, request } from 'graphql-request'; +import { Config } from 'src/config/config'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { EvmClient } from '../shared/evm/evm-client'; +import { EvmUtil } from '../shared/evm/evm.util'; +import { JuiceEquityGraphDto, JuicePositionGraphDto, JuiceSavingsInfoDto } from './dto/juice.dto'; + +interface GraphQLPageInfo { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string; + endCursor: string; +} + +export class JuiceClient { + constructor(private readonly evmClient: EvmClient) {} + + async getPositionV2s(): Promise { + const graphUrl = Config.blockchain.juice.graphUrl; + if (!graphUrl) return []; + + let gqlResult = await request<{ positionV2s: { items: [JuicePositionGraphDto]; pageInfo: GraphQLPageInfo } }>( + graphUrl, + gql` + ${this.createGQLPositionV2s()} + `, + ); + + const positionV2s: JuicePositionGraphDto[] = gqlResult.positionV2s.items; + + while (gqlResult.positionV2s.pageInfo.hasNextPage) { + gqlResult = await request<{ positionV2s: { items: [JuicePositionGraphDto]; pageInfo: GraphQLPageInfo } }>( + graphUrl, + gql` + ${this.createGQLPositionV2s(gqlResult.positionV2s.pageInfo.endCursor)} + `, + ); + + positionV2s.push(...gqlResult.positionV2s.items); + } + + return positionV2s; + } + + private createGQLPositionV2s(after?: string): string { + const gqlParams = after ? `(after: "${after}")` : ''; + + return `{ + positionV2s${gqlParams} { + items { + id + position + owner + jusd + collateral + price + collateralSymbol + collateralBalance + collateralDecimals + limitForClones + availableForClones + principal + reserveContribution + expiration + closed + denied + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + }`; + } + + async getSavingsInfo(): Promise { + const apiUrl = Config.blockchain.juice.apiUrl; + if (!apiUrl) { + return { + totalSaved: 0, + totalWithdrawn: 0, + totalBalance: 0, + totalInterest: 0, + rate: 0, + ratioOfSupply: 0, + }; + } + + const url = `${apiUrl}/savings/core/info`; + return this.evmClient.http.get(url); + } + + async getJuice(): Promise { + const graphUrl = Config.blockchain.juice.graphUrl; + if (!graphUrl) return null; + + const address = ADDRESS[this.evmClient.chainId].juiceDollar; + + const document = gql` + { + jUICE(id: "${address}") { + id + profits + loss + reserve + } + } + `; + + return request<{ jUICE: JuiceEquityGraphDto }>(graphUrl, document).then((r) => r.jUICE); + } + + getWalletAddress(): string { + return this.evmClient.wallet.address; + } + + getJusdContract(): Contract { + return new Contract(ADDRESS[this.evmClient.chainId].juiceDollar, JuiceDollarABI, this.evmClient.wallet); + } + + getEquityContract(): Contract { + return new Contract(ADDRESS[this.evmClient.chainId].equity, EquityABI, this.evmClient.wallet); + } + + getPositionContract(address: string): Contract { + return new Contract(address, PositionV2ABI, this.evmClient.wallet); + } + + getErc20Contract(address: string): Contract { + return new Contract(address, ERC20ABI, this.evmClient.wallet); + } + + getBridgeContracts(): Contract[] { + const contracts: Contract[] = [this.getBridgeStartUSDContract()]; + + const addresses = ADDRESS[this.evmClient.chainId]; + if (addresses.bridgeUSDC) contracts.push(this.getBridgeUSDCContract()); + if (addresses.bridgeUSDT) contracts.push(this.getBridgeUSDTContract()); + if (addresses.bridgeCTUSD) contracts.push(this.getBridgeCTUSDContract()); + + return contracts; + } + + getBridgeContract(assetName: string): Contract { + switch (assetName) { + case 'StartUSD': + return this.getBridgeStartUSDContract(); + case 'USDC': + return this.getBridgeUSDCContract(); + case 'USDT': + return this.getBridgeUSDTContract(); + case 'CTUSD': + return this.getBridgeCTUSDContract(); + default: + throw new Error(`No bridge contract found for asset: ${assetName}`); + } + } + + getBridgeStartUSDContract(): Contract { + return new Contract(ADDRESS[this.evmClient.chainId].bridgeStartUSD, StablecoinBridgeABI, this.evmClient.wallet); + } + + getBridgeUSDCContract(): Contract { + const address = ADDRESS[this.evmClient.chainId].bridgeUSDC; + if (!address) throw new Error('USDC bridge not available on this chain'); + return new Contract(address, StablecoinBridgeABI, this.evmClient.wallet); + } + + getBridgeUSDTContract(): Contract { + const address = ADDRESS[this.evmClient.chainId].bridgeUSDT; + if (!address) throw new Error('USDT bridge not available on this chain'); + return new Contract(address, StablecoinBridgeABI, this.evmClient.wallet); + } + + getBridgeCTUSDContract(): Contract { + const address = ADDRESS[this.evmClient.chainId].bridgeCTUSD; + if (!address) throw new Error('CTUSD bridge not available on this chain'); + return new Contract(address, StablecoinBridgeABI, this.evmClient.wallet); + } + + async bridgeToJusd(asset: Asset, amount: number): Promise { + const bridgeContract = this.getBridgeContract(asset.name); + + if (!asset.decimals) throw new Error(`Asset ${asset.name} has no decimals`); + if (!asset.chainId) throw new Error(`Asset ${asset.name} has no chainId`); + + const remainingCapacity = await this.getBridgeRemainingCapacity(asset.name); + if (remainingCapacity < amount) { + throw new Error( + `Bridge capacity exceeded for ${asset.name} (remaining: ${remainingCapacity} JUSD, requested: ${amount} ${asset.name})`, + ); + } + + const weiAmount = EvmUtil.toWeiAmount(amount, asset.decimals); + const eurTokenContract = this.getErc20Contract(asset.chainId); + + const allowance = await eurTokenContract.allowance(this.evmClient.wallet.address, bridgeContract.address); + if (allowance.lt(weiAmount)) { + const approveTx = await eurTokenContract.approve(bridgeContract.address, ethers.constants.MaxUint256); + await approveTx.wait(); + } + + const tx = await bridgeContract.mint(weiAmount); + return tx.hash; + } + + private async getBridgeRemainingCapacity(assetName: string): Promise { + const bridgeContract = this.getBridgeContract(assetName); + const limit = await bridgeContract.limit(); + const minted = await bridgeContract.minted(); + return EvmUtil.fromWeiAmount(limit.sub(minted), 18); // bridge capacity is in JUSD = 18 decimals + } +} diff --git a/src/integration/blockchain/juice/juice.module.ts b/src/integration/blockchain/juice/juice.module.ts new file mode 100644 index 0000000000..983e975b5c --- /dev/null +++ b/src/integration/blockchain/juice/juice.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from 'src/shared/shared.module'; +import { LogModule } from 'src/subdomains/supporting/log/log.module'; +import { JuiceController } from './controllers/juice.controller'; +import { JuiceService } from './juice.service'; + +@Module({ + imports: [SharedModule, LogModule], + controllers: [JuiceController], + providers: [JuiceService], + exports: [JuiceService], +}) +export class JuiceModule {} diff --git a/src/integration/blockchain/juice/juice.service.ts b/src/integration/blockchain/juice/juice.service.ts new file mode 100644 index 0000000000..b29b06d612 --- /dev/null +++ b/src/integration/blockchain/juice/juice.service.ts @@ -0,0 +1,290 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { CronExpression } from '@nestjs/schedule'; +import { Contract } from 'ethers'; +import { Config } from 'src/config/config'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { Process } from 'src/shared/services/process.service'; +import { DfxCron } from 'src/shared/utils/cron'; +import { Util } from 'src/shared/utils/util'; +import { CreateLogDto } from 'src/subdomains/supporting/log/dto/create-log.dto'; +import { LogSeverity } from 'src/subdomains/supporting/log/log.entity'; +import { LogService } from 'src/subdomains/supporting/log/log.service'; +import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { CollateralWithTotalBalance } from '../shared/dto/frankencoin-based.dto'; +import { Blockchain } from '../shared/enums/blockchain.enum'; +import { EvmClient } from '../shared/evm/evm-client'; +import { EvmUtil } from '../shared/evm/evm.util'; +import { FrankencoinBasedService } from '../shared/frankencoin/frankencoin-based.service'; +import { BlockchainRegistryService } from '../shared/services/blockchain-registry.service'; +import { + JuiceBridgeLogDto, + JuiceInfoDto, + JuiceLogDto, + JuicePoolSharesDto, + JuicePositionDto, + JuicePositionGraphDto, + JuiceSavingsInfoDto, + JuiceSavingsLogDto, +} from './dto/juice.dto'; +import { JuiceClient } from './juice-client'; + +@Injectable() +export class JuiceService extends FrankencoinBasedService implements OnModuleInit { + private static readonly LOG_SYSTEM = 'EvmInformation'; + private static readonly LOG_SUBSYSTEM = 'JuiceSmartContract'; + + private juiceClient: JuiceClient; + + constructor( + private readonly moduleRef: ModuleRef, + private readonly logService: LogService, + ) { + super(); + } + + onModuleInit() { + this.setup( + this.moduleRef.get(PricingService, { strict: false }), + this.moduleRef.get(BlockchainRegistryService, { strict: false }), + ); + + this.juiceClient = new JuiceClient(this.getEvmClient()); + } + + // Override to use Citrea instead of Ethereum + getEvmClient(): EvmClient { + return this.registryService.getClient(Blockchain.CITREA) as EvmClient; + } + + @DfxCron(CronExpression.EVERY_10_MINUTES, { process: Process.JUICE_LOG_INFO }) + async processLogInfo(): Promise { + if (!Config.blockchain.juice.graphUrl) { + this.logger.warn('Juice graphUrl not configured - skipping processLogInfo'); + return; + } + + const collateralTvl = await this.getCollateralTvl(); + const bridgeTvl = await this.getBridgeTvl(); + const totalValueLocked = collateralTvl + bridgeTvl; + + const positionV2s = await this.getPositionV2s(); + const totalBorrowed = Util.sum(positionV2s.map((p) => p.details.totalBorrowed)); + + const logMessage: JuiceLogDto = { + positionV2s, + poolShares: await this.getJuice(), + savings: await this.getSavingsLogInfo(), + bridges: await this.getBridgeLogInfo(), + totalSupply: await this.getTotalSupply(), + totalValueLocked, + totalBorrowed, + }; + + const log: CreateLogDto = { + system: JuiceService.LOG_SYSTEM, + subsystem: JuiceService.LOG_SUBSYSTEM, + severity: LogSeverity.INFO, + message: JSON.stringify(logMessage), + valid: null, + category: null, + }; + + await this.logService.create(log); + } + + async getPositionV2s(): Promise { + const positions = await this.juiceClient.getPositionV2s(); + return this.getPositions(positions); + } + + private async getPositions(positions: JuicePositionGraphDto[]): Promise { + const positionsResult: JuicePositionDto[] = []; + + for (const position of positions) { + try { + const jusdContract = this.juiceClient.getJusdContract(); + const calculateAssignedReserve = await jusdContract.calculateAssignedReserve( + position.principal, + position.reserveContribution, + ); + + const positionContract = this.juiceClient.getPositionContract(position.id); + const virtualPrice = await positionContract.virtualPrice(); + + positionsResult.push({ + address: { + position: position.position, + jusd: position.jusd, + collateral: position.collateral, + owner: position.owner, + }, + collateral: { + symbol: position.collateralSymbol, + amount: EvmUtil.fromWeiAmount(position.collateralBalance, position.collateralDecimals), + }, + details: { + availableAmount: EvmUtil.fromWeiAmount(position.availableForClones), + totalBorrowed: EvmUtil.fromWeiAmount(position.principal), + liquidationPrice: EvmUtil.fromWeiAmount(position.price, 36 - position.collateralDecimals), + virtualPrice: EvmUtil.fromWeiAmount(virtualPrice, 36 - position.collateralDecimals), + retainedReserve: EvmUtil.fromWeiAmount(calculateAssignedReserve), + limit: EvmUtil.fromWeiAmount(position.limitForClones), + expirationDate: new Date(Number(position.expiration) * 1000), + }, + }); + } catch (e) { + this.logger.error(`Error while getting position ${position.position}`, e); + } + } + + return positionsResult; + } + + async getJuice(): Promise { + const equityContract = this.getEquityContract(); + const jusdContract = this.juiceClient.getJusdContract(); + const juice = await this.juiceClient.getJuice(); + + try { + const totalSupply = await equityContract.totalSupply(); + const price = await equityContract.price(); + const jusdMinterReserve = await jusdContract.minterReserve(); + const jusdEquity = await jusdContract.equity(); + + const juiceResult: JuicePoolSharesDto = { + juicePrice: EvmUtil.fromWeiAmount(price), + supply: EvmUtil.fromWeiAmount(totalSupply), + marketCap: EvmUtil.fromWeiAmount(totalSupply) * EvmUtil.fromWeiAmount(price), + totalReserve: EvmUtil.fromWeiAmount(jusdMinterReserve) + EvmUtil.fromWeiAmount(jusdEquity), + equityCapital: EvmUtil.fromWeiAmount(jusdEquity), + minterReserve: EvmUtil.fromWeiAmount(jusdMinterReserve), + totalIncome: EvmUtil.fromWeiAmount(juice?.profits ?? '0x0'), + totalLosses: EvmUtil.fromWeiAmount(juice?.loss ?? '0x0'), + }; + + return juiceResult; + } catch (e) { + this.logger.error(`Error while getting pool shares ${juice?.id ?? 0}`, e); + } + } + + private async getTotalSupply(): Promise { + const jusdContract = this.juiceClient.getJusdContract(); + const jusdTotalSupply = await jusdContract.totalSupply(); + + return EvmUtil.fromWeiAmount(jusdTotalSupply); + } + + getWalletAddress(): string { + return this.juiceClient.getWalletAddress(); + } + + getEquityContract(): Contract { + return this.juiceClient.getEquityContract(); + } + + getWrapperContract(): Contract { + return null; + } + + async getEquityPrice(): Promise { + return this.getJuicePrice(); + } + + async getCustomCollateralPrice(_collateral: CollateralWithTotalBalance): Promise { + return undefined; + } + + async getJuicePrice(): Promise { + const equityContract = this.getEquityContract(); + const price = await equityContract.price(); + + return EvmUtil.fromWeiAmount(price); + } + + async getCollateralTvl(): Promise { + const positionV2s = await this.juiceClient.getPositionV2s(); + + const collaterals = positionV2s.map((p) => { + return { + collateral: p.collateral, + collateralSymbol: p.collateralSymbol, + collateralBalance: p.collateralBalance, + collateralDecimals: p.collateralDecimals, + }; + }); + + return this.getTvlByCollaterals(collaterals); + } + + async getBridgeTvl(): Promise { + const bridgeContracts = this.juiceClient.getBridgeContracts(); + + let tvl = 0; + + for (const bridgeContract of bridgeContracts) { + const minted = await bridgeContract.minted(); + tvl += EvmUtil.fromWeiAmount(minted); + } + + return tvl; + } + + async getSavingsLogInfo(): Promise { + return this.juiceClient.getSavingsInfo().then((s) => this.mapSavingsInfo(s)); + } + + private mapSavingsInfo(savingsInfo: JuiceSavingsInfoDto): JuiceSavingsLogDto { + return { + totalSaved: savingsInfo.totalSaved, + totalBalance: savingsInfo.totalBalance, + }; + } + + async getBridgeLogInfo(): Promise { + const bridgeLogInfo: JuiceBridgeLogDto[] = []; + + const bridgeContracts = this.juiceClient.getBridgeContracts(); + + for (const bridgeContract of bridgeContracts) { + const minted = await bridgeContract.minted(); + const stablecoinAddress = await bridgeContract.usd(); + + const stablecoinContract = this.juiceClient.getErc20Contract(stablecoinAddress); + const symbol = await stablecoinContract.symbol(); + + bridgeLogInfo.push({ symbol, minted: EvmUtil.fromWeiAmount(minted) }); + } + + return bridgeLogInfo; + } + + async bridgeToJusd(asset: Asset, amount: number): Promise { + return this.juiceClient.bridgeToJusd(asset, amount); + } + + async getJuiceInfo(): Promise { + const maxJuiceLogEntity = await this.logService.maxEntity( + JuiceService.LOG_SYSTEM, + JuiceService.LOG_SUBSYSTEM, + LogSeverity.INFO, + ); + + if (!maxJuiceLogEntity) { + return { + totalSupplyJusd: 0, + totalValueLockedInUsd: 0, + juiceMarketCapInUsd: 0, + }; + } + + const juiceLog = JSON.parse(maxJuiceLogEntity.message); + + return { + totalSupplyJusd: juiceLog.totalSupply, + totalValueLockedInUsd: juiceLog.totalValueLocked, + juiceMarketCapInUsd: juiceLog.poolShares.marketCap, + }; + } +} diff --git a/src/integration/blockchain/shared/blockscout/blockscout.module.ts b/src/integration/blockchain/shared/blockscout/blockscout.module.ts new file mode 100644 index 0000000000..21ac05d8e2 --- /dev/null +++ b/src/integration/blockchain/shared/blockscout/blockscout.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from 'src/shared/shared.module'; +import { BlockscoutService } from './blockscout.service'; + +@Module({ + imports: [SharedModule], + providers: [BlockscoutService], + exports: [BlockscoutService], +}) +export class BlockscoutModule {} diff --git a/src/integration/blockchain/shared/blockscout/blockscout.service.ts b/src/integration/blockchain/shared/blockscout/blockscout.service.ts new file mode 100644 index 0000000000..0d97ab92e7 --- /dev/null +++ b/src/integration/blockchain/shared/blockscout/blockscout.service.ts @@ -0,0 +1,111 @@ +import { Injectable } from '@nestjs/common'; +import { HttpService } from 'src/shared/services/http.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; + +export interface BlockscoutTransaction { + hash: string; + block_number: number; + timestamp: string; + from: { hash: string }; + to: { hash: string } | null; + value: string; + status: string; +} + +export interface BlockscoutTokenTransfer { + tx_hash: string; + block_number: number; + timestamp: string; + from: { hash: string }; + to: { hash: string }; + token: { + address: string; + symbol: string; + name: string; + decimals: string; + }; + total: { + value: string; + decimals: string; + }; +} + +interface BlockscoutResponse { + items: T[]; + next_page_params: { block_number: number; index: number } | null; +} + +@Injectable() +export class BlockscoutService { + private readonly logger = new DfxLogger(BlockscoutService); + + constructor(private readonly http: HttpService) {} + + async getTransactions(apiUrl: string, address: string, fromBlock?: number): Promise { + const allTransactions: BlockscoutTransaction[] = []; + let nextPageParams: { block_number: number; index: number } | null = null; + + do { + const params: Record = { filter: 'to' }; + if (nextPageParams) { + params.block_number = nextPageParams.block_number.toString(); + params.index = nextPageParams.index.toString(); + } + + const response = await this.http.get>( + `${apiUrl}/api/v2/addresses/${address}/transactions`, + { params }, + ); + + const transactions = response.items || []; + + // Filter by fromBlock if specified + const filtered = fromBlock ? transactions.filter((tx) => tx.block_number >= fromBlock) : transactions; + + allTransactions.push(...filtered); + + // Stop if we've gone past the fromBlock or no more pages + if (fromBlock && transactions.some((tx) => tx.block_number < fromBlock)) { + break; + } + + nextPageParams = response.next_page_params; + } while (nextPageParams); + + return allTransactions; + } + + async getTokenTransfers(apiUrl: string, address: string, fromBlock?: number): Promise { + const allTransfers: BlockscoutTokenTransfer[] = []; + let nextPageParams: { block_number: number; index: number } | null = null; + + do { + const params: Record = { filter: 'to', type: 'ERC-20' }; + if (nextPageParams) { + params.block_number = nextPageParams.block_number.toString(); + params.index = nextPageParams.index.toString(); + } + + const response = await this.http.get>( + `${apiUrl}/api/v2/addresses/${address}/token-transfers`, + { params }, + ); + + const transfers = response.items || []; + + // Filter by fromBlock if specified + const filtered = fromBlock ? transfers.filter((tx) => tx.block_number >= fromBlock) : transfers; + + allTransfers.push(...filtered); + + // Stop if we've gone past the fromBlock or no more pages + if (fromBlock && transfers.some((tx) => tx.block_number < fromBlock)) { + break; + } + + nextPageParams = response.next_page_params; + } while (nextPageParams); + + return allTransfers; + } +} diff --git a/src/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index 7db6a69bf5..17a7a13dae 100644 --- a/src/integration/blockchain/shared/evm/evm-client.ts +++ b/src/integration/blockchain/shared/evm/evm-client.ts @@ -13,7 +13,7 @@ import ERC1271_ABI from 'src/integration/blockchain/shared/evm/abi/erc1271.abi.j 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 { GoldskyService } from 'src/integration/goldsky/goldsky.service'; +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'; @@ -30,7 +30,8 @@ import { EvmCoinHistoryEntry, EvmTokenHistoryEntry } from './interfaces'; export interface EvmClientParams { http: HttpService; alchemyService?: AlchemyService; - goldskyService?: GoldskyService; + blockscoutService?: BlockscoutService; + blockscoutApiUrl?: string; gatewayUrl: string; apiKey: string; walletPrivateKey: string; @@ -60,7 +61,8 @@ export abstract class EvmClient extends BlockchainClient { readonly http: HttpService; private readonly alchemyService: AlchemyService; - protected readonly goldskyService?: GoldskyService; + protected readonly blockscoutService?: BlockscoutService; + protected readonly blockscoutApiUrl?: string; readonly chainId: ChainId; protected provider: ethers.providers.JsonRpcProvider; @@ -77,7 +79,8 @@ export abstract class EvmClient extends BlockchainClient { super(); this.http = params.http; this.alchemyService = params.alchemyService; - this.goldskyService = params.goldskyService; + this.blockscoutService = params.blockscoutService; + this.blockscoutApiUrl = params.blockscoutApiUrl; this.chainId = params.chainId; const url = `${params.gatewayUrl}/${params.apiKey ?? ''}`; diff --git a/src/integration/blockchain/shared/evm/evm.service.ts b/src/integration/blockchain/shared/evm/evm.service.ts index 51a9337e12..7b64775d6d 100644 --- a/src/integration/blockchain/shared/evm/evm.service.ts +++ b/src/integration/blockchain/shared/evm/evm.service.ts @@ -4,7 +4,7 @@ import { EvmClient, EvmClientParams } from './evm-client'; export abstract class EvmService extends BlockchainService { private readonly client: EvmClient; - constructor(client: new (params) => EvmClient, params: EvmClientParams) { + constructor(client: new (params: EvmClientParams) => EvmClient, params: EvmClientParams) { super(); this.client = new client(params); } diff --git a/src/integration/blockchain/shared/frankencoin/frankencoin-based.service.ts b/src/integration/blockchain/shared/frankencoin/frankencoin-based.service.ts index 139971490a..d24a34794a 100644 --- a/src/integration/blockchain/shared/frankencoin/frankencoin-based.service.ts +++ b/src/integration/blockchain/shared/frankencoin/frankencoin-based.service.ts @@ -19,7 +19,7 @@ export abstract class FrankencoinBasedService { protected readonly logger = new DfxLogger(this.constructor.name); private pricingService: PricingService; - private registryService: BlockchainRegistryService; + protected registryService: BlockchainRegistryService; getEvmClient(): EvmClient { return this.registryService.getClient(Blockchain.ETHEREUM) as EvmClient; diff --git a/src/integration/blockchain/shared/services/blockchain-registry.service.ts b/src/integration/blockchain/shared/services/blockchain-registry.service.ts index 7c16d3333b..18b7a53cd0 100644 --- a/src/integration/blockchain/shared/services/blockchain-registry.service.ts +++ b/src/integration/blockchain/shared/services/blockchain-registry.service.ts @@ -6,6 +6,7 @@ import { BitcoinNodeType, BitcoinService } from '../../bitcoin/node/bitcoin.serv import { BscService } from '../../bsc/bsc.service'; import { CardanoClient } from '../../cardano/cardano-client'; import { CardanoService } from '../../cardano/services/cardano.service'; +import { CitreaService } from '../../citrea/citrea.service'; import { CitreaTestnetService } from '../../citrea-testnet/citrea-testnet.service'; import { EthereumService } from '../../ethereum/ethereum.service'; import { GnosisService } from '../../gnosis/gnosis.service'; @@ -64,6 +65,7 @@ export class BlockchainRegistryService { private readonly solanaService: SolanaService, private readonly tronService: TronService, private readonly cardanoService: CardanoService, + private readonly citreaService: CitreaService, private readonly citreaTestnetService: CitreaTestnetService, ) {} @@ -116,6 +118,8 @@ export class BlockchainRegistryService { return this.tronService; case Blockchain.CARDANO: return this.cardanoService; + case Blockchain.CITREA: + return this.citreaService; case Blockchain.CITREA_TESTNET: return this.citreaTestnetService; diff --git a/src/integration/exchange/dto/mexc.dto.ts b/src/integration/exchange/dto/mexc.dto.ts index 2975d30edc..5696074c4c 100644 --- a/src/integration/exchange/dto/mexc.dto.ts +++ b/src/integration/exchange/dto/mexc.dto.ts @@ -61,3 +61,53 @@ export interface Withdrawal { coinId: string; vcoinId: string; } + +// --- ZCHF Assessment Period - can be removed once assessment ends --- // +export interface MexcSymbol { + symbol: string; + status: string; + baseAsset: string; + baseAssetPrecision: number; + quoteAsset: string; + quotePrecision: number; + quoteAssetPrecision: number; + baseCommissionPrecision: number; + quoteCommissionPrecision: number; + orderTypes: string[]; + isSpotTradingAllowed: boolean; + isMarginTradingAllowed: boolean; + quoteAmountPrecision: string; + baseSizePrecision: string; + permissions: string[]; + filters: unknown[]; + maxQuoteAmount: string; + makerCommission: string; + takerCommission: string; + quoteAmountPrecisionMarket: string; + maxQuoteAmountMarket: string; + fullName: string; +} + +export interface MexcExchangeInfo { + timezone: string; + serverTime: number; + rateLimits: unknown[]; + exchangeFilters: unknown[]; + symbols: MexcSymbol[]; +} + +export interface MexcOrderBook { + lastUpdateId: number; + bids: [string, string][]; // [price, quantity] + asks: [string, string][]; // [price, quantity] +} + +export interface MexcTrade { + id: number | null; + price: string; + qty: string; + quoteQty: string; + time: number; + isBuyerMaker: boolean; + isBestMatch: boolean; +} diff --git a/src/integration/exchange/services/exchange.service.ts b/src/integration/exchange/services/exchange.service.ts index c009cba01b..23590ab4ee 100644 --- a/src/integration/exchange/services/exchange.service.ts +++ b/src/integration/exchange/services/exchange.service.ts @@ -7,6 +7,7 @@ import { Exchange, Market, Order, + OrderBook, Trade, Transaction, WithdrawalResponse, @@ -227,12 +228,16 @@ export abstract class ExchangeService extends PricingProvider implements OnModul // currency pairs private async getMarkets(): Promise { if (!this.markets) { - this.markets = await this.callApi((e) => e.fetchMarkets()); + this.markets = await this.fetchMarkets(); } return this.markets; } + protected async fetchMarkets(): Promise { + return this.callApi((e) => e.fetchMarkets()); + } + async getMinTradeAmount(pair: string): Promise { return this.getMarket(pair).then((m) => m.limits.amount.min); } @@ -277,14 +282,22 @@ export abstract class ExchangeService extends PricingProvider implements OnModul private async fetchLastOrderPrice(from: string, to: string): Promise { const pair = await this.getPair(from, to); - const trades = await this.callApi((e) => e.fetchTrades(pair, undefined, 1)); + const trades = await this.fetchTrades(pair, 1); if (trades.length === 0) throw new Error(`${this.name}: no trades found for ${pair}`); return Util.sort(trades, 'timestamp', 'DESC')[0].price; } + protected async fetchTrades(pair: string, limit: number): Promise { + return this.callApi((e) => e.fetchTrades(pair, undefined, limit)); + } + + protected async fetchOrderBook(pair: string): Promise { + return this.callApi((e) => e.fetchOrderBook(pair)); + } + private async fetchCurrentOrderPrice(pair: string, direction: string): Promise { - const orderBook = await this.callApi((e) => e.fetchOrderBook(pair)); + const orderBook = await this.fetchOrderBook(pair); const { price: pricePrecision } = await this.getPrecision(pair); @@ -298,7 +311,7 @@ export abstract class ExchangeService extends PricingProvider implements OnModul const { pair, direction } = await this.getTradePair(from, to); const minAmount = await this.getMinTradeAmount(pair); - const orderBook = await this.callApi((e) => e.fetchOrderBook(pair)); + const orderBook = await this.fetchOrderBook(pair); const { price: pricePrecision } = await this.getPrecision(pair); const orders = direction === OrderSide.SELL ? orderBook.bids : orderBook.asks; diff --git a/src/integration/exchange/services/mexc.service.ts b/src/integration/exchange/services/mexc.service.ts index 06d72769aa..d2d9d50287 100644 --- a/src/integration/exchange/services/mexc.service.ts +++ b/src/integration/exchange/services/mexc.service.ts @@ -1,12 +1,21 @@ import { Injectable } from '@nestjs/common'; import { Method } from 'axios'; -import { mexc, Transaction } from 'ccxt'; +import { Market, mexc, OrderBook, Trade, Transaction } from 'ccxt'; import { Config, GetConfig } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { HttpService } from 'src/shared/services/http.service'; import { Util } from 'src/shared/utils/util'; -import { Deposit, DepositStatus, Withdrawal, WithdrawalStatus } from '../dto/mexc.dto'; +import { + Deposit, + DepositStatus, + MexcExchangeInfo, + MexcOrderBook, + MexcSymbol, + MexcTrade, + Withdrawal, + WithdrawalStatus, +} from '../dto/mexc.dto'; import { ExchangeService } from './exchange.service'; @Injectable() @@ -159,4 +168,97 @@ export class MexcService extends ExchangeService { headers: { 'X-MEXC-APIKEY': Config.mexc.apiKey, 'Content-Type': 'application/json' }, }); } + + // --- ZCHF Assessment Period - can be removed once assessment ends --- // + + protected async fetchMarkets(): Promise { + const data = await this.request('GET', 'exchangeInfo', {}); + return data.symbols.map((s) => this.toMarket(s)); + } + + private toMarket(symbol: MexcSymbol): Market { + return { + id: symbol.symbol, + symbol: `${symbol.baseAsset}/${symbol.quoteAsset}`, + base: symbol.baseAsset, + quote: symbol.quoteAsset, + baseId: symbol.baseAsset, + quoteId: symbol.quoteAsset, + active: symbol.status === '1' && symbol.isSpotTradingAllowed, + type: 'spot', + spot: true, + margin: symbol.isMarginTradingAllowed, + swap: false, + future: false, + option: false, + contract: false, + settle: undefined, + settleId: undefined, + contractSize: undefined, + linear: undefined, + inverse: undefined, + expiry: undefined, + expiryDatetime: undefined, + strike: undefined, + optionType: undefined, + taker: parseFloat(symbol.takerCommission), + maker: parseFloat(symbol.makerCommission), + percentage: true, + tierBased: false, + feeSide: 'get', + precision: { + amount: this.parsePrecision(symbol.baseAssetPrecision), + price: this.parsePrecision(symbol.quoteAssetPrecision), + }, + limits: { + amount: { min: parseFloat(symbol.baseSizePrecision), max: undefined }, + price: { min: undefined, max: undefined }, + cost: { min: parseFloat(symbol.quoteAmountPrecision), max: parseFloat(symbol.maxQuoteAmount) }, + leverage: { min: undefined, max: undefined }, + }, + created: undefined, + info: symbol, + } as Market; + } + + protected async fetchOrderBook(pair: string): Promise { + const symbol = pair.replace('/', ''); + const data = await this.request('GET', 'depth', { symbol }); + + return { + symbol: pair, + bids: data.bids.map(([price, amount]) => [parseFloat(price), parseFloat(amount)]), + asks: data.asks.map(([price, amount]) => [parseFloat(price), parseFloat(amount)]), + timestamp: undefined, + datetime: undefined, + nonce: data.lastUpdateId, + }; + } + + protected async fetchTrades(pair: string, limit: number): Promise { + const symbol = pair.replace('/', ''); + const data = await this.request('GET', 'trades', { symbol, limit: limit.toString() }); + + return data.map((t) => ({ + id: t.id?.toString(), + info: t, + timestamp: t.time, + datetime: new Date(t.time).toISOString(), + symbol: pair, + order: undefined, + type: undefined, + side: t.isBuyerMaker ? 'sell' : 'buy', + takerOrMaker: t.isBuyerMaker ? 'maker' : 'taker', + price: parseFloat(t.price), + amount: parseFloat(t.qty), + cost: parseFloat(t.quoteQty), + fee: undefined, + fees: [], + })); + } + + private parsePrecision(precision: number): number { + if (precision === 0) return 1; + return parseFloat('0.' + '0'.repeat(precision - 1) + '1'); + } } diff --git a/src/integration/goldsky/goldsky.module.ts b/src/integration/goldsky/goldsky.module.ts deleted file mode 100644 index 8aa0c7cd8a..0000000000 --- a/src/integration/goldsky/goldsky.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { GoldskyService } from './goldsky.service'; - -@Module({ - providers: [GoldskyService], - exports: [GoldskyService], -}) -export class GoldskyModule {} diff --git a/src/integration/goldsky/goldsky.service.ts b/src/integration/goldsky/goldsky.service.ts deleted file mode 100644 index cfa280528d..0000000000 --- a/src/integration/goldsky/goldsky.service.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { gql, request } from 'graphql-request'; -import { Config } from 'src/config/config'; -import { GoldskyNetwork } from './goldsky.types'; - -export interface GoldskyTransfer { - id: string; - from: string; - to: string; - value: string; - blockNumber: number; - blockTimestamp: number; - transactionHash: string; - gasUsed?: string; - gasPrice?: string; -} - -export interface GoldskyTokenTransfer extends GoldskyTransfer { - contractAddress: string; - tokenSymbol?: string; - tokenName?: string; - tokenDecimals?: number; -} - -@Injectable() -export class GoldskyService { - private getEndpoints(): Record { - return { - [GoldskyNetwork.CITREA_TESTNET]: Config.blockchain.citreaTestnet.goldskySubgraphUrl, - [GoldskyNetwork.CITREA_DEVNET]: undefined, - }; - } - - async getNativeCoinTransfers( - network: GoldskyNetwork, - address: string, - fromBlock: number, - toBlock?: number, - ): Promise { - const endpoint = this.getEndpoint(network); - - const query = gql` - query GetNativeTransfers($address: String!, $fromBlock: Int!, $toBlock: Int) { - transfers( - where: { or: [{ from: $address }, { to: $address }], blockNumber_gte: $fromBlock, blockNumber_lte: $toBlock } - orderBy: blockNumber - orderDirection: desc - first: 1000 - ) { - id - from - to - value - blockNumber - blockTimestamp - transactionHash - gasUsed - gasPrice - } - } - `; - - const variables = { - address: address.toLowerCase(), - fromBlock, - toBlock: toBlock || 999999999, - }; - - const data = await request<{ transfers: GoldskyTransfer[] }>(endpoint, query, variables); - return data.transfers || []; - } - - async getTokenTransfers( - network: GoldskyNetwork, - address: string, - fromBlock: number, - toBlock?: number, - contractAddress?: string, - ): Promise { - const endpoint = this.getEndpoint(network); - - const query = gql` - query GetTokenTransfers($address: String!, $fromBlock: Int!, $toBlock: Int, $contractAddress: String) { - tokenTransfers( - where: { - or: [ - { from: $address } - { to: $address } - ] - blockNumber_gte: $fromBlock - blockNumber_lte: $toBlock - ${contractAddress ? 'contractAddress: $contractAddress' : ''} - } - orderBy: blockNumber - orderDirection: desc - first: 1000 - ) { - id - from - to - value - blockNumber - blockTimestamp - transactionHash - contractAddress - tokenSymbol - tokenName - tokenDecimals - gasUsed - gasPrice - } - } - `; - - const variables: any = { - address: address.toLowerCase(), - fromBlock, - toBlock: toBlock || 999999999, - }; - - if (contractAddress) { - variables.contractAddress = contractAddress.toLowerCase(); - } - - const data = await request<{ tokenTransfers: GoldskyTokenTransfer[] }>(endpoint, query, variables); - return data.tokenTransfers || []; - } - - private getEndpoint(network: GoldskyNetwork): string { - const endpoint = this.getEndpoints()[network]; - if (!endpoint) throw new Error(`No Goldsky endpoint configured for ${network}`); - - return endpoint; - } -} diff --git a/src/integration/goldsky/goldsky.types.ts b/src/integration/goldsky/goldsky.types.ts deleted file mode 100644 index 9d9f293112..0000000000 --- a/src/integration/goldsky/goldsky.types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum GoldskyNetwork { - CITREA_TESTNET = 'CitreaTestnet', - CITREA_DEVNET = 'CitreaDevnet', -} diff --git a/src/shared/services/process.service.ts b/src/shared/services/process.service.ts index 1fb350513c..d606f053b4 100644 --- a/src/shared/services/process.service.ts +++ b/src/shared/services/process.service.ts @@ -42,6 +42,7 @@ export enum Process { TFA_CACHE = '2faCache', FRANKENCOIN_LOG_INFO = 'FrankencoinLogInfo', DEURO_LOG_INFO = 'DEuroLogInfo', + JUICE_LOG_INFO = 'JuiceLogInfo', WEBHOOK = 'Webhook', AUTO_CREATE_BANK_DATA = 'AutoCreateBankData', TX_SPEEDUP = 'TxSpeedup', diff --git a/src/subdomains/core/aml/enums/aml-rule.enum.ts b/src/subdomains/core/aml/enums/aml-rule.enum.ts index ec0ecbad54..3050b4467c 100644 --- a/src/subdomains/core/aml/enums/aml-rule.enum.ts +++ b/src/subdomains/core/aml/enums/aml-rule.enum.ts @@ -16,6 +16,7 @@ export enum AmlRule { RULE_13 = 13, // Checkout BankTransactionVerificationDate & KycLevel 50 RULE_14 = 14, // No phoneCallCheck RULE_15 = 15, // Force Manual Check + RULE_16 = 16, // Force phoneCallCheck for personal accounts } export const SpecialIpCountries = ['CH']; diff --git a/src/subdomains/core/aml/services/aml-helper.service.ts b/src/subdomains/core/aml/services/aml-helper.service.ts index 41f55c6d84..bb338e680b 100644 --- a/src/subdomains/core/aml/services/aml-helper.service.ts +++ b/src/subdomains/core/aml/services/aml-helper.service.ts @@ -435,6 +435,11 @@ export class AmlHelperService { case AmlRule.RULE_15: errors.push(AmlError.FORCE_MANUAL_CHECK); break; + + case AmlRule.RULE_16: + if (entity.userData.accountType === AccountType.PERSONAL && !entity.userData.phoneCallCheckDate) + errors.push(AmlError.PHONE_VERIFICATION_NEEDED); + break; } return errors; diff --git a/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.controller.spec.ts b/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.controller.spec.ts index 961ddd82b3..f6502ab2a6 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.controller.spec.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/__tests__/buy.controller.spec.ts @@ -5,6 +5,7 @@ import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { PaymentInfoService } from 'src/shared/services/payment-info.service'; import { TestSharedModule } from 'src/shared/utils/test.shared.module'; import { TestUtil } from 'src/shared/utils/test.util'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; @@ -29,6 +30,7 @@ describe('BuyController', () => { let fiatService: FiatService; let swissQrService: SwissQRService; let virtualIbanService: VirtualIbanService; + let userDataService: UserDataService; beforeEach(async () => { buyService = createMock(); @@ -42,6 +44,7 @@ describe('BuyController', () => { fiatService = createMock(); swissQrService = createMock(); virtualIbanService = createMock(); + userDataService = createMock(); const module: TestingModule = await Test.createTestingModule({ imports: [TestSharedModule], @@ -58,6 +61,7 @@ describe('BuyController', () => { { provide: FiatService, useValue: fiatService }, { provide: SwissQRService, useValue: swissQrService }, { provide: VirtualIbanService, useValue: virtualIbanService }, + { provide: UserDataService, useValue: userDataService }, TestUtil.provideConfig(), ], diff --git a/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts b/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts index 599d1e8afa..ba5603822a 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts @@ -23,10 +23,12 @@ import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { PaymentInfoService } from 'src/shared/services/payment-info.service'; import { Util } from 'src/shared/utils/util'; import { KycLevel, RiskStatus, UserDataStatus } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { UserStatus } from 'src/subdomains/generic/user/models/user/user.enum'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { CreateVirtualIbanDto } from 'src/subdomains/supporting/bank/virtual-iban/dto/create-virtual-iban.dto'; import { VirtualIbanDto } from 'src/subdomains/supporting/bank/virtual-iban/dto/virtual-iban.dto'; +import { VirtualIbanMapper } from 'src/subdomains/supporting/bank/virtual-iban/dto/virtual-iban.mapper'; import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; import { CryptoPaymentMethod, FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; import { TransactionRequestStatus } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; @@ -59,6 +61,7 @@ export class BuyController { private readonly fiatService: FiatService, private readonly swissQrService: SwissQRService, private readonly virtualIbanService: VirtualIbanService, + private readonly userDataService: UserDataService, ) {} @Get() @@ -69,14 +72,6 @@ export class BuyController { return this.buyService.getUserBuys(jwt.user).then((l) => this.toDtoList(jwt.user, l)); } - @Get(':id') - @ApiBearerAuth() - @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), BuyActiveGuard()) - @ApiOkResponse({ type: BuyDto }) - async getBuy(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise { - return this.buyService.get(jwt.account, +id).then((l) => this.toDto(jwt.user, l)); - } - @Post() @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), BuyActiveGuard()) @@ -211,11 +206,19 @@ export class BuyController { await this.transactionRequestService.confirmTransactionRequest(request); } + @Get('/personalIban') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) + @ApiOkResponse({ type: VirtualIbanDto, isArray: true }) + async getAllPersonalIbans(@GetJwt() jwt: JwtPayload): Promise { + return this.virtualIbanService.getVirtualIbansForAccount(jwt.account).then((vI) => vI.map(VirtualIbanMapper.toDto)); + } + @Post('/personalIban') @ApiBearerAuth() @UseGuards( AuthGuard(), - RoleGuard(UserRole.USER), + RoleGuard(UserRole.ACCOUNT), UserActiveGuard( [UserStatus.BLOCKED, UserStatus.DELETED], [UserDataStatus.BLOCKED, UserDataStatus.DEACTIVATED], @@ -224,23 +227,14 @@ export class BuyController { ) @ApiOkResponse({ type: VirtualIbanDto }) async createPersonalIban(@GetJwt() jwt: JwtPayload, @Body() dto: CreateVirtualIbanDto): Promise { - const user = await this.userService.getUser(jwt.user, { userData: true }); + const userData = await this.userDataService.getUserData(jwt.account); - if (user.userData.kycLevel < KycLevel.LEVEL_50) + if (userData.kycLevel < KycLevel.LEVEL_50) throw new BadRequestException('KYC level 50 or higher required for personal IBAN'); - const virtualIban = await this.virtualIbanService.createForUser(user.userData, dto.currency); + const virtualIban = await this.virtualIbanService.createForUser(userData, dto.currency); - return { - id: virtualIban.id, - iban: virtualIban.iban, - bban: virtualIban.bban, - currency: virtualIban.currency.name, - active: virtualIban.active, - status: virtualIban.status, - label: virtualIban.label, - activatedAt: virtualIban.activatedAt, - }; + return VirtualIbanMapper.toDto(virtualIban); } @Put(':id') @@ -251,6 +245,14 @@ export class BuyController { return this.buyService.updateBuy(jwt.user, +id, dto).then((b) => this.toDto(jwt.user, b)); } + @Get(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), BuyActiveGuard()) + @ApiOkResponse({ type: BuyDto }) + async getBuy(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise { + return this.buyService.get(jwt.account, +id).then((l) => this.toDto(jwt.user, l)); + } + @Get(':id/history') @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.USER)) diff --git a/src/subdomains/core/liquidity-management/adapters/actions/juice.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/juice.adapter.ts new file mode 100644 index 0000000000..df12492789 --- /dev/null +++ b/src/subdomains/core/liquidity-management/adapters/actions/juice.adapter.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { JuiceService } from 'src/integration/blockchain/juice/juice.service'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { LiquidityManagementSystem } from '../../enums'; +import { LiquidityManagementBalanceService } from '../../services/liquidity-management-balance.service'; +import { FrankencoinBasedAdapter, FrankencoinBasedAdapterCommands } from './base/frankencoin-based.adapter'; + +@Injectable() +export class JuiceAdapter extends FrankencoinBasedAdapter { + constructor( + liquidityManagementBalanceService: LiquidityManagementBalanceService, + readonly juiceService: JuiceService, + private readonly assetService: AssetService, + ) { + super(LiquidityManagementSystem.JUICE, liquidityManagementBalanceService, juiceService); + + // Juice doesn't have a wrapper contract + this.commands.delete(FrankencoinBasedAdapterCommands.WRAP); + } + + async getStableToken(): Promise { + return this.assetService.getAssetByQuery({ + name: 'JUSD', + type: AssetType.TOKEN, + blockchain: Blockchain.CITREA, + }); + } + + async getEquityToken(): Promise { + return this.assetService.getAssetByQuery({ + name: 'JUICE', + type: AssetType.TOKEN, + blockchain: Blockchain.CITREA, + }); + } +} diff --git a/src/subdomains/core/liquidity-management/enums/index.ts b/src/subdomains/core/liquidity-management/enums/index.ts index b30ff2e77b..b2005fc968 100644 --- a/src/subdomains/core/liquidity-management/enums/index.ts +++ b/src/subdomains/core/liquidity-management/enums/index.ts @@ -18,6 +18,7 @@ export enum LiquidityManagementSystem { LIQUIDITY_PIPELINE = 'LiquidityPipeline', FRANKENCOIN = 'Frankencoin', DEURO = 'dEURO', + JUICE = 'Juice', XT = 'XT', } @@ -58,6 +59,7 @@ export const LiquidityManagementExchanges = [ LiquidityManagementSystem.SCRYPT, LiquidityManagementSystem.FRANKENCOIN, LiquidityManagementSystem.DEURO, + LiquidityManagementSystem.JUICE, ]; export const LiquidityManagementBridges = [ LiquidityManagementSystem.BASE_L2_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 018602636b..097372ecfd 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 @@ -5,6 +5,7 @@ import { BinanceAdapter } from '../adapters/actions/binance.adapter'; import { DEuroAdapter } from '../adapters/actions/deuro.adapter'; 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 { LiquidityPipelineAdapter } from '../adapters/actions/liquidity-pipeline.adapter'; import { MexcAdapter } from '../adapters/actions/mexc.adapter'; @@ -33,6 +34,7 @@ export class LiquidityActionIntegrationFactory { readonly liquidityPipelineAdapter: LiquidityPipelineAdapter, readonly frankencoinAdapter: FrankencoinAdapter, readonly deuroAdapter: DEuroAdapter, + readonly juiceAdapter: JuiceAdapter, readonly xtAdapter: XtAdapter, ) { this.adapters.set(LiquidityManagementSystem.DFX_DEX, dfxDexAdapter); @@ -47,6 +49,7 @@ export class LiquidityActionIntegrationFactory { this.adapters.set(LiquidityManagementSystem.LIQUIDITY_PIPELINE, liquidityPipelineAdapter); this.adapters.set(LiquidityManagementSystem.FRANKENCOIN, frankencoinAdapter); this.adapters.set(LiquidityManagementSystem.DEURO, deuroAdapter); + this.adapters.set(LiquidityManagementSystem.JUICE, juiceAdapter); this.adapters.set(LiquidityManagementSystem.XT, xtAdapter); } diff --git a/src/subdomains/core/liquidity-management/liquidity-management.module.ts b/src/subdomains/core/liquidity-management/liquidity-management.module.ts index be7b3bb801..e9f3043d6b 100644 --- a/src/subdomains/core/liquidity-management/liquidity-management.module.ts +++ b/src/subdomains/core/liquidity-management/liquidity-management.module.ts @@ -16,6 +16,7 @@ import { BinanceAdapter } from './adapters/actions/binance.adapter'; import { DEuroAdapter } from './adapters/actions/deuro.adapter'; 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 { LiquidityPipelineAdapter } from './adapters/actions/liquidity-pipeline.adapter'; import { MexcAdapter } from './adapters/actions/mexc.adapter'; @@ -103,6 +104,7 @@ import { LiquidityManagementService } from './services/liquidity-management.serv LiquidityPipelineAdapter, FrankencoinAdapter, DEuroAdapter, + JuiceAdapter, ], exports: [LiquidityManagementService, LiquidityManagementBalanceService, LiquidityManagementPipelineService], }) diff --git a/src/subdomains/generic/kyc/controllers/kyc.controller.ts b/src/subdomains/generic/kyc/controllers/kyc.controller.ts index 2b7102f524..36b92ea510 100644 --- a/src/subdomains/generic/kyc/controllers/kyc.controller.ts +++ b/src/subdomains/generic/kyc/controllers/kyc.controller.ts @@ -25,7 +25,7 @@ import { ApiTags, ApiUnauthorizedResponse, } from '@nestjs/swagger'; -import { Request, Response } from 'express'; +import { Request } from 'express'; import { RealIP } from 'nestjs-real-ip'; import { GetConfig } from 'src/config/config'; import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; @@ -422,17 +422,6 @@ export class KycController { // --- HELPER METHODS --- // - private allowFrameIntegration(res: Response) { - res.removeHeader('X-Frame-Options'); - - const contentPolicy = res.getHeader('Content-Security-Policy') as string; - const updatedPolicy = contentPolicy - ?.split(';') - .filter((p) => !p.includes('frame-ancestors')) - .join(';'); - res.setHeader('Content-Security-Policy', updatedPolicy); - } - private fileName(type: string, file: string): string { return `${Util.isoDateTime(new Date())}_${type}_user-upload_${Util.randomId()}_${file}`; } diff --git a/src/subdomains/supporting/bank/virtual-iban/dto/virtual-iban.mapper.ts b/src/subdomains/supporting/bank/virtual-iban/dto/virtual-iban.mapper.ts new file mode 100644 index 0000000000..a371d94b8e --- /dev/null +++ b/src/subdomains/supporting/bank/virtual-iban/dto/virtual-iban.mapper.ts @@ -0,0 +1,19 @@ +import { VirtualIban } from '../virtual-iban.entity'; +import { VirtualIbanDto } from './virtual-iban.dto'; + +export class VirtualIbanMapper { + static toDto(virtualIban: VirtualIban): VirtualIbanDto { + const dto: VirtualIbanDto = { + id: virtualIban.id, + iban: virtualIban.iban, + bban: virtualIban.bban, + currency: virtualIban.currency.name, + active: virtualIban.active, + status: virtualIban.status, + label: virtualIban.label, + activatedAt: virtualIban.activatedAt, + }; + + return Object.assign(new VirtualIbanDto(), dto); + } +} diff --git a/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts b/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts index 30d1e19bff..ddb526ced0 100644 --- a/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts +++ b/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts @@ -18,13 +18,11 @@ export class VirtualIbanService { ) {} async getActiveForUserAndCurrency(userData: UserData, currencyName: string): Promise { - return this.virtualIbanRepo.findOneCached(`${userData.id}-${currencyName}`, { - where: { - userData: { id: userData.id }, - currency: { name: currencyName }, - active: true, - status: VirtualIbanStatus.ACTIVE, - }, + return this.virtualIbanRepo.findOneCachedBy(`${userData.id}-${currencyName}`, { + userData: { id: userData.id }, + currency: { name: currencyName }, + active: true, + status: VirtualIbanStatus.ACTIVE, }); } @@ -110,10 +108,6 @@ export class VirtualIbanService { }); } - async getVirtualIbansForAccount(userDataId: number): Promise { - return this.virtualIbanRepo.findCachedBy(`user-${userDataId}`, { userData: { id: userDataId } }); - } - async countActiveForUser(userDataId: number): Promise { return this.virtualIbanRepo.countBy({ userData: { id: userDataId }, @@ -159,4 +153,8 @@ export class VirtualIbanService { accountUid: result.accountUid, }; } + + async getVirtualIbansForAccount(userDataId: number): Promise { + return this.virtualIbanRepo.findCachedBy(`user-${userDataId}`, { userData: { id: userDataId } }); + } } diff --git a/src/subdomains/supporting/dex/dex.module.ts b/src/subdomains/supporting/dex/dex.module.ts index e36cd470ef..6dc7f92104 100644 --- a/src/subdomains/supporting/dex/dex.module.ts +++ b/src/subdomains/supporting/dex/dex.module.ts @@ -12,6 +12,7 @@ import { DexBaseService } from './services/dex-base.service'; import { DexBitcoinService } from './services/dex-bitcoin.service'; import { DexBscService } from './services/dex-bsc.service'; import { DexCardanoService } from './services/dex-cardano.service'; +import { DexCitreaService } from './services/dex-citrea.service'; import { DexCitreaTestnetService } from './services/dex-citrea-testnet.service'; import { DexEthereumService } from './services/dex-ethereum.service'; import { DexGnosisService } from './services/dex-gnosis.service'; @@ -34,6 +35,8 @@ import { BscCoinStrategy as BscCoinStrategyCL } from './strategies/check-liquidi import { BscTokenStrategy as BscTokenStrategyCL } from './strategies/check-liquidity/impl/bsc-token.strategy'; import { CardanoCoinStrategy as CardanoCoinStrategyCL } from './strategies/check-liquidity/impl/cardano-coin.strategy'; import { CardanoTokenStrategy as CardanoTokenStrategyCL } from './strategies/check-liquidity/impl/cardano-token.strategy'; +import { CitreaCoinStrategy as CitreaCoinStrategyCL } from './strategies/check-liquidity/impl/citrea-coin.strategy'; +import { CitreaTokenStrategy as CitreaTokenStrategyCL } from './strategies/check-liquidity/impl/citrea-token.strategy'; import { CitreaTestnetCoinStrategy as CitreaTestnetCoinStrategyCL } from './strategies/check-liquidity/impl/citrea-testnet-coin.strategy'; import { CitreaTestnetTokenStrategy as CitreaTestnetTokenStrategyCL } from './strategies/check-liquidity/impl/citrea-testnet-token.strategy'; import { EthereumCoinStrategy as EthereumCoinStrategyCL } from './strategies/check-liquidity/impl/ethereum-coin.strategy'; @@ -64,6 +67,8 @@ import { BscCoinStrategy as BscCoinStrategyPL } from './strategies/purchase-liqu import { BscTokenStrategy as BscTokenStrategyPL } from './strategies/purchase-liquidity/impl/bsc-token.strategy'; import { CardanoCoinStrategy as CardanoCoinStrategyPL } from './strategies/purchase-liquidity/impl/cardano-coin.strategy'; import { CardanoTokenStrategy as CardanoTokenStrategyPL } from './strategies/purchase-liquidity/impl/cardano-token.strategy'; +import { CitreaCoinStrategy as CitreaCoinStrategyPL } from './strategies/purchase-liquidity/impl/citrea-coin.strategy'; +import { CitreaTokenStrategy as CitreaTokenStrategyPL } from './strategies/purchase-liquidity/impl/citrea-token.strategy'; import { CitreaTestnetCoinStrategy as CitreaTestnetCoinStrategyPL } from './strategies/purchase-liquidity/impl/citrea-testnet-coin.strategy'; import { CitreaTestnetTokenStrategy as CitreaTestnetTokenStrategyPL } from './strategies/purchase-liquidity/impl/citrea-testnet-token.strategy'; import { EthereumCoinStrategy as EthereumCoinStrategyPL } from './strategies/purchase-liquidity/impl/ethereum-coin.strategy'; @@ -93,6 +98,8 @@ import { BscCoinStrategy as BscCoinStrategySL } from './strategies/sell-liquidit import { BscTokenStrategy as BscTokenStrategySL } from './strategies/sell-liquidity/impl/bsc-token.strategy'; import { CardanoCoinStrategy as CardanoCoinStrategySL } from './strategies/sell-liquidity/impl/cardano-coin.strategy'; import { CardanoTokenStrategy as CardanoTokenStrategySL } from './strategies/sell-liquidity/impl/cardano-token.strategy'; +import { CitreaCoinStrategy as CitreaCoinStrategySL } from './strategies/sell-liquidity/impl/citrea-coin.strategy'; +import { CitreaTokenStrategy as CitreaTokenStrategySL } from './strategies/sell-liquidity/impl/citrea-token.strategy'; import { CitreaTestnetCoinStrategy as CitreaTestnetCoinStrategySL } from './strategies/sell-liquidity/impl/citrea-testnet-coin.strategy'; import { CitreaTestnetTokenStrategy as CitreaTestnetTokenStrategySL } from './strategies/sell-liquidity/impl/citrea-testnet-token.strategy'; import { EthereumCoinStrategy as EthereumCoinStrategySL } from './strategies/sell-liquidity/impl/ethereum-coin.strategy'; @@ -118,6 +125,7 @@ import { SupplementaryStrategyRegistry } from './strategies/supplementary/impl/b import { BitcoinStrategy as BitcoinStrategyS } from './strategies/supplementary/impl/bitcoin.strategy'; import { BscStrategy as BscStrategyS } from './strategies/supplementary/impl/bsc.strategy'; import { CardanoStrategy as CardanoStrategyS } from './strategies/supplementary/impl/cardano.strategy'; +import { CitreaStrategy as CitreaStrategyS } from './strategies/supplementary/impl/citrea.strategy'; import { CitreaTestnetStrategy as CitreaTestnetStrategyS } from './strategies/supplementary/impl/citrea-testnet.strategy'; import { EthereumStrategy as EthereumStrategyS } from './strategies/supplementary/impl/ethereum.strategy'; import { GnosisStrategy as GnosisStrategyS } from './strategies/supplementary/impl/gnosis.strategy'; @@ -145,6 +153,7 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z DexGnosisService, DexBscService, DexBitcoinService, + DexCitreaService, DexCitreaTestnetService, DexLightningService, DexMoneroService, @@ -175,6 +184,8 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z PolygonTokenStrategyCL, BaseCoinStrategyCL, BaseTokenStrategyCL, + CitreaCoinStrategyCL, + CitreaTokenStrategyCL, CitreaTestnetCoinStrategyCL, CitreaTestnetTokenStrategyCL, SolanaCoinStrategyCL, @@ -203,6 +214,8 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z PolygonTokenStrategyPL, BaseCoinStrategyPL, BaseTokenStrategyPL, + CitreaCoinStrategyPL, + CitreaTokenStrategyPL, CitreaTestnetCoinStrategyPL, CitreaTestnetTokenStrategyPL, SolanaCoinStrategyPL, @@ -231,6 +244,8 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z PolygonTokenStrategySL, BaseCoinStrategySL, BaseTokenStrategySL, + CitreaCoinStrategySL, + CitreaTokenStrategySL, CitreaTestnetCoinStrategySL, CitreaTestnetTokenStrategySL, SolanaCoinStrategySL, @@ -251,6 +266,7 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z OptimismStrategyS, PolygonStrategyS, BaseStrategyS, + CitreaStrategyS, CitreaTestnetStrategyS, SolanaStrategyS, GnosisStrategyS, diff --git a/src/subdomains/supporting/dex/services/dex-citrea.service.ts b/src/subdomains/supporting/dex/services/dex-citrea.service.ts new file mode 100644 index 0000000000..02326d8332 --- /dev/null +++ b/src/subdomains/supporting/dex/services/dex-citrea.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { CitreaService } from 'src/integration/blockchain/citrea/citrea.service'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { LiquidityOrderRepository } from '../repositories/liquidity-order.repository'; +import { DexEvmService } from './base/dex-evm.service'; + +@Injectable() +export class DexCitreaService extends DexEvmService { + constructor(liquidityOrderRepo: LiquidityOrderRepository, citreaService: CitreaService) { + super(liquidityOrderRepo, citreaService, 'cBTC', Blockchain.CITREA); + } +} diff --git a/src/subdomains/supporting/dex/strategies/check-liquidity/impl/citrea-coin.strategy.ts b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/citrea-coin.strategy.ts new file mode 100644 index 0000000000..5f8ad5fbf7 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/citrea-coin.strategy.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DexCitreaService } from '../../../services/dex-citrea.service'; +import { EvmCoinStrategy } from './base/evm-coin.strategy'; + +@Injectable() +export class CitreaCoinStrategy extends EvmCoinStrategy { + constructor( + protected readonly assetService: AssetService, + dexCitreaService: DexCitreaService, + ) { + super(dexCitreaService); + } + + get blockchain(): Blockchain { + return Blockchain.CITREA; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + protected getFeeAsset(): Promise { + return this.assetService.getCitreaCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/check-liquidity/impl/citrea-token.strategy.ts b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/citrea-token.strategy.ts new file mode 100644 index 0000000000..116ca954d0 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/citrea-token.strategy.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DexCitreaService } from '../../../services/dex-citrea.service'; +import { EvmTokenStrategy } from './base/evm-token.strategy'; + +@Injectable() +export class CitreaTokenStrategy extends EvmTokenStrategy { + constructor( + protected readonly assetService: AssetService, + dexCitreaService: DexCitreaService, + ) { + super(dexCitreaService); + } + + get blockchain(): Blockchain { + return Blockchain.CITREA; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + protected getFeeAsset(): Promise { + return this.assetService.getCitreaCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/citrea-coin.strategy.ts b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/citrea-coin.strategy.ts new file mode 100644 index 0000000000..48345d8105 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/citrea-coin.strategy.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { PurchaseStrategy } from './base/purchase.strategy'; + +@Injectable() +export class CitreaCoinStrategy extends PurchaseStrategy { + protected readonly logger = new DfxLogger(CitreaCoinStrategy); + + get blockchain(): Blockchain { + return Blockchain.CITREA; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + get dexName(): string { + return undefined; + } + + protected getFeeAsset(): Promise { + return this.assetService.getCitreaCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/citrea-token.strategy.ts b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/citrea-token.strategy.ts new file mode 100644 index 0000000000..beefed5953 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/citrea-token.strategy.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { DexCitreaService } from '../../../services/dex-citrea.service'; +import { PurchaseStrategy } from './base/purchase.strategy'; + +@Injectable() +export class CitreaTokenStrategy extends PurchaseStrategy { + protected readonly logger = new DfxLogger(CitreaTokenStrategy); + + constructor(dexCitreaService: DexCitreaService) { + super(dexCitreaService); + } + + get blockchain(): Blockchain { + return Blockchain.CITREA; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + get dexName(): string { + return undefined; + } + + protected getFeeAsset(): Promise { + return this.assetService.getCitreaCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/citrea-coin.strategy.ts b/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/citrea-coin.strategy.ts new file mode 100644 index 0000000000..329197d053 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/citrea-coin.strategy.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { EvmCoinStrategy } from './base/evm-coin.strategy'; + +@Injectable() +export class CitreaCoinStrategy extends EvmCoinStrategy { + protected readonly logger = new DfxLogger(CitreaCoinStrategy); + + constructor(protected readonly assetService: AssetService) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.CITREA; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + sellLiquidity(): Promise { + throw new Error('Selling liquidity on DEX is not supported for Citrea coin'); + } + + addSellData(): Promise { + throw new Error('Selling liquidity on DEX is not supported for Citrea coin'); + } + + protected getFeeAsset(): Promise { + return this.assetService.getCitreaCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/citrea-token.strategy.ts b/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/citrea-token.strategy.ts new file mode 100644 index 0000000000..60d56dfafa --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/citrea-token.strategy.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { EvmTokenStrategy } from './base/evm-token.strategy'; + +@Injectable() +export class CitreaTokenStrategy extends EvmTokenStrategy { + protected readonly logger = new DfxLogger(CitreaTokenStrategy); + + constructor(protected readonly assetService: AssetService) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.CITREA; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + sellLiquidity(): Promise { + throw new Error('Selling liquidity on DEX is not supported for Citrea token'); + } + + addSellData(): Promise { + throw new Error('Selling liquidity on DEX is not supported for Citrea token'); + } + + protected getFeeAsset(): Promise { + return this.assetService.getCitreaCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/supplementary/impl/citrea.strategy.ts b/src/subdomains/supporting/dex/strategies/supplementary/impl/citrea.strategy.ts new file mode 100644 index 0000000000..2b53791a47 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/supplementary/impl/citrea.strategy.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { DexCitreaService } from '../../../services/dex-citrea.service'; +import { EvmStrategy } from './base/evm.strategy'; + +@Injectable() +export class CitreaStrategy extends EvmStrategy { + constructor(citreaService: DexCitreaService) { + super(citreaService); + } + + get blockchain(): Blockchain { + return Blockchain.CITREA; + } +} diff --git a/src/subdomains/supporting/payin/payin.module.ts b/src/subdomains/supporting/payin/payin.module.ts index 7d50c59e31..bde0382cae 100644 --- a/src/subdomains/supporting/payin/payin.module.ts +++ b/src/subdomains/supporting/payin/payin.module.ts @@ -21,6 +21,7 @@ import { PayInArbitrumService } from './services/payin-arbitrum.service'; import { PayInBaseService } from './services/payin-base.service'; import { PayInBitcoinService } from './services/payin-bitcoin.service'; import { PayInBscService } from './services/payin-bsc.service'; +import { PayInCitreaService } from './services/payin-citrea.service'; import { PayInCitreaTestnetService } from './services/payin-citrea-testnet.service'; import { PayInEthereumService } from './services/payin-ethereum.service'; import { PayInGnosisService } from './services/payin-gnosis.service'; @@ -42,6 +43,7 @@ import { BinancePayStrategy as BinancePayStrategyR } from './strategies/register import { BitcoinStrategy as BitcoinStrategyR } from './strategies/register/impl/bitcoin.strategy'; import { BscStrategy as BscStrategyR } from './strategies/register/impl/bsc.strategy'; import { CardanoStrategy as CardanoStrategyR } from './strategies/register/impl/cardano.strategy'; +import { CitreaStrategy as CitreaStrategyR } from './strategies/register/impl/citrea.strategy'; import { CitreaTestnetStrategy as CitreaTestnetStrategyR } from './strategies/register/impl/citrea-testnet.strategy'; import { EthereumStrategy as EthereumStrategyR } from './strategies/register/impl/ethereum.strategy'; import { GnosisStrategy as GnosisStrategyR } from './strategies/register/impl/gnosis.strategy'; @@ -65,6 +67,8 @@ import { BscCoinStrategy as BscCoinStrategyS } from './strategies/send/impl/bsc- import { BscTokenStrategy as BscTokenStrategyS } from './strategies/send/impl/bsc-token.strategy'; import { CardanoCoinStrategy as CardanoCoinStrategyS } from './strategies/send/impl/cardano-coin.strategy'; import { CardanoTokenStrategy as CardanoTokenStrategyS } from './strategies/send/impl/cardano-token.strategy'; +import { CitreaCoinStrategy as CitreaCoinStrategyS } from './strategies/send/impl/citrea-coin.strategy'; +import { CitreaTokenStrategy as CitreaTokenStrategyS } from './strategies/send/impl/citrea-token.strategy'; import { CitreaTestnetCoinStrategy as CitreaTestnetCoinStrategyS } from './strategies/send/impl/citrea-testnet-coin.strategy'; import { CitreaTestnetTokenStrategy as CitreaTestnetTokenStrategyS } from './strategies/send/impl/citrea-testnet-token.strategy'; import { EthereumCoinStrategy as EthereumCoinStrategyS } from './strategies/send/impl/ethereum-coin.strategy'; @@ -125,6 +129,7 @@ import { ZanoTokenStrategy as ZanoTokenStrategyS } from './strategies/send/impl/ PayInGnosisService, PayInTronService, PayInCardanoService, + PayInCitreaService, PayInCitreaTestnetService, RegisterStrategyRegistry, SendStrategyRegistry, @@ -170,6 +175,9 @@ import { ZanoTokenStrategy as ZanoTokenStrategyS } from './strategies/send/impl/ CardanoStrategyR, CardanoCoinStrategyS, CardanoTokenStrategyS, + CitreaStrategyR, + CitreaCoinStrategyS, + CitreaTokenStrategyS, CitreaTestnetStrategyR, CitreaTestnetCoinStrategyS, CitreaTestnetTokenStrategyS, diff --git a/src/subdomains/supporting/payin/services/payin-citrea.service.ts b/src/subdomains/supporting/payin/services/payin-citrea.service.ts new file mode 100644 index 0000000000..e38fa92219 --- /dev/null +++ b/src/subdomains/supporting/payin/services/payin-citrea.service.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { CitreaService } from 'src/integration/blockchain/citrea/citrea.service'; +import { PayInEvmService } from './base/payin-evm.service'; + +@Injectable() +export class PayInCitreaService extends PayInEvmService { + constructor(citreaService: CitreaService) { + super(citreaService); + } +} diff --git a/src/subdomains/supporting/payin/services/payin.service.ts b/src/subdomains/supporting/payin/services/payin.service.ts index e8dd4a9028..1dc932bcb7 100644 --- a/src/subdomains/supporting/payin/services/payin.service.ts +++ b/src/subdomains/supporting/payin/services/payin.service.ts @@ -28,7 +28,6 @@ import { import { PayInEntry } from '../interfaces'; import { PayInRepository } from '../repositories/payin.repository'; import { RegisterStrategyRegistry } from '../strategies/register/impl/base/register.strategy-registry'; -import { CardanoStrategy } from '../strategies/register/impl/cardano.strategy'; import { SendType } from '../strategies/send/impl/base/send.strategy'; import { SendStrategyRegistry } from '../strategies/send/impl/base/send.strategy-registry'; import { PayInBitcoinService } from './payin-bitcoin.service'; @@ -72,11 +71,10 @@ export class PayInService { } async pollAddress(address: BlockchainAddress): Promise { - if (address.blockchain !== Blockchain.CARDANO) - throw new BadRequestException(`Address poll not supported for ${address.blockchain}`); + const registerStrategy = this.registerStrategyRegistry.get(address.blockchain); + if (registerStrategy.pollAddress) return registerStrategy.pollAddress(address); - const registerStrategy = this.registerStrategyRegistry.get(address.blockchain) as CardanoStrategy; - return registerStrategy.pollAddress(address); + throw new BadRequestException(`Address poll not supported for ${address.blockchain}`); } async createPayIns(transactions: PayInEntry[]): Promise { diff --git a/src/subdomains/supporting/payin/strategies/register/impl/base/register.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/base/register.strategy.ts index 85d8e5f83c..48b7998a0f 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/base/register.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/base/register.strategy.ts @@ -1,6 +1,7 @@ import { Inject, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { AssetService } from 'src/shared/models/asset/asset.service'; +import { BlockchainAddress } from 'src/shared/models/blockchain-address'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { PayInEntry } from 'src/subdomains/supporting/payin/interfaces'; import { PayInRepository } from 'src/subdomains/supporting/payin/repositories/payin.repository'; @@ -29,6 +30,8 @@ export abstract class RegisterStrategy implements OnModuleInit, OnModuleDestroy abstract get blockchain(): Blockchain; + pollAddress?(depositAddress: BlockchainAddress): Promise; + protected async createPayInsAndSave(transactions: PayInEntry[], log: PayInInputLog): Promise { const payIns = await this.payInService.createPayIns(transactions); diff --git a/src/subdomains/supporting/payin/strategies/register/impl/citrea-testnet.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/citrea-testnet.strategy.ts index 642d542f7d..cba68615af 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/citrea-testnet.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/citrea-testnet.strategy.ts @@ -1,172 +1,155 @@ -import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; import { EvmCoinHistoryEntry, EvmTokenHistoryEntry } from 'src/integration/blockchain/shared/evm/interfaces'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { BlockchainAddress } from 'src/shared/models/blockchain-address'; -import { SettingService } from 'src/shared/models/setting/setting.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Process } from 'src/shared/services/process.service'; +import { DfxCron } from 'src/shared/utils/cron'; import { Util } from 'src/shared/utils/util'; +import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; +import { PayInType } from '../../../entities/crypto-input.entity'; import { PayInEntry } from '../../../interfaces'; import { PayInCitreaTestnetService } from '../../../services/payin-citrea-testnet.service'; -import { EvmStrategy } from './base/evm.strategy'; +import { RegisterStrategy } from './base/register.strategy'; @Injectable() -export class CitreaTestnetStrategy extends EvmStrategy implements OnModuleInit { +export class CitreaTestnetStrategy extends RegisterStrategy { protected readonly logger = new DfxLogger(CitreaTestnetStrategy); - private static readonly LAST_PROCESSED_BLOCK_KEY = 'citreaTestnetLastProcessedBlock'; - private lastProcessedBlock: number | null = null; + private readonly paymentDepositAddress: string; - @Inject() private readonly settingService: SettingService; - - constructor(private readonly citreaTestnetService: PayInCitreaTestnetService) { + constructor( + private readonly payInCitreaTestnetService: PayInCitreaTestnetService, + private readonly transactionRequestService: TransactionRequestService, + ) { super(); + this.paymentDepositAddress = EvmUtil.createWallet({ seed: Config.payment.evmSeed, index: 0 }).address; } - onModuleInit() { - super.onModuleInit(); - - void this.loadPersistedState().catch((error) => - this.logger.error('Failed to load persisted state during initialization:', error), - ); + get blockchain(): Blockchain { + return Blockchain.CITREA_TESTNET; } // --- JOBS --- // - - // Note: pay-in functionality currently not used/tested for Citrea - // @DfxCron(CronExpression.EVERY_5_MINUTES, { process: Process.PAY_IN, timeout: 7200 }) + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.PAY_IN, timeout: 7200 }) async checkPayInEntries(): Promise { - const log = this.createNewLogObject(); - const { entries, processedBlock } = await this.getNewEntriesWithBlock(); - - await this.createPayInsAndSave(entries, log); - - if (processedBlock !== null) await this.updateLastProcessedBlock(processedBlock); + const activeDepositAddresses = await this.transactionRequestService.getActiveDepositAddresses( + Util.hoursBefore(1), + this.blockchain, + ); - this.printInputLog(log, processedBlock ?? 'omitted', Blockchain.CITREA_TESTNET); + await this.processNewPayInEntries(activeDepositAddresses.map((a) => BlockchainAddress.create(a, this.blockchain))); } - // --- HELPER METHODS --- // - - private async getNewEntriesWithBlock(): Promise<{ entries: PayInEntry[]; processedBlock: number | null }> { - const currentBlock = await this.citreaTestnetService.getCurrentBlockNumber(); - const { fromBlock, toBlock } = this.getBlockRange(currentBlock); + async pollAddress(depositAddress: BlockchainAddress): Promise { + if (depositAddress.blockchain !== this.blockchain) + throw new Error(`Invalid blockchain: ${depositAddress.blockchain}`); - if (fromBlock > toBlock) { - // no new blocks to process (could indicate blockchain reorganization) - if (this.lastProcessedBlock !== null && currentBlock < this.lastProcessedBlock) { - this.logger.warn( - `Potential blockchain reorganization detected: currentBlock=${currentBlock} < lastProcessedBlock=${this.lastProcessedBlock}. ` + - `This could indicate a chain fork. Will wait for chain to advance.`, - ); - } - return { entries: [], processedBlock: null }; - } - - this.logger.verbose(`Processing CitreaTestnet blocks ${fromBlock} to ${toBlock}`); - - const newEntries = await this.fetchTransactionsForBlockRange(fromBlock, toBlock); - - if (newEntries.length > 0) this.logger.info(`Found ${newEntries.length} new CitreaTestnet transactions`); - - return { entries: newEntries, processedBlock: toBlock }; + return this.processNewPayInEntries([depositAddress]); } - private getBlockRange(currentBlock: number): { fromBlock: number; toBlock: number } { - if (this.lastProcessedBlock == null) { - const fromBlock = Math.max(0, currentBlock - 100); + private async processNewPayInEntries(depositAddresses: BlockchainAddress[]): Promise { + const log = this.createNewLogObject(); - this.logger.warn( - `First run: Starting from block ${fromBlock} (skipping blocks 0-${fromBlock - 1}). ` + - `Historical transactions before block ${fromBlock} will not be processed.`, - ); + const newEntries: PayInEntry[] = []; - return { - fromBlock, - toBlock: currentBlock, - }; - } + for (const depositAddress of depositAddresses) { + const lastCheckedBlockHeight = await this.getLastCheckedBlockHeight(depositAddress); - const maxBlocksPerRun = 100; - const nextFromBlock = this.lastProcessedBlock + 1; - const nextToBlock = Math.min(currentBlock, nextFromBlock + maxBlocksPerRun - 1); + newEntries.push(...(await this.getNewEntries(depositAddress, lastCheckedBlockHeight))); + } - // warn about large gaps that might indicate service downtime - if (nextToBlock - nextFromBlock + 1 >= maxBlocksPerRun) { - this.logger.warn( - `Processing maximum block range: ${nextFromBlock}-${nextToBlock} (${nextToBlock - nextFromBlock + 1} blocks)`, - ); + if (newEntries?.length) { + await this.createPayInsAndSave(newEntries, log); } - return { - fromBlock: nextFromBlock, - toBlock: nextToBlock, - }; + this.printInputLog(log, 'omitted', this.blockchain); } - private async fetchTransactionsForBlockRange(fromBlock: number, toBlock: number): Promise { - const allEntries: PayInEntry[] = []; - const addressesToMonitor = await this.getPayInAddresses(); + private async getLastCheckedBlockHeight(depositAddress: BlockchainAddress): Promise { + return this.payInRepository + .findOne({ + select: ['id', 'blockHeight'], + where: { address: depositAddress }, + order: { blockHeight: 'DESC' }, + loadEagerRelations: false, + }) + .then((input) => input?.blockHeight ?? 0); + } - for (const address of addressesToMonitor) { - const [coinTransactions, tokenTransactions] = await this.citreaTestnetService.getHistory( - address, - fromBlock, - toBlock, - ); + private async getNewEntries( + depositAddress: BlockchainAddress, + lastCheckedBlockHeight: number, + ): Promise { + const fromBlock = lastCheckedBlockHeight + 1; + const [coinTransactions, tokenTransactions] = await this.payInCitreaTestnetService.getHistory( + depositAddress.address, + fromBlock, + ); - const coinEntries = await this.mapCoinTransactionsToEntries(coinTransactions, address); - allEntries.push(...coinEntries); + const supportedAssets = await this.assetService.getAllBlockchainAssets([this.blockchain]); - const tokenEntries = await this.mapTokenTransactionsToEntries(tokenTransactions, address); - allEntries.push(...tokenEntries); - } + const coinEntries = this.mapCoinTransactionsToEntries(coinTransactions, depositAddress, supportedAssets); + const tokenEntries = this.mapTokenTransactionsToEntries(tokenTransactions, depositAddress, supportedAssets); - return allEntries; + return [...coinEntries, ...tokenEntries]; } - private async mapCoinTransactionsToEntries( + private mapCoinTransactionsToEntries( transactions: EvmCoinHistoryEntry[], - monitoredAddress: string, - ): Promise { - const asset = await this.assetService.getCitreaTestnetCoin(); + depositAddress: BlockchainAddress, + supportedAssets: Asset[], + ): PayInEntry[] { + const relevantTransactions = transactions.filter( + (t) => t.to.toLowerCase() === depositAddress.address.toLowerCase(), + ); - return transactions.map((tx) => ({ + const coinAsset = supportedAssets.find((a) => a.type === AssetType.COIN); + + return relevantTransactions.map((tx) => ({ senderAddresses: tx.from, - receiverAddress: BlockchainAddress.create(tx.to, Blockchain.CITREA_TESTNET), + receiverAddress: depositAddress, txId: tx.hash, - txType: this.getTxType(monitoredAddress), - txSequence: 0, // EVM coin transactions have single output + txType: this.getTxType(depositAddress.address), + txSequence: 0, blockHeight: parseInt(tx.blockNumber), - amount: parseFloat(tx.value), - asset, + amount: Util.floorByPrecision(EvmUtil.fromWeiAmount(tx.value), 15), + asset: coinAsset, })); } - private async mapTokenTransactionsToEntries( + private mapTokenTransactionsToEntries( transactions: EvmTokenHistoryEntry[], - monitoredAddress: string, - ): Promise { - const entries: PayInEntry[] = []; - - const supportedAssets = await this.assetService.getAllBlockchainAssets([this.blockchain]); + depositAddress: BlockchainAddress, + supportedAssets: Asset[], + ): PayInEntry[] { + const relevantTransactions = transactions.filter( + (t) => t.to.toLowerCase() === depositAddress.address.toLowerCase(), + ); - // group by transaction hash to handle multiple token transfers in same tx - const txGroups = Util.groupBy(transactions, 'hash'); + const entries: PayInEntry[] = []; + const txGroups = Util.groupBy(relevantTransactions, 'hash'); for (const txGroup of txGroups.values()) { for (let i = 0; i < txGroup.length; i++) { const tx = txGroup[i]; + const asset = this.assetService.getByChainIdSync(supportedAssets, this.blockchain, tx.contractAddress); + const decimals = tx.tokenDecimal ? parseInt(tx.tokenDecimal) : asset?.decimals; + entries.push({ senderAddresses: tx.from, - receiverAddress: BlockchainAddress.create(tx.to, Blockchain.CITREA_TESTNET), + receiverAddress: depositAddress, txId: tx.hash, - txType: this.getTxType(monitoredAddress), - txSequence: i, // use index for multiple token transfers in same transaction + txType: this.getTxType(depositAddress.address), + txSequence: i, blockHeight: parseInt(tx.blockNumber), - amount: parseFloat(tx.value), - asset: this.getTransactionAsset(supportedAssets, tx.contractAddress), + amount: Util.floorByPrecision(EvmUtil.fromWeiAmount(tx.value, decimals), 15), + asset, }); } } @@ -174,22 +157,10 @@ export class CitreaTestnetStrategy extends EvmStrategy implements OnModuleInit { return entries; } - private async loadPersistedState(): Promise { - const persistedBlock = await this.settingService.get(CitreaTestnetStrategy.LAST_PROCESSED_BLOCK_KEY); - if (persistedBlock) this.lastProcessedBlock = +persistedBlock; - } - - private async updateLastProcessedBlock(blockNumber: number): Promise { - await this.settingService.set(CitreaTestnetStrategy.LAST_PROCESSED_BLOCK_KEY, blockNumber.toString()); - this.lastProcessedBlock = blockNumber; + private getTxType(depositAddress: string): PayInType { + return Util.equalsIgnoreCase(this.paymentDepositAddress, depositAddress) ? PayInType.PAYMENT : PayInType.DEPOSIT; } - get blockchain(): Blockchain { - return Blockchain.CITREA_TESTNET; - } - - // --- HELPER METHODS --- // - protected getOwnAddresses(): string[] { return [Config.blockchain.citreaTestnet.citreaTestnetWalletAddress]; } diff --git a/src/subdomains/supporting/payin/strategies/register/impl/citrea.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/citrea.strategy.ts new file mode 100644 index 0000000000..05e65e314f --- /dev/null +++ b/src/subdomains/supporting/payin/strategies/register/impl/citrea.strategy.ts @@ -0,0 +1,167 @@ +import { Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { Config } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; +import { EvmCoinHistoryEntry, EvmTokenHistoryEntry } from 'src/integration/blockchain/shared/evm/interfaces'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { BlockchainAddress } from 'src/shared/models/blockchain-address'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Process } from 'src/shared/services/process.service'; +import { DfxCron } from 'src/shared/utils/cron'; +import { Util } from 'src/shared/utils/util'; +import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; +import { PayInType } from '../../../entities/crypto-input.entity'; +import { PayInEntry } from '../../../interfaces'; +import { PayInCitreaService } from '../../../services/payin-citrea.service'; +import { RegisterStrategy } from './base/register.strategy'; + +@Injectable() +export class CitreaStrategy extends RegisterStrategy { + protected readonly logger = new DfxLogger(CitreaStrategy); + + private readonly paymentDepositAddress: string; + + constructor( + private readonly payInCitreaService: PayInCitreaService, + private readonly transactionRequestService: TransactionRequestService, + ) { + super(); + this.paymentDepositAddress = EvmUtil.createWallet({ seed: Config.payment.evmSeed, index: 0 }).address; + } + + get blockchain(): Blockchain { + return Blockchain.CITREA; + } + + // --- JOBS --- // + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.PAY_IN, timeout: 7200 }) + async checkPayInEntries(): Promise { + const activeDepositAddresses = await this.transactionRequestService.getActiveDepositAddresses( + Util.hoursBefore(1), + this.blockchain, + ); + + await this.processNewPayInEntries(activeDepositAddresses.map((a) => BlockchainAddress.create(a, this.blockchain))); + } + + async pollAddress(depositAddress: BlockchainAddress): Promise { + if (depositAddress.blockchain !== this.blockchain) + throw new Error(`Invalid blockchain: ${depositAddress.blockchain}`); + + return this.processNewPayInEntries([depositAddress]); + } + + private async processNewPayInEntries(depositAddresses: BlockchainAddress[]): Promise { + const log = this.createNewLogObject(); + + const newEntries: PayInEntry[] = []; + + for (const depositAddress of depositAddresses) { + const lastCheckedBlockHeight = await this.getLastCheckedBlockHeight(depositAddress); + + newEntries.push(...(await this.getNewEntries(depositAddress, lastCheckedBlockHeight))); + } + + if (newEntries?.length) { + await this.createPayInsAndSave(newEntries, log); + } + + this.printInputLog(log, 'omitted', this.blockchain); + } + + private async getLastCheckedBlockHeight(depositAddress: BlockchainAddress): Promise { + return this.payInRepository + .findOne({ + select: ['id', 'blockHeight'], + where: { address: depositAddress }, + order: { blockHeight: 'DESC' }, + loadEagerRelations: false, + }) + .then((input) => input?.blockHeight ?? 0); + } + + private async getNewEntries( + depositAddress: BlockchainAddress, + lastCheckedBlockHeight: number, + ): Promise { + const fromBlock = lastCheckedBlockHeight + 1; + const [coinTransactions, tokenTransactions] = await this.payInCitreaService.getHistory( + depositAddress.address, + fromBlock, + ); + + const supportedAssets = await this.assetService.getAllBlockchainAssets([this.blockchain]); + + const coinEntries = this.mapCoinTransactionsToEntries(coinTransactions, depositAddress, supportedAssets); + const tokenEntries = this.mapTokenTransactionsToEntries(tokenTransactions, depositAddress, supportedAssets); + + return [...coinEntries, ...tokenEntries]; + } + + private mapCoinTransactionsToEntries( + transactions: EvmCoinHistoryEntry[], + depositAddress: BlockchainAddress, + supportedAssets: Asset[], + ): PayInEntry[] { + const relevantTransactions = transactions.filter( + (t) => t.to.toLowerCase() === depositAddress.address.toLowerCase(), + ); + + const coinAsset = supportedAssets.find((a) => a.type === AssetType.COIN); + + return relevantTransactions.map((tx) => ({ + senderAddresses: tx.from, + receiverAddress: depositAddress, + txId: tx.hash, + txType: this.getTxType(depositAddress.address), + txSequence: 0, + blockHeight: parseInt(tx.blockNumber), + amount: Util.floorByPrecision(EvmUtil.fromWeiAmount(tx.value), 15), + asset: coinAsset, + })); + } + + private mapTokenTransactionsToEntries( + transactions: EvmTokenHistoryEntry[], + depositAddress: BlockchainAddress, + supportedAssets: Asset[], + ): PayInEntry[] { + const relevantTransactions = transactions.filter( + (t) => t.to.toLowerCase() === depositAddress.address.toLowerCase(), + ); + + const entries: PayInEntry[] = []; + const txGroups = Util.groupBy(relevantTransactions, 'hash'); + + for (const txGroup of txGroups.values()) { + for (let i = 0; i < txGroup.length; i++) { + const tx = txGroup[i]; + + const asset = this.assetService.getByChainIdSync(supportedAssets, this.blockchain, tx.contractAddress); + const decimals = tx.tokenDecimal ? parseInt(tx.tokenDecimal) : asset?.decimals; + + entries.push({ + senderAddresses: tx.from, + receiverAddress: depositAddress, + txId: tx.hash, + txType: this.getTxType(depositAddress.address), + txSequence: i, + blockHeight: parseInt(tx.blockNumber), + amount: Util.floorByPrecision(EvmUtil.fromWeiAmount(tx.value, decimals), 15), + asset, + }); + } + } + + return entries; + } + + private getTxType(depositAddress: string): PayInType { + return Util.equalsIgnoreCase(this.paymentDepositAddress, depositAddress) ? PayInType.PAYMENT : PayInType.DEPOSIT; + } + + protected getOwnAddresses(): string[] { + return [Config.blockchain.citrea.citreaWalletAddress]; + } +} diff --git a/src/subdomains/supporting/payin/strategies/send/impl/citrea-coin.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/citrea-coin.strategy.ts new file mode 100644 index 0000000000..ca662a1a38 --- /dev/null +++ b/src/subdomains/supporting/payin/strategies/send/impl/citrea-coin.strategy.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { Config } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { BlockchainAddress } from 'src/shared/models/blockchain-address'; +import { PayInRepository } from '../../../repositories/payin.repository'; +import { PayInCitreaService } from '../../../services/payin-citrea.service'; +import { EvmCoinStrategy } from './base/evm-coin.strategy'; + +@Injectable() +export class CitreaCoinStrategy extends EvmCoinStrategy { + constructor(citreaService: PayInCitreaService, payInRepo: PayInRepository) { + super(citreaService, payInRepo); + } + + get blockchain(): Blockchain { + return Blockchain.CITREA; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected getForwardAddress(): BlockchainAddress { + return BlockchainAddress.create(Config.blockchain.citrea.citreaWalletAddress, Blockchain.CITREA); + } +} diff --git a/src/subdomains/supporting/payin/strategies/send/impl/citrea-token.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/citrea-token.strategy.ts new file mode 100644 index 0000000000..eb1296d8c7 --- /dev/null +++ b/src/subdomains/supporting/payin/strategies/send/impl/citrea-token.strategy.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { Config } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { BlockchainAddress } from 'src/shared/models/blockchain-address'; +import { PayInRepository } from '../../../repositories/payin.repository'; +import { PayInCitreaService } from '../../../services/payin-citrea.service'; +import { EvmTokenStrategy } from './base/evm.token.strategy'; + +@Injectable() +export class CitreaTokenStrategy extends EvmTokenStrategy { + constructor(citreaService: PayInCitreaService, payInRepo: PayInRepository) { + super(citreaService, payInRepo); + } + + get blockchain(): Blockchain { + return Blockchain.CITREA; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + protected getForwardAddress(): BlockchainAddress { + return BlockchainAddress.create(Config.blockchain.citrea.citreaWalletAddress, Blockchain.CITREA); + } +} diff --git a/src/subdomains/supporting/payout/payout.module.ts b/src/subdomains/supporting/payout/payout.module.ts index 3c2b791557..00bb511d71 100644 --- a/src/subdomains/supporting/payout/payout.module.ts +++ b/src/subdomains/supporting/payout/payout.module.ts @@ -14,6 +14,7 @@ import { PayoutBaseService } from './services/payout-base.service'; import { PayoutBitcoinService } from './services/payout-bitcoin.service'; import { PayoutBscService } from './services/payout-bsc.service'; import { PayoutCardanoService } from './services/payout-cardano.service'; +import { PayoutCitreaService } from './services/payout-citrea.service'; import { PayoutCitreaTestnetService } from './services/payout-citrea-testnet.service'; import { PayoutEthereumService } from './services/payout-ethereum.service'; import { PayoutGnosisService } from './services/payout-gnosis.service'; @@ -38,6 +39,8 @@ import { BscCoinStrategy as BscCoinStrategyPO } from './strategies/payout/impl/b import { BscTokenStrategy as BscTokenStrategyPO } from './strategies/payout/impl/bsc-token.strategy'; import { CardanoCoinStrategy as CardanoCoinStrategyPO } from './strategies/payout/impl/cardano-coin.strategy'; import { CardanoTokenStrategy as CardanoTokenStrategyPO } from './strategies/payout/impl/cardano-token.strategy'; +import { CitreaCoinStrategy as CitreaCoinStrategyPO } from './strategies/payout/impl/citrea-coin.strategy'; +import { CitreaTokenStrategy as CitreaTokenStrategyPO } from './strategies/payout/impl/citrea-token.strategy'; import { CitreaTestnetCoinStrategy as CitreaTestnetCoinStrategyPO } from './strategies/payout/impl/citrea-testnet-coin.strategy'; import { CitreaTestnetTokenStrategy as CitreaTestnetTokenStrategyPO } from './strategies/payout/impl/citrea-testnet-token.strategy'; import { EthereumCoinStrategy as EthereumCoinStrategyPO } from './strategies/payout/impl/ethereum-coin.strategy'; @@ -65,6 +68,7 @@ import { PrepareStrategyRegistry } from './strategies/prepare/impl/base/prepare. import { BitcoinStrategy as BitcoinStrategyPR } from './strategies/prepare/impl/bitcoin.strategy'; import { BscStrategy as BscStrategyPR } from './strategies/prepare/impl/bsc.strategy'; import { CardanoStrategy as CardanoStrategyPR } from './strategies/prepare/impl/cardano.strategy'; +import { CitreaStrategy as CitreaStrategyPR } from './strategies/prepare/impl/citrea.strategy'; import { CitreaTestnetStrategy as CitreaTestnetStrategyPR } from './strategies/prepare/impl/citrea-testnet.strategy'; import { EthereumStrategy as EthereumStrategyPR } from './strategies/prepare/impl/ethereum.strategy'; import { GnosisStrategy as GnosisStrategyPR } from './strategies/prepare/impl/gnosis.strategy'; @@ -109,6 +113,7 @@ import { SparkStrategy as SparkStrategyPR } from './strategies/prepare/impl/spar PayoutSolanaService, PayoutTronService, PayoutCardanoService, + PayoutCitreaService, PayoutCitreaTestnetService, PayoutStrategyRegistry, PrepareStrategyRegistry, @@ -155,6 +160,9 @@ import { SparkStrategy as SparkStrategyPR } from './strategies/prepare/impl/spar CardanoStrategyPR, CardanoCoinStrategyPO, CardanoTokenStrategyPO, + CitreaStrategyPR, + CitreaCoinStrategyPO, + CitreaTokenStrategyPO, CitreaTestnetStrategyPR, CitreaTestnetCoinStrategyPO, CitreaTestnetTokenStrategyPO, diff --git a/src/subdomains/supporting/payout/services/payout-citrea.service.ts b/src/subdomains/supporting/payout/services/payout-citrea.service.ts new file mode 100644 index 0000000000..ef653ac2af --- /dev/null +++ b/src/subdomains/supporting/payout/services/payout-citrea.service.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { CitreaService } from 'src/integration/blockchain/citrea/citrea.service'; +import { PayoutEvmService } from './payout-evm.service'; + +@Injectable() +export class PayoutCitreaService extends PayoutEvmService { + constructor(citreaService: CitreaService) { + super(citreaService); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/citrea-coin.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/citrea-coin.strategy.ts new file mode 100644 index 0000000000..65f1464152 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/impl/citrea-coin.strategy.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { PayoutOrder } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutCitreaService } from '../../../services/payout-citrea.service'; +import { EvmStrategy } from './base/evm.strategy'; + +@Injectable() +export class CitreaCoinStrategy extends EvmStrategy { + constructor( + protected readonly citreaService: PayoutCitreaService, + protected readonly assetService: AssetService, + payoutOrderRepo: PayoutOrderRepository, + ) { + super(citreaService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.CITREA; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected async dispatchPayout(order: PayoutOrder): Promise { + const nonce = await this.getOrderNonce(order); + + return this.citreaService.sendNativeCoin(order.destinationAddress, order.amount, nonce); + } + + protected getCurrentGasForTransaction(): Promise { + return this.citreaService.getCurrentGasForCoinTransaction(); + } + + protected getFeeAsset(): Promise { + return this.assetService.getCitreaCoin(); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/citrea-token.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/citrea-token.strategy.ts new file mode 100644 index 0000000000..b3a7d6a53a --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/impl/citrea-token.strategy.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { PayoutOrder } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutCitreaService } from '../../../services/payout-citrea.service'; +import { EvmStrategy } from './base/evm.strategy'; + +@Injectable() +export class CitreaTokenStrategy extends EvmStrategy { + constructor( + protected readonly citreaService: PayoutCitreaService, + protected readonly assetService: AssetService, + payoutOrderRepo: PayoutOrderRepository, + ) { + super(citreaService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.CITREA; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + protected async dispatchPayout(order: PayoutOrder): Promise { + const nonce = await this.getOrderNonce(order); + + return this.citreaService.sendToken(order.destinationAddress, order.asset, order.amount, nonce); + } + + protected getCurrentGasForTransaction(token: Asset): Promise { + return this.citreaService.getCurrentGasForTokenTransaction(token); + } + + protected async getFeeAsset(): Promise { + return this.assetService.getCitreaCoin(); + } +} diff --git a/src/subdomains/supporting/payout/strategies/prepare/impl/citrea.strategy.ts b/src/subdomains/supporting/payout/strategies/prepare/impl/citrea.strategy.ts new file mode 100644 index 0000000000..ebf789b140 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/prepare/impl/citrea.strategy.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { EvmStrategy } from './base/evm.strategy'; + +@Injectable() +export class CitreaStrategy extends EvmStrategy { + constructor( + private readonly assetService: AssetService, + payoutOrderRepo: PayoutOrderRepository, + ) { + super(payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.CITREA; + } + + protected async getFeeAsset(): Promise { + return this.assetService.getCitreaCoin(); + } +} diff --git a/src/subdomains/supporting/pricing/domain/entities/price-rule.entity.ts b/src/subdomains/supporting/pricing/domain/entities/price-rule.entity.ts index 825ef2e3db..1da6ac89c6 100644 --- a/src/subdomains/supporting/pricing/domain/entities/price-rule.entity.ts +++ b/src/subdomains/supporting/pricing/domain/entities/price-rule.entity.ts @@ -20,6 +20,7 @@ export enum PriceSource { CURRENCY = 'Currency', FRANKENCOIN = 'Frankencoin', DEURO = 'dEURO', + JUICE = 'Juice', EBEL2X = 'Ebel2X', REALUNIT = 'RealUnit', CONSTANT = 'Constant', diff --git a/src/subdomains/supporting/pricing/pricing.module.ts b/src/subdomains/supporting/pricing/pricing.module.ts index 70cd5f0453..c7ce4a3a79 100644 --- a/src/subdomains/supporting/pricing/pricing.module.ts +++ b/src/subdomains/supporting/pricing/pricing.module.ts @@ -19,6 +19,7 @@ import { FixerService } from './services/integration/fixer.service'; import { PricingConstantService } from './services/integration/pricing-constant.service'; import { PricingDeuroService } from './services/integration/pricing-deuro.service'; import { PricingDexService } from './services/integration/pricing-dex.service'; +import { PricingJuiceService } from './services/integration/pricing-juice.service'; import { PricingEbel2xService } from './services/integration/pricing-ebel2x.service'; import { PricingFrankencoinService } from './services/integration/pricing-frankencoin.service'; import { PricingRealUnitService } from './services/integration/pricing-realunit.service'; @@ -47,6 +48,7 @@ import { PricingService } from './services/pricing.service'; PricingDexService, PricingFrankencoinService, PricingDeuroService, + PricingJuiceService, PricingEbel2xService, PricingRealUnitService, PricingConstantService, diff --git a/src/subdomains/supporting/pricing/services/integration/pricing-deuro.service.ts b/src/subdomains/supporting/pricing/services/integration/pricing-deuro.service.ts index 96d2c9bf97..63ad93861e 100644 --- a/src/subdomains/supporting/pricing/services/integration/pricing-deuro.service.ts +++ b/src/subdomains/supporting/pricing/services/integration/pricing-deuro.service.ts @@ -57,12 +57,10 @@ export class PricingDeuroService extends PricingProvider implements OnModuleInit let totalPrice = contractPrice; - for (const reference of PricingDeuroService.REFERENCE_ASSETS) { - if ([from, to].includes(reference)) { - const eurPrice = await this.krakenService.getPrice('EUR', reference); - totalPrice = eurPrice.convert(contractPrice); - break; - } + const referenceAsset = [from, to].find((a) => PricingDeuroService.REFERENCE_ASSETS.includes(a)); + if (referenceAsset) { + const eurPrice = await this.krakenService.getPrice('EUR', referenceAsset); + totalPrice = eurPrice.convert(contractPrice); } const assetPrice = [PricingDeuroService.DEPS, PricingDeuroService.NDEPS].includes(from) diff --git a/src/subdomains/supporting/pricing/services/integration/pricing-juice.service.ts b/src/subdomains/supporting/pricing/services/integration/pricing-juice.service.ts new file mode 100644 index 0000000000..aac4a82283 --- /dev/null +++ b/src/subdomains/supporting/pricing/services/integration/pricing-juice.service.ts @@ -0,0 +1,55 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { JuiceService } from 'src/integration/blockchain/juice/juice.service'; +import { KrakenService } from 'src/integration/exchange/services/kraken.service'; +import { Util } from 'src/shared/utils/util'; +import { Price } from '../../domain/entities/price'; +import { PricingProvider } from './pricing-provider'; + +@Injectable() +export class PricingJuiceService extends PricingProvider implements OnModuleInit { + private static readonly JUSD = 'JUSD'; + private static readonly JUICE = 'JUICE'; + private static readonly BTC = 'BTC'; + + private static readonly ALLOWED_ASSETS = [ + PricingJuiceService.JUSD, + PricingJuiceService.JUICE, + PricingJuiceService.BTC, + ]; + + private static readonly CONTRACT_FEE = 0.01; + + private juiceService: JuiceService; + private krakenService: KrakenService; + + constructor(private readonly moduleRef: ModuleRef) { + super(); + } + + onModuleInit() { + this.juiceService = this.moduleRef.get(JuiceService, { strict: false }); + this.krakenService = this.moduleRef.get(KrakenService, { strict: false }); + } + + async getPrice(from: string, to: string): Promise { + if (!PricingJuiceService.ALLOWED_ASSETS.includes(from)) throw new Error(`from asset ${from} is not allowed`); + if (!PricingJuiceService.ALLOWED_ASSETS.includes(to)) throw new Error(`to asset ${to} is not allowed`); + if (from !== PricingJuiceService.JUICE && to !== PricingJuiceService.JUICE) + throw new Error(`from asset ${from} to asset ${to} is not allowed`); + + // TODO: This calculation is only correct for purchases + const contractPrice = (await this.juiceService.getJuicePrice()) * (1 + PricingJuiceService.CONTRACT_FEE); + + let totalPrice = contractPrice; + + if ([from, to].includes(PricingJuiceService.BTC)) { + const usdPrice = await this.krakenService.getPrice('USD', PricingJuiceService.BTC); + totalPrice = usdPrice.convert(contractPrice); + } + + const assetPrice = from === PricingJuiceService.JUICE ? 1 / totalPrice : totalPrice; + + return Price.create(from, to, Util.round(assetPrice, 8)); + } +} diff --git a/src/subdomains/supporting/pricing/services/pricing.service.ts b/src/subdomains/supporting/pricing/services/pricing.service.ts index f78201d41c..23e69d98a3 100644 --- a/src/subdomains/supporting/pricing/services/pricing.service.ts +++ b/src/subdomains/supporting/pricing/services/pricing.service.ts @@ -27,6 +27,7 @@ import { PricingConstantService } from './integration/pricing-constant.service'; import { PricingDeuroService } from './integration/pricing-deuro.service'; import { PricingDexService } from './integration/pricing-dex.service'; import { PricingEbel2xService } from './integration/pricing-ebel2x.service'; +import { PricingJuiceService } from './integration/pricing-juice.service'; import { PricingFrankencoinService } from './integration/pricing-frankencoin.service'; import { PricingRealUnitService } from './integration/pricing-realunit.service'; @@ -69,6 +70,7 @@ export class PricingService implements OnModuleInit { readonly currencyService: CurrencyService, readonly frankencoinService: PricingFrankencoinService, readonly deuroService: PricingDeuroService, + readonly juiceService: PricingJuiceService, readonly ebel2xService: PricingEbel2xService, readonly realunitService: PricingRealUnitService, readonly constantService: PricingConstantService, @@ -86,6 +88,7 @@ export class PricingService implements OnModuleInit { [PriceSource.CURRENCY]: currencyService, [PriceSource.FRANKENCOIN]: frankencoinService, [PriceSource.DEURO]: deuroService, + [PriceSource.JUICE]: juiceService, [PriceSource.EBEL2X]: ebel2xService, [PriceSource.REALUNIT]: realunitService, [PriceSource.CONSTANT]: constantService,