diff --git a/.env.example b/.env.example index cc883d3..653fc18 100644 --- a/.env.example +++ b/.env.example @@ -48,8 +48,8 @@ GAS_FEE_EVM=0.00001 DECIMAL_TOKEN_EVM=18 # coinmarketcap -COINMARKET_KEY=233ae614-1377-40e6-83c6-c042185a5a23 -COINMARKET_URL='https://pro-api.coinmarketcap.com/v2/cryptocurrency/quotes/latest?id=1027,8646' +COINMARKET_KEY= +COINMARKET_URL= MINA_BRIDGE_SC_PRIVATE_KEY=EKF19hihcXry9QMttf719fVp56DuRB2vZySdeQ1y9BkkvWWxnJAa diff --git a/.github/workflows/auto-deploy-develop.yml b/.github/workflows/auto-deploy-develop.yml index 34325dd..6d6a4e1 100644 --- a/.github/workflows/auto-deploy-develop.yml +++ b/.github/workflows/auto-deploy-develop.yml @@ -13,6 +13,6 @@ jobs: with: node-version: v18.20.4 - run: | - echo "${{ vars.MINA_BRIDGE_BE_DEV }}" >> .env + echo "${{ secrets.MINA_BRIDGE_BE_DEV }}" >> .env docker build . -t mina-bridge:1.0.0 - docker compose -f docker-compose.dev.yaml up -d --remove-orphans + docker-compose -f docker-compose.dev.yaml up -d --remove-orphans diff --git a/.github/workflows/auto-deploy-test.yml b/.github/workflows/auto-deploy-test.yml index 958e677..bf809cc 100644 --- a/.github/workflows/auto-deploy-test.yml +++ b/.github/workflows/auto-deploy-test.yml @@ -13,6 +13,6 @@ jobs: with: node-version: v18.20.4 - run: | - echo "${{ vars.MINA_BRIDGE_BE_TEST }}" >> .env + echo "${{ secrets.MINA_BRIDGE_BE_TEST }}" >> .env docker build . -t mina-bridge:1.0.0 docker compose -f docker-compose.dev.yaml up -d --remove-orphans diff --git a/design/add-new-token.puml b/design/add-new-token.puml new file mode 100644 index 0000000..a279d3e --- /dev/null +++ b/design/add-new-token.puml @@ -0,0 +1,41 @@ +@startuml +actor admin as "Admin" +boundary admin_site as "Admin Site" +queue bull_queue +boundary metamask +boundary aurowallet +control api +database db +actor user as "User" +boundary user_site as "User Site" + +group Admin create new pair on web ui + + admin -> admin_site: enter create pair page + admin -> admin_site: fill in form + admin -> admin_site: click create button + admin_site -> api: create pair in database + + api -> bull_queue: create job new pair + api --> admin_site: pair created ok + + +end +group Job deploy token + bull_queue -> job: trigger job + job -> mina: create token + job -> eth: whitelist token +end +group Crawler confirm new pair + mina -> crawler: event from mina bridge + eth -> crawler: event from eth bridge + crawler --> crawler: process event + crawler -> db: save pair status +end +group User see new pair + user -> user_site: view pair list page + user_site -> api: list of pairs + api-> user_site: pairs include new pair + user_site --> user: display pairs +end +@enduml \ No newline at end of file diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index f642858..3ef248a 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -66,34 +66,34 @@ services: networks: - myNetwork user: node - sender-mina-2: - image: mina-bridge:1.0.0 - command: > - sh -c "npm run console sender-mina-bridge-unlock" - tty: true - restart: always - environment: - SIGNER_MINA_PRIVATE_KEY : ${SIGNER_MINA_PRIVATE_KEY_2} - depends_on: - - postgres - - redis - networks: - - myNetwork - user: node - sender-mina-3: - image: mina-bridge:1.0.0 - command: > - sh -c "npm run console sender-mina-bridge-unlock" - tty: true - restart: always - environment: - SIGNER_MINA_PRIVATE_KEY : ${SIGNER_MINA_PRIVATE_KEY_3} - depends_on: - - postgres - - redis - networks: - - myNetwork - user: node + # sender-mina-2: + # image: mina-bridge:1.0.0 + # command: > + # sh -c "npm run console sender-mina-bridge-unlock" + # tty: true + # restart: always + # environment: + # SIGNER_MINA_PRIVATE_KEY : ${SIGNER_MINA_PRIVATE_KEY_2} + # depends_on: + # - postgres + # - redis + # networks: + # - myNetwork + # user: node + # sender-mina-3: + # image: mina-bridge:1.0.0 + # command: > + # sh -c "npm run console sender-mina-bridge-unlock" + # tty: true + # restart: always + # environment: + # SIGNER_MINA_PRIVATE_KEY : ${SIGNER_MINA_PRIVATE_KEY_3} + # depends_on: + # - postgres + # - redis + # networks: + # - myNetwork + # user: node validate-evm-signature-1: image: mina-bridge:1.0.0 command: > diff --git a/src/config/common.config.ts b/src/config/common.config.ts index 044ac29..9143b97 100644 --- a/src/config/common.config.ts +++ b/src/config/common.config.ts @@ -2,7 +2,7 @@ import { ConfigService } from '@nestjs/config'; import { EEnvKey } from '../constants/env.constant.js'; import { RpcFactory } from '../shared/modules/web3/web3.module.js'; -import { ETHBridgeContract } from '../shared/modules/web3/web3.service.js'; +import { Erc20ContractTemplate, ETHBridgeContract } from '../shared/modules/web3/web3.service.js'; async function createRpcService(configService: ConfigService) { return await RpcFactory(configService); @@ -18,8 +18,11 @@ function getEthBridgeAddress(configService: ConfigService) { function getEthBridgeStartBlock(configService: ConfigService) { return +configService.get(EEnvKey.ETH_BRIDGE_START_BLOCK)!; } - -async function initializeEthContract(configService: ConfigService) { +export interface IRpcInit { + bridgeContract: ETHBridgeContract; + erc20Template: Erc20ContractTemplate; +} +async function initializeEthContract(configService: ConfigService): Promise { const [rpcEthService, address, _startBlock] = await Promise.all([ createRpcEthService(configService), getEthBridgeAddress(configService), @@ -27,6 +30,9 @@ async function initializeEthContract(configService: ConfigService) { ]); // Instantiate the ETHBridgeContract with the resolved dependencies - return new ETHBridgeContract(rpcEthService, address!, _startBlock); + return { + bridgeContract: new ETHBridgeContract(rpcEthService, address!, _startBlock), + erc20Template: new Erc20ContractTemplate(rpcEthService), + }; } export { createRpcService, createRpcEthService, getEthBridgeAddress, getEthBridgeStartBlock, initializeEthContract }; diff --git a/src/config/config.module.ts b/src/config/config.module.ts index f891936..d0180d6 100644 --- a/src/config/config.module.ts +++ b/src/config/config.module.ts @@ -52,7 +52,7 @@ const getEnvFile = () => { [EEnvKey.MINA_BRIDGE_CONTRACT_ADDRESS]: Joi.string().required(), [EEnvKey.ETH_BRIDGE_CONTRACT_ADDRESS]: Joi.string().required(), [EEnvKey.ETH_TOKEN_BRIDGE_ADDRESS]: Joi.string().required(), - [EEnvKey.MINA_TOKEN_BRIDGE_ADDRESS]: Joi.string().required(), + // [EEnvKey.MINA_TOKEN_BRIDGE_ADDRESS]: Joi.string().required(), [EEnvKey.ADMIN_MESSAGE_FOR_SIGN]: Joi.string().required(), [EEnvKey.MINA_BRIDGE_SC_PRIVATE_KEY]: Joi.string().required(), [EEnvKey.ETH_BRIDGE_DOMAIN_NAME]: Joi.string().required(), diff --git a/src/constants/blockchain.constant.ts b/src/constants/blockchain.constant.ts index 0388ada..6f5f314 100644 --- a/src/constants/blockchain.constant.ts +++ b/src/constants/blockchain.constant.ts @@ -21,6 +21,9 @@ export enum EEventStatus { export enum ETokenPairStatus { ENABLE = 'enable', DISABLE = 'disable', + CREATED = 'created', + DEPLOYING = 'deploying', + DEPLOY_FAILED = 'deploy_failed', } export enum EMinaChainEnviroment { diff --git a/src/constants/env.constant.ts b/src/constants/env.constant.ts index 56aca8d..b5b525f 100644 --- a/src/constants/env.constant.ts +++ b/src/constants/env.constant.ts @@ -31,6 +31,7 @@ export enum EEnvKey { MINA_TOKEN_BRIDGE_ADDRESS = 'MINA_TOKEN_BRIDGE_ADDRESS', MINA_BRIDGE_SC_PRIVATE_KEY = 'MINA_BRIDGE_SC_PRIVATE_KEY', SIGNER_PRIVATE_KEY = 'SIGNER_PRIVATE_KEY', + ADMIN_EVM_PRIVATE_KEY = 'ADMIN_EVM_PRIVATE_KEY', NUMBER_OF_BLOCK_PER_JOB = 'NUMBER_OF_BLOCK_PER_JOB', ADMIN_MESSAGE_FOR_SIGN = 'ADMIN_MESSAGE_FOR_SIGN', SIGNER_MINA_PRIVATE_KEY = 'SIGNER_MINA_PRIVATE_KEY', diff --git a/src/constants/error.constant.ts b/src/constants/error.constant.ts index e7ebaf8..adf0dfd 100644 --- a/src/constants/error.constant.ts +++ b/src/constants/error.constant.ts @@ -1,6 +1,7 @@ export enum EError { OTHER_SYSTEM_ERROR = 'OTHER_SYSTEM_ERROR', FORBIDDEN_RESOURCE = 'FORBIDDEN_RESOURCE', + ACTION_CANNOT_PROCESSED = 'ACTION_CANNOT_PROCESSED', DUPLICATED_ACTION = 'DUPLICATED_ACTION', USER_NOT_FOUND = 'USER_NOT_FOUND', WRONG_EMAIL_OR_PASS = 'WRONG_EMAIL_OR_PASS', diff --git a/src/constants/queue.constant.ts b/src/constants/queue.constant.ts index a798719..902d627 100644 --- a/src/constants/queue.constant.ts +++ b/src/constants/queue.constant.ts @@ -5,3 +5,9 @@ export enum EQueueName { } export const getEvmValidatorQueueName = (index: number) => `EVM_VALIDATOR_${index}`; export const getMinaValidatorQueueName = (index: number) => `MINA_VALIDATOR_${index}`; + +// job priority, lower index is higher priority +export enum EJobPriority { + DEPLOY_TOKEN, + UNLOCK, +} diff --git a/src/database/migrations/1735615966984-add-token-fields-common-config.ts b/src/database/migrations/1735615966984-add-token-fields-common-config.ts new file mode 100644 index 0000000..80b22df --- /dev/null +++ b/src/database/migrations/1735615966984-add-token-fields-common-config.ts @@ -0,0 +1,88 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddTokenFieldsCommonConfig1735615966984 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + return queryRunner.addColumns('common_configuration', [ + new TableColumn({ + name: 'from_chain', + type: 'varchar', + length: '255', + isNullable: true, + }), + new TableColumn({ + name: 'to_chain', + type: 'varchar', + length: '255', + isNullable: true, + }), + new TableColumn({ + name: 'from_symbol', + type: 'varchar', + length: '255', + isNullable: true, + }), + new TableColumn({ + name: 'to_symbol', + type: 'varchar', + length: '255', + isNullable: true, + }), + new TableColumn({ + name: 'from_address', + type: 'varchar', + length: '255', + isNullable: true, + }), + new TableColumn({ + name: 'to_address', + type: 'varchar', + length: '255', + isNullable: true, + }), + new TableColumn({ + name: 'from_decimal', + type: 'int', + isNullable: true, + }), + new TableColumn({ + name: 'to_decimal', + type: 'int', + isNullable: true, + }), + new TableColumn({ + name: 'from_sc_address', + type: 'varchar', + length: '255', + isNullable: true, + }), + new TableColumn({ + name: 'to_sc_address', + type: 'varchar', + length: '255', + isNullable: true, + }), + new TableColumn({ + name: 'status', + type: 'varchar', + length: '255', + isNullable: true, + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + return queryRunner.dropColumns('common_configuration', [ + 'from_chain', + 'to_chain', + 'from_symbol', + 'to_symbol', + 'from_address', + 'to_address', + 'from_decimal', + 'to_decimal', + 'from_sc_address', + 'to_sc_address', + 'status', + ]); + } +} diff --git a/src/database/migrations/1736238200965-unique-token-address.ts b/src/database/migrations/1736238200965-unique-token-address.ts new file mode 100644 index 0000000..b928050 --- /dev/null +++ b/src/database/migrations/1736238200965-unique-token-address.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner, TableUnique } from 'typeorm'; + +export class UniqueTokenAddress1736238200965 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createUniqueConstraint( + 'common_configuration', + new TableUnique({ + name: 'unique_from_address', + columnNames: ['from_address'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropUniqueConstraint('common_configuration', 'unique-from-address'); + } +} diff --git a/src/database/migrations/1736240154189-add-toggle-visibility-token-pair.ts b/src/database/migrations/1736240154189-add-toggle-visibility-token-pair.ts new file mode 100644 index 0000000..4d955ea --- /dev/null +++ b/src/database/migrations/1736240154189-add-toggle-visibility-token-pair.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddToggleVisibilityTokenPair1736240154189 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + return queryRunner.addColumns('common_configuration', [ + new TableColumn({ + name: 'is_hidden', + type: 'boolean', + default: false, + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + return queryRunner.dropColumns('common_configuration', ['is_hidden']); + } +} diff --git a/src/database/repositories/common-configuration.repository.ts b/src/database/repositories/common-configuration.repository.ts index ee6e5bb..baed1c8 100644 --- a/src/database/repositories/common-configuration.repository.ts +++ b/src/database/repositories/common-configuration.repository.ts @@ -1,18 +1,42 @@ +import { isArray, isNotEmpty } from 'class-validator'; import { UpdateCommonConfigBodyDto } from 'modules/users/dto/common-config-request.dto.js'; import { EntityRepository } from 'nestjs-typeorm-custom-repository'; +import { Brackets, ILike, In } from 'typeorm'; import { ETableName } from '../../constants/entity.constant.js'; import { BaseRepository } from '../../core/base-repository.js'; import { CommonConfig } from '../../modules/crawler/entities/common-config.entity.js'; +import { GetTokensReqDto } from '../../modules/users/dto/user-request.dto.js'; @EntityRepository(CommonConfig) export class CommonConfigRepository extends BaseRepository { protected alias: ETableName = ETableName.COMMON_CONFIGURATION; - public getCommonConfig() { - return this.createQueryBuilder(`${this.alias}`).select().getOne(); - } + public getManyAndPagination(dto: GetTokensReqDto, role: 'user' | 'admin' = 'admin') { + const qb = this.createQb(); + qb.select(); + if (isArray(dto.statuses)) { + qb.andWhere({ status: In(dto.statuses) }); + } + if (isNotEmpty(dto.assetName)) { + qb.andWhere({ asset: ILike(`%${dto.assetName}%`) }); + } + if (role === 'user') { + qb.andWhere({ isHidden: false }); + } + if (isNotEmpty(dto.tokenAddress)) { + qb.andWhere( + new Brackets(qb => { + qb.orWhere({ fromAddress: ILike(dto.tokenAddress) }); + qb.orWhere({ toAddress: ILike(dto.tokenAddress) }); + }), + ); + } + qb.orderBy('id', 'DESC'); + this.queryBuilderAddPagination(qb, dto); + return qb.getManyAndCount(); + } public updateCommonConfig(id: number, updateConfig: UpdateCommonConfigBodyDto) { return this.createQueryBuilder(`${this.alias}`) .update(CommonConfig) diff --git a/src/database/repositories/event-log.repository.ts b/src/database/repositories/event-log.repository.ts index ef6440d..3e9691c 100644 --- a/src/database/repositories/event-log.repository.ts +++ b/src/database/repositories/event-log.repository.ts @@ -51,6 +51,7 @@ export class EventLogRepository extends BaseRepository { `${this.alias}.id as "eventLogId"`, `${this.alias}.network_received as "network"`, `${this.alias}.sender_address as "senderAddress"`, + `${this.alias}.token_received_address as "tokenReceivedAddress"`, ]); qb.leftJoin(`${this.alias}.validator`, 'signature'); @@ -157,10 +158,14 @@ export class EventLogRepository extends BaseRepository { return queryBuilder.getManyAndCount(); } - public async sumAmountBridgeOfUserInDay(address: string): Promise<{ totalamount: string }> { + public async sumAmountBridgeOfUserInDay( + address: string, + tokenReceivedAddress: string, + ): Promise<{ totalamount: string }> { const qb = this.createQb(); qb.select([`${this.alias}.sender_address`, `SUM(CAST(${this.alias}.amount_from as DECIMAL(100,2))) as totalamount`]) .where(`${this.alias}.sender_address = :address`, { address }) + .andWhere({ tokenReceivedAddress }) .andWhere(`${this.alias}.block_time_lock BETWEEN ${startOfDayUnix(new Date())} AND ${endOfDayUnix(new Date())}`) .groupBy(`${this.alias}.sender_address`); return qb.getRawOne() as any; diff --git a/src/database/seeds/common_config.seed.ts b/src/database/seeds/common_config.seed.ts index 682a888..fb2b175 100644 --- a/src/database/seeds/common_config.seed.ts +++ b/src/database/seeds/common_config.seed.ts @@ -3,6 +3,8 @@ import { DataSource } from 'typeorm'; import { Seeder } from 'typeorm-extension'; import { COMMOM_CONFIG_TIP, COMMON__CONFIG_DAILY_QUOTA, EAsset } from '../../constants/api.constant.js'; +import { ENetworkName, ETokenPairStatus } from '../../constants/blockchain.constant.js'; +import { EEnvKey } from '../../constants/env.constant.js'; import { CommonConfig } from '../../modules/crawler/entities/common-config.entity.js'; export default class CommonConfigSeeder implements Seeder { @@ -12,11 +14,22 @@ export default class CommonConfigSeeder implements Seeder { await repository.delete({}); await repository.insert( new CommonConfig({ - tip: COMMOM_CONFIG_TIP, + bridgeFee: COMMOM_CONFIG_TIP, dailyQuota: COMMON__CONFIG_DAILY_QUOTA, - feeUnlockEth: '0.0001', - feeUnlockMina: '0.00001', + unlockingFee: '0.0001', + mintingFee: '0.00001', asset: EAsset.ETH, + fromAddress: '0x0000000000000000000000000000000000000000', + toAddress: process.env[EEnvKey.MINA_TOKEN_BRIDGE_ADDRESS], + fromScAddress: process.env[EEnvKey.ETH_BRIDGE_CONTRACT_ADDRESS], + toScAddress: process.env[EEnvKey.MINA_BRIDGE_CONTRACT_ADDRESS], + fromDecimal: 18, + toDecimal: 9, + fromSymbol: 'ETH', + toSymbol: 'WETH', + fromChain: ENetworkName.ETH, + toChain: ENetworkName.MINA, + status: ETokenPairStatus.ENABLE, }), ); } diff --git a/src/database/seeds/token_pairs.seed.ts b/src/database/seeds/token_pairs.seed.ts index f778a72..8a05688 100644 --- a/src/database/seeds/token_pairs.seed.ts +++ b/src/database/seeds/token_pairs.seed.ts @@ -2,56 +2,8 @@ import * as dotenv from 'dotenv'; import { DataSource } from 'typeorm'; import { Seeder } from 'typeorm-extension'; -import { EAsset } from '../../constants/api.constant.js'; -import { ENetworkName, ETokenPairStatus } from '../../constants/blockchain.constant.js'; -import { TokenPair } from '../../modules/users/entities/tokenpair.entity.js'; - export default class TokenPairsSeeder implements Seeder { public async run(dataSource: DataSource): Promise { dotenv.config(); - const repository = dataSource.getRepository(TokenPair); - const listToken = [ - { - fromChain: ENetworkName.ETH, - toChain: ENetworkName.MINA, - fromSymbol: EAsset.ETH, - toSymbol: EAsset.WETH, - fromAddress: process.env.ETH_TOKEN_BRIDGE_ADDRESS, - toAddress: process.env.MINA_TOKEN_BRIDGE_ADDRESS, - fromDecimal: 18, - toDecimal: 9, - fromScAddress: process.env.ETH_BRIDGE_CONTRACT_ADDRESS, - toScAddress: process.env.MINA_BRIDGE_CONTRACT_ADDRESS, - }, - { - fromChain: ENetworkName.MINA, - toChain: ENetworkName.ETH, - fromSymbol: EAsset.WETH, - toSymbol: EAsset.ETH, - fromAddress: process.env.MINA_TOKEN_BRIDGE_ADDRESS, - toAddress: process.env.ETH_TOKEN_BRIDGE_ADDRESS, - fromDecimal: 9, - toDecimal: 18, - fromScAddress: process.env.MINA_BRIDGE_CONTRACT_ADDRESS, - toScAddress: process.env.ETH_BRIDGE_CONTRACT_ADDRESS, - }, - ]; - await repository.delete({}); - for (const token of listToken) { - const newToken = new TokenPair({ - fromChain: token.fromChain, - toChain: token.toChain, - fromSymbol: token.fromSymbol, - toSymbol: token.toSymbol, - fromAddress: token.fromAddress, - toAddress: token.toAddress, - fromDecimal: token.fromDecimal, - toDecimal: token.toDecimal, - fromScAddress: token.fromScAddress, - toScAddress: token.toScAddress, - status: ETokenPairStatus.ENABLE, - }); - await repository.insert(newToken); - } } } diff --git a/src/modules/crawler/crawler.console.ts b/src/modules/crawler/crawler.console.ts index 952c355..7a0782f 100644 --- a/src/modules/crawler/crawler.console.ts +++ b/src/modules/crawler/crawler.console.ts @@ -8,7 +8,8 @@ import { QueueService } from '../../shared/modules/queue/queue.service.js'; import { sleep } from '../../shared/utils/promise.js'; import { BlockchainEVMCrawler } from './crawler.evmbridge.js'; import { SCBridgeMinaCrawler } from './crawler.minabridge.js'; -import { IGenerateSignature, IUnlockToken } from './interfaces/job.interface.js'; +import { TokenDeployer } from './deploy-token.js'; +import { IDeployToken, IGenerateSignature, ISenderJobPayload, IUnlockToken } from './interfaces/job.interface.js'; import { JobUnlockProvider } from './job-unlock.provider.js'; import { SenderEVMBridge } from './sender.evmbridge.js'; import { SenderMinaBridge } from './sender.minabridge.js'; @@ -26,6 +27,7 @@ export class CrawlerConsole { private readonly queueService: QueueService, private readonly unlockProviderService: JobUnlockProvider, private readonly poaSyncer: POASync, + private readonly tokenDeployerService: TokenDeployer, ) {} private readonly logger = this.loggerService.getLogger('CRAWLER_CONSOLE'); @@ -34,7 +36,7 @@ export class CrawlerConsole { description: 'Crawl ETH Bridge contract', }) async handleCrawlETHBridge() { - const safeBlock = +this.configService.get(EEnvKey.ETH_TOKEN_BRIDGE_ADDRESS); + const safeBlock = +this.configService.get(EEnvKey.EVM_SAFE_BLOCK); while (true) { try { await this.blockchainEVMCrawler.handleEventCrawlBlock(safeBlock); @@ -84,13 +86,17 @@ export class CrawlerConsole { }) async handleSenderETHBridgeUnlock() { this.logger.info('ETH_SENDER_JOB: started'); - await this.queueService.handleQueueJob(EQueueName.EVM_SENDER_QUEUE, async (data: IUnlockToken) => { - const result = await this.senderEVMBridge.handleUnlockEVM(data.eventLogId); - if (result.error) { - // catch in queueService - throw result.error; - } - }); + await this.queueService.handleQueueJob( + EQueueName.EVM_SENDER_QUEUE, + async (data: ISenderJobPayload) => { + switch (data.type) { + case 'deploy-token': + return this.tokenDeployerService.deployTokenEth((data.payload as IDeployToken).tokenPairId); + case 'unlock': + return this.senderEVMBridge.handleUnlockEVM((data.payload as IUnlockToken).eventLogId); + } + }, + ); } @Command({ @@ -115,8 +121,13 @@ export class CrawlerConsole { }) async handleSenderMinaBridgeUnlock() { this.logger.info('MINA_SENDER_JOB: started'); - await this.queueService.handleQueueJob(EQueueName.MINA_SENDER_QUEUE, (data: IUnlockToken) => { - return this.senderMinaBridge.handleUnlockMina(data.eventLogId); + await this.queueService.handleQueueJob(EQueueName.MINA_SENDER_QUEUE, (data: ISenderJobPayload) => { + switch (data.type) { + case 'deploy-token': + return this.tokenDeployerService.deployTokenMina((data.payload as IDeployToken).tokenPairId); + case 'unlock': + return this.senderMinaBridge.handleUnlockMina((data.payload as IUnlockToken).eventLogId); + } }); } @Command({ diff --git a/src/modules/crawler/crawler.evmbridge.ts b/src/modules/crawler/crawler.evmbridge.ts index 7dde7ee..e7f1ac0 100644 --- a/src/modules/crawler/crawler.evmbridge.ts +++ b/src/modules/crawler/crawler.evmbridge.ts @@ -82,8 +82,8 @@ export class BlockchainEVMCrawler { const inputAmount = event.returnValues.amount; const fromTokenDecimal = this.configService.get(EEnvKey.DECIMAL_TOKEN_EVM), toTokenDecimal = this.configService.get(EEnvKey.DECIMAL_TOKEN_MINA); - const config = await configRepo.findOneBy({}); - assert(!!config?.tip, 'tip config undefined'); + const config = await configRepo.findOneBy({ fromAddress: event.returnValues.tokenAddress }); + assert(!!config?.bridgeFee, 'tip config undefined'); const { success, amountReceiveNoDecimalPlace, @@ -95,8 +95,8 @@ export class BlockchainEVMCrawler { fromDecimal: fromTokenDecimal, toDecimal: toTokenDecimal, inputAmountNoDecimalPlaces: inputAmount, - gasFeeWithDecimalPlaces: config.feeUnlockMina, - tipPercent: +config!.tip, + gasFeeWithDecimalPlaces: config.mintingFee, + tipPercent: +config!.bridgeFee, }); if (error) { this.logger.error('Calculate error', error); @@ -109,7 +109,7 @@ export class BlockchainEVMCrawler { networkFrom: ENetworkName.ETH, networkReceived: ENetworkName.MINA, tokenFromName: event.returnValues.tokenName, - tokenReceivedAddress: this.configService.get(EEnvKey.MINA_TOKEN_BRIDGE_ADDRESS), + tokenReceivedAddress: config.toAddress, txHashLock: event.transactionHash, receiveAddress: event.returnValues.receipt, blockNumber: event.blockNumber, @@ -151,7 +151,7 @@ export class BlockchainEVMCrawler { tokenReceivedName: EAsset.ETH, }); // update total weth burned. - const currentConfig = await configRepo.findOneBy({}); + const currentConfig = await configRepo.findOneBy({ fromAddress: event.returnValues.tokenAddress }); assert(currentConfig, 'comomn config not exist'); const newTotalEthBurnt = new BigNumber(currentConfig.totalWethBurnt).plus(existLockTx.amountFrom).toString(); diff --git a/src/modules/crawler/crawler.minabridge.ts b/src/modules/crawler/crawler.minabridge.ts index 0442ca8..1609284 100644 --- a/src/modules/crawler/crawler.minabridge.ts +++ b/src/modules/crawler/crawler.minabridge.ts @@ -113,7 +113,7 @@ export class SCBridgeMinaCrawler { }); // update total weth minted - const currentConfig = await configRepo.findOneBy({}); + const currentConfig = await configRepo.findOneBy({ toAddress: event.event.data.tokenAddress.toBase58() }); assert(currentConfig, 'comomn config not exist'); const newTotalEthMinted = new BigNumber(currentConfig.totalWethMinted).plus(existLockTx.amountReceived).toString(); @@ -142,9 +142,9 @@ export class SCBridgeMinaCrawler { } const fromTokenDecimal = this.configService.get(EEnvKey.DECIMAL_TOKEN_MINA), toTokenDecimal = this.configService.get(EEnvKey.DECIMAL_TOKEN_EVM); - const config = await configRepo.findOneBy({}); + const config = await configRepo.findOneBy({ toAddress: event.event.data.tokenAddress.toBase58() }); - assert(!!config?.tip, 'tip config undefined'); + assert(!!config?.bridgeFee, 'tip config undefined'); const { tipWithDecimalPlaces, @@ -156,17 +156,17 @@ export class SCBridgeMinaCrawler { fromDecimal: fromTokenDecimal, toDecimal: toTokenDecimal, inputAmountNoDecimalPlaces: inputAmount, - gasFeeWithDecimalPlaces: config.feeUnlockEth, - tipPercent: Number(config.tip).valueOf(), + gasFeeWithDecimalPlaces: config.unlockingFee, + tipPercent: Number(config.bridgeFee).valueOf(), }); const eventUnlock: Partial = { senderAddress: JSON.parse(JSON.stringify(event.event.data.locker)), amountFrom: inputAmount, - tokenFromAddress: this.configService.get(EEnvKey.MINA_TOKEN_BRIDGE_ADDRESS), + tokenFromAddress: config.fromAddress, networkFrom: ENetworkName.MINA, networkReceived: ENetworkName.ETH, tokenFromName: EAsset.WETH, - tokenReceivedAddress: this.configService.get(EEnvKey.ETH_TOKEN_BRIDGE_ADDRESS), + tokenReceivedAddress: config.toAddress, txHashLock, receiveAddress: receiveAddress, blockNumber: +event.blockHeight.toString(), diff --git a/src/modules/crawler/crawler.module.ts b/src/modules/crawler/crawler.module.ts index 7fcb727..ad7654a 100644 --- a/src/modules/crawler/crawler.module.ts +++ b/src/modules/crawler/crawler.module.ts @@ -11,6 +11,7 @@ import { BatchJobGetPriceToken } from './batch.tokenprice.js'; import { CrawlerConsole } from './crawler.console.js'; import { BlockchainEVMCrawler } from './crawler.evmbridge.js'; import { SCBridgeMinaCrawler } from './crawler.minabridge.js'; +import { TokenDeployer } from './deploy-token.js'; import { JobUnlockProvider } from './job-unlock.provider.js'; import { SenderEVMBridge } from './sender.evmbridge.js'; import { SenderMinaBridge } from './sender.minabridge.js'; @@ -36,6 +37,7 @@ import { POASync } from './services/token-poa-sync.service.js'; BatchJobGetPriceToken, JobUnlockProvider, POASync, + TokenDeployer, ], }) export class CrawlerModule {} diff --git a/src/modules/crawler/deploy-token.ts b/src/modules/crawler/deploy-token.ts new file mode 100644 index 0000000..75607fb --- /dev/null +++ b/src/modules/crawler/deploy-token.ts @@ -0,0 +1,172 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { isNotEmpty } from 'class-validator'; +import { FungibleToken, FungibleTokenAdmin } from 'mina-fungible-token'; +import { AccountUpdate, Bool, fetchAccount, Mina, PrivateKey, PublicKey, UInt8 } from 'o1js'; +import { IsNull } from 'typeorm'; + +import { ETokenPairStatus } from '../../constants/blockchain.constant.js'; +import { EEnvKey } from '../../constants/env.constant.js'; +import { EJobPriority, EQueueName } from '../../constants/queue.constant.js'; +import { CommonConfigRepository } from '../../database/repositories/common-configuration.repository.js'; +import { LoggerService } from '../../shared/modules/logger/logger.service.js'; +import { QueueService } from '../../shared/modules/queue/queue.service.js'; +import { ETHBridgeContract } from '../../shared/modules/web3/web3.service.js'; +import { ISenderJobPayload } from './interfaces/job.interface.js'; + +@Injectable() +export class TokenDeployer { + constructor( + private readonly ethBridgeContract: ETHBridgeContract, + private readonly configService: ConfigService, + private readonly queueService: QueueService, + private readonly tokenPairRepo: CommonConfigRepository, + private readonly loggerService: LoggerService, + ) {} + private readonly logger = this.loggerService.getLogger('DEPLOY_TOKEN_SERVICE'); + public async deployTokenEth(tokenPairId: number) { + const tokenInfo = await this.tokenPairRepo.findOneBy({ id: tokenPairId }); + if (!tokenInfo) { + this.logger.info('Token not found', tokenPairId); + return; + } + try { + await this.ethBridgeContract.whitelistToken(tokenInfo.fromAddress); + await this.tokenPairRepo.update( + { id: tokenPairId }, + { + status: ETokenPairStatus.ENABLE, + }, + ); + } catch (error) { + this.logger.error(error); + await this.tokenPairRepo.update( + { id: tokenPairId }, + { + status: ETokenPairStatus.DEPLOY_FAILED, + }, + ); + } + } + public async deployTokenMina(tokenPairId: number) { + const tokenInfo = await this.tokenPairRepo.findOneBy({ id: tokenPairId }); + if (!tokenInfo) { + this.logger.info('Token not found', tokenPairId); + return; + } + if (isNotEmpty(tokenInfo.toAddress)) { + this.logger.info(`token is already deployed`); + // must try invoke eth sender job + await this.addJobWhitelistTokenEth(tokenPairId); + return; + } + const src = 'https://github.com/MinaFoundation/mina-fungible-token/blob/main/FungibleToken.ts'; + const MINAURL = this.configService.get(EEnvKey.MINA_BRIDGE_RPC_OPTIONS); + const ARCHIVEURL = this.configService.get(EEnvKey.MINA_BRIDGE_ARCHIVE_RPC_OPTIONS); + + const network = Mina.Network({ + mina: MINAURL, + archive: ARCHIVEURL, + }); + Mina.setActiveInstance(network); + + // compile contract + await FungibleToken.compile(); + await FungibleTokenAdmin.compile(); + + const feePayerPrivateKey = PrivateKey.fromBase58(this.configService.get(EEnvKey.SIGNER_MINA_PRIVATE_KEY)!); // a minter + const tokenPrivateKey = PrivateKey.random(); + const tokenAdminContractPrivateKey = PrivateKey.random(); + + const fee = +this.configService.get(EEnvKey.BASE_MINA_BRIDGE_FEE); // in nanomina (1 billion = 1.0 mina) + + await Promise.all([fetchAccount({ publicKey: feePayerPrivateKey.toPublicKey() })]); + + const token = new FungibleToken(tokenPrivateKey.toPublicKey()); + const tokenAdminContract = new FungibleTokenAdmin(tokenAdminContractPrivateKey.toPublicKey()); + + this.logger.info('feePayerPrivateKey', feePayerPrivateKey.toPublicKey().toBase58()); + + let sentTx; + try { + const tx = await Mina.transaction({ sender: feePayerPrivateKey.toPublicKey(), fee }, async () => { + AccountUpdate.fundNewAccount(feePayerPrivateKey.toPublicKey(), 3); + await tokenAdminContract.deploy({ + adminPublicKey: PublicKey.fromBase58(this.configService.get(EEnvKey.MINA_BRIDGE_CONTRACT_ADDRESS)!), + }); + await token.deploy({ + symbol: tokenInfo.asset, + src: src, + }); + await token.initialize(tokenAdminContractPrivateKey.toPublicKey(), UInt8.from(9), Bool(false)); + }); + await tx.prove(); + sentTx = await tx.sign([feePayerPrivateKey, tokenAdminContractPrivateKey, tokenPrivateKey]).send(); + this.logger.info('=====================txhash: ', sentTx?.hash); + await sentTx?.wait({ maxAttempts: 300 }); + } catch (err) { + this.logger.error(err); + await this.tokenPairRepo.update( + { id: tokenPairId, toAddress: IsNull() }, + { + status: ETokenPairStatus.DEPLOY_FAILED, + }, + ); + return; // terminate the job + } + + // Save all private and public keys to a single JSON file + const keysToSave = [ + { name: 'token', privateKey: tokenPrivateKey.toBase58(), publicKey: tokenPrivateKey.toPublicKey().toBase58() }, + { + name: 'tokenAdminContract', + privateKey: tokenAdminContractPrivateKey.toBase58(), + publicKey: tokenAdminContractPrivateKey.toPublicKey().toBase58(), + }, + ]; + this.logger.info(keysToSave); + // save to db + await this.tokenPairRepo.update( + { id: tokenPairId, toAddress: IsNull() }, + { + toAddress: tokenPrivateKey.toPublicKey().toBase58(), + }, + ); + await this.addJobWhitelistTokenEth(tokenPairId); + } + + public addJobDeployTokenMina(tokenPairId: number) { + return this.queueService.addJobToQueue( + EQueueName.MINA_SENDER_QUEUE, + { + type: 'deploy-token', + payload: { + tokenPairId, + }, + }, + { + jobId: `deploy-token-${tokenPairId}`, + removeOnComplete: true, + removeOnFail: true, + priority: EJobPriority.DEPLOY_TOKEN, + }, + ); + } + private addJobWhitelistTokenEth(tokenPairId: number) { + return this.queueService.addJobToQueue( + EQueueName.EVM_SENDER_QUEUE, + { + type: 'deploy-token', + payload: { + tokenPairId, + }, + }, + { + jobId: `deploy-token-${tokenPairId}`, + removeOnComplete: true, + removeOnFail: true, + priority: EJobPriority.DEPLOY_TOKEN, + }, + ); + } +} diff --git a/src/modules/crawler/entities/common-config.entity.ts b/src/modules/crawler/entities/common-config.entity.ts index fddf9db..99e8ff8 100644 --- a/src/modules/crawler/entities/common-config.entity.ts +++ b/src/modules/crawler/entities/common-config.entity.ts @@ -1,5 +1,8 @@ +import { BigNumber } from 'bignumber.js'; +import { isEmpty, isNotEmpty } from 'class-validator'; import { Column, Entity } from 'typeorm'; +import { ENetworkName, ETokenPairStatus } from '../../../constants/blockchain.constant.js'; import { ETableName } from '../../../constants/entity.constant.js'; import { BaseEntityIncludeTime } from '../../../core/base.entity.js'; @@ -23,19 +26,19 @@ export class CommonConfig extends BaseEntityIncludeTime { default: 0, nullable: false, }) - tip: number; + bridgeFee: number; @Column({ name: 'fee_unlock_mina', type: 'varchar', }) - feeUnlockMina: string; + mintingFee: string; @Column({ name: 'fee_unlock_eth', type: 'varchar', }) - feeUnlockEth: string; + unlockingFee: string; @Column({ name: 'asset', type: 'varchar', nullable: true }) asset: string; @@ -46,8 +49,60 @@ export class CommonConfig extends BaseEntityIncludeTime { @Column({ name: 'total_weth_burnt', type: 'varchar', default: '0' }) totalWethBurnt: string; + // pairs detail + @Column({ name: 'from_chain', type: 'varchar', enum: ENetworkName, nullable: false }) + fromChain: ENetworkName; + + @Column({ name: 'to_chain', type: 'varchar', enum: ENetworkName, nullable: false }) + toChain: ENetworkName; + + @Column({ name: 'from_symbol', type: 'varchar', nullable: false }) + fromSymbol: string; + + @Column({ name: 'to_symbol', type: 'varchar', nullable: false }) + toSymbol: string; + + @Column({ name: 'from_address', type: 'varchar', nullable: false }) + fromAddress: string; + + @Column({ name: 'to_address', type: 'varchar', nullable: false }) + toAddress: string; + + @Column({ name: 'from_decimal', type: 'int', nullable: true }) + fromDecimal: number; + + @Column({ name: 'to_decimal', type: 'int', nullable: true }) + toDecimal: number; + + @Column({ name: 'from_sc_address', type: 'varchar', nullable: true }) + fromScAddress: string; + + @Column({ name: 'to_sc_address', type: 'varchar', nullable: true }) + toScAddress: string; + + @Column({ name: 'is_hidden', default: false }) + isHidden: boolean; + + @Column({ + name: 'status', + type: 'varchar', + enum: ETokenPairStatus, + default: ETokenPairStatus.ENABLE, + nullable: false, + }) + status: ETokenPairStatus; + // end pair detail constructor(value: Partial) { super(); Object.assign(this, value); } + toJSON() { + if (isEmpty(this.totalWethBurnt) || isEmpty(this.totalWethBurnt)) { + return this; + } + const totalCirculation = new BigNumber(this.totalWethMinted) + .minus(this.totalWethBurnt) + .div(BigNumber(this.fromDecimal).pow(this.toDecimal)); + return { ...this, totalCirculation: BigNumber.maximum(totalCirculation.toString(), '0') }; + } } diff --git a/src/modules/crawler/interfaces/job.interface.ts b/src/modules/crawler/interfaces/job.interface.ts index a64e496..14d01ae 100644 --- a/src/modules/crawler/interfaces/job.interface.ts +++ b/src/modules/crawler/interfaces/job.interface.ts @@ -12,6 +12,13 @@ export interface IReceiveVerifiedSignature { export interface IGenerateSignature { eventLogId: number; } +export interface ISenderJobPayload { + type: 'unlock' | 'deploy-token'; + payload: IUnlockToken | IDeployToken; +} +export interface IDeployToken { + tokenPairId: number; +} export interface IUnlockToken { eventLogId: number; } @@ -19,4 +26,5 @@ export interface IJobUnlockPayload { eventLogId: number; network: ENetworkName; senderAddress: string; + tokenReceivedAddress: string; } diff --git a/src/modules/crawler/job-unlock.provider.ts b/src/modules/crawler/job-unlock.provider.ts index 63c7545..9ba1f6f 100644 --- a/src/modules/crawler/job-unlock.provider.ts +++ b/src/modules/crawler/job-unlock.provider.ts @@ -7,7 +7,12 @@ import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity import { ENetworkName } from '../../constants/blockchain.constant.js'; import { EEnvKey } from '../../constants/env.constant.js'; -import { EQueueName, getEvmValidatorQueueName, getMinaValidatorQueueName } from '../../constants/queue.constant.js'; +import { + EJobPriority, + EQueueName, + getEvmValidatorQueueName, + getMinaValidatorQueueName, +} from '../../constants/queue.constant.js'; import { CommonConfigRepository } from '../../database/repositories/common-configuration.repository.js'; import { EventLogRepository } from '../../database/repositories/event-log.repository.js'; import { LoggerService } from '../../shared/modules/logger/logger.service.js'; @@ -18,7 +23,7 @@ import { sleep } from '../../shared/utils/promise.js'; import { getNextDayInUnix, getTimeInFutureInMinutes } from '../../shared/utils/time.js'; import { BatchJobGetPriceToken } from './batch.tokenprice.js'; import { EventLog } from './entities/event-logs.entity.js'; -import { IGenerateSignature, IJobUnlockPayload, IUnlockToken } from './interfaces/job.interface.js'; +import { IGenerateSignature, IJobUnlockPayload, ISenderJobPayload } from './interfaces/job.interface.js'; @Injectable() export class JobUnlockProvider { @@ -171,30 +176,43 @@ export class JobUnlockProvider { private async handleSendTxJobs(data: IJobUnlockPayload) { // check if there is enough threshhold -> then create an unlock job. - if (await this.isPassDailyQuota(data.senderAddress, data.network)) { + if (await this.isPassDailyQuota(data.senderAddress, data.network, data.tokenReceivedAddress)) { this.logger.warn('this tx exceed daily quota, skip until next day', data.eventLogId); await this.eventLogRepository.update(data.eventLogId, { nextSendTxJobTime: getNextDayInUnix().toString() }); return; } - await this.queueService.addJobToQueue( + await this.queueService.addJobToQueue( this.getSenderQueueName(data.network), { - eventLogId: data.eventLogId, + type: 'unlock', + payload: { + eventLogId: data.eventLogId, + }, }, { jobId: `send-unlock-${data.eventLogId}`, removeOnComplete: true, removeOnFail: true, + priority: EJobPriority.UNLOCK, }, ); } - private async isPassDailyQuota(address: string, networkReceived: ENetworkName): Promise { + private async isPassDailyQuota(address: string, networkReceived: ENetworkName, token: string): Promise { const fromDecimal = this.configService.get( networkReceived === ENetworkName.MINA ? EEnvKey.DECIMAL_TOKEN_EVM : EEnvKey.DECIMAL_TOKEN_MINA, ); const [dailyQuota, todayData] = await Promise.all([ - await this.commonConfigRepository.getCommonConfig(), - await this.eventLogRepository.sumAmountBridgeOfUserInDay(address), + await this.commonConfigRepository.findOne({ + where: { + fromAddress: token, + }, + select: { + id: true, + fromAddress: true, + dailyQuota: true, + }, + }), + await this.eventLogRepository.sumAmountBridgeOfUserInDay(address, token), ]); assert(!!dailyQuota, 'daily quota undefined'); if ( diff --git a/src/modules/crawler/sender.evmbridge.ts b/src/modules/crawler/sender.evmbridge.ts index 0912b88..2536d19 100644 --- a/src/modules/crawler/sender.evmbridge.ts +++ b/src/modules/crawler/sender.evmbridge.ts @@ -8,7 +8,6 @@ import { getEthBridgeAddress } from '../../config/common.config.js'; import { EEventStatus, ENetworkName } from '../../constants/blockchain.constant.js'; import { EEnvKey } from '../../constants/env.constant.js'; import { EError } from '../../constants/error.constant.js'; -import { CommonConfigRepository } from '../../database/repositories/common-configuration.repository.js'; import { EventLogRepository } from '../../database/repositories/event-log.repository.js'; import { MultiSignatureRepository } from '../../database/repositories/multi-signature.repository.js'; import { TokenPairRepository } from '../../database/repositories/token-pair.repository.js'; @@ -22,7 +21,6 @@ import { MultiSignature } from './entities/multi-signature.entity.js'; export class SenderEVMBridge { constructor( private readonly eventLogRepository: EventLogRepository, - private readonly commonConfigRepository: CommonConfigRepository, private readonly tokenPairRepository: TokenPairRepository, private readonly multiSignatureRepository: MultiSignatureRepository, private readonly ethBridgeContract: ETHBridgeContract, diff --git a/src/modules/crawler/sender.minabridge.ts b/src/modules/crawler/sender.minabridge.ts index 0eacc7f..f2eaada 100644 --- a/src/modules/crawler/sender.minabridge.ts +++ b/src/modules/crawler/sender.minabridge.ts @@ -7,10 +7,8 @@ import { AccountUpdate, Bool, fetchAccount, Mina, PrivateKey, PublicKey, Signatu import { EEventStatus, ENetworkName } from '../../constants/blockchain.constant.js'; import { EEnvKey } from '../../constants/env.constant.js'; import { EError } from '../../constants/error.constant.js'; -import { CommonConfigRepository } from '../../database/repositories/common-configuration.repository.js'; import { EventLogRepository } from '../../database/repositories/event-log.repository.js'; import { MultiSignatureRepository } from '../../database/repositories/multi-signature.repository.js'; -import { TokenPairRepository } from '../../database/repositories/token-pair.repository.js'; import { LoggerService } from '../../shared/modules/logger/logger.service.js'; import { canTxRetry } from '../../shared/utils/unlock.js'; import { MultiSignature } from './entities/multi-signature.entity.js'; @@ -23,18 +21,14 @@ export class SenderMinaBridge { private isContractCompiled = false; private readonly feePayerKey: PrivateKey; private readonly bridgeKey: PrivateKey; - private readonly tokenPublicKey: PublicKey; constructor( private readonly configService: ConfigService, private readonly eventLogRepository: EventLogRepository, - private readonly commonConfigRepository: CommonConfigRepository, - private readonly tokenPairRepository: TokenPairRepository, private readonly multiSignatureRepository: MultiSignatureRepository, private readonly loggerService: LoggerService, ) { this.feePayerKey = PrivateKey.fromBase58(this.configService.get(EEnvKey.SIGNER_MINA_PRIVATE_KEY)!); this.bridgeKey = PrivateKey.fromBase58(this.configService.get(EEnvKey.MINA_BRIDGE_SC_PRIVATE_KEY)!); - this.tokenPublicKey = PublicKey.fromBase58(this.configService.get(EEnvKey.MINA_TOKEN_BRIDGE_ADDRESS)!); const network = Mina.Network({ mina: this.configService.get(EEnvKey.MINA_BRIDGE_RPC_OPTIONS), @@ -47,7 +41,6 @@ export class SenderMinaBridge { private getContractsInfo() { this.logger.log('Bridge: ' + this.bridgeKey.toPublicKey().toBase58()); this.logger.log('FeePayer: ' + this.feePayerKey.toPublicKey().toBase58()); - this.logger.log('Token: ' + this.tokenPublicKey.toBase58()); } private async compileContract() { if (!this.isContractCompiled) { @@ -76,7 +69,7 @@ export class SenderMinaBridge { assert(dataLock?.amountReceived, 'invalida amount to unlock'); const { id, receiveAddress, amountReceived } = dataLock; - const result = await this.callUnlockFunction(amountReceived, id, receiveAddress); + const result = await this.callUnlockFunction(amountReceived, id, receiveAddress, dataLock.tokenReceivedAddress); // Update status eventLog when call function unlock if (result.success) { await this.eventLogRepository.updateStatusAndRetryEvenLog({ @@ -106,14 +99,15 @@ export class SenderMinaBridge { amount: string, txId: number, receiveAddress: string, + receiveTokenAddress: string, ): Promise<{ success: boolean; error: Error | null; data: string | null }> { try { - this.logger.info(`Bridge: ${this.bridgeKey.toPublicKey().toBase58()}\nToken: ${this.tokenPublicKey.toBase58()}`); + this.logger.info(`Bridge: ${this.bridgeKey.toPublicKey().toBase58()}\nToken: ${receiveTokenAddress}`); const generatedSignatures = await this.multiSignatureRepository.findBy({ txId, }); const signatureData = generatedSignatures - .map(e => [Bool(true), PublicKey.fromBase58(e.validator), Signature.fromJSON(JSON.parse(e.signature))]) + .map(e => [Bool(false), PublicKey.fromBase58(e.validator), Signature.fromJSON(JSON.parse(e.signature))]) .flat(1); this.logger.info(`Found ${generatedSignatures.length} signatures for txId= ${txId}`); this.logger.info('compile the contract...'); @@ -123,16 +117,17 @@ export class SenderMinaBridge { const feePayerPublicKey = this.feePayerKey.toPublicKey(); const bridgePublicKey = this.bridgeKey.toPublicKey(); const receiverPublicKey = PublicKey.fromBase58(receiveAddress); + const receiveTokenPublicKey = PublicKey.fromBase58(receiveTokenAddress); const zkBridge = new Bridge(bridgePublicKey); - const token = new FungibleToken(this.tokenPublicKey); + const token = new FungibleToken(receiveTokenPublicKey); const tokenId = token.deriveTokenId(); await Promise.all([ fetchAccount({ publicKey: bridgePublicKey }), fetchAccount({ publicKey: feePayerPublicKey }), fetchAccount({ publicKey: receiverPublicKey, tokenId }), fetchAccount({ - publicKey: this.tokenPublicKey, + publicKey: receiveTokenPublicKey, tokenId, }), ]); @@ -146,7 +141,13 @@ export class SenderMinaBridge { this.logger.info('build transaction and create proof...'); const tx = await Mina.transaction({ sender: feePayerPublicKey, fee }, async () => { if (!hasAccount) AccountUpdate.fundNewAccount(feePayerPublicKey); - await zkBridge.unlock(typedAmount, receiverPublicKey, UInt64.from(txId), this.tokenPublicKey, ...signatureData); + await zkBridge.unlock( + typedAmount, + receiverPublicKey, + UInt64.from(txId), + receiveTokenPublicKey, + ...signatureData, + ); }); const sentTx = await this.handleSendTxMina(txId, tx); @@ -194,7 +195,7 @@ export class SenderMinaBridge { const msg = [ ...receiverPublicKey.toFields(), ...UInt64.from(amountReceived).toFields(), - ...this.tokenPublicKey.toFields(), + ...PublicKey.fromBase58(dataLock.tokenReceivedAddress).toFields(), ]; const signature = Signature.create(signerPrivateKey, msg).toJSON(); @@ -227,6 +228,7 @@ export class SenderMinaBridge { await sentTx?.wait({ maxAttempts: 300 }); assert(sentTx?.hash, 'transaction failed'); + this.logger.info(`Tx ${sentTx.hash} is sent and waiting for crawler confirmation.`); return sentTx; } } diff --git a/src/modules/crawler/services/token-poa-sync.service.ts b/src/modules/crawler/services/token-poa-sync.service.ts index 5b29069..ce4ab50 100644 --- a/src/modules/crawler/services/token-poa-sync.service.ts +++ b/src/modules/crawler/services/token-poa-sync.service.ts @@ -1,7 +1,5 @@ import { Injectable } from '@nestjs/common'; -import assert from 'assert'; -import { ENetworkName } from '../../../constants/blockchain.constant.js'; import { CommonConfigRepository } from '../../../database/repositories/common-configuration.repository.js'; import { EventLogRepository } from '../../../database/repositories/event-log.repository.js'; @@ -12,14 +10,14 @@ export class POASync { private readonly eventLogRepo: EventLogRepository, ) {} public async handleSyncPOA() { - const currentConfig = await this.commonConfigRepo.findOneBy({}); - assert(currentConfig, 'please seed common config'); - - const mina = await this.eventLogRepo.getTotalAmoutFromNetworkReceived(ENetworkName.MINA); - const eth = await this.eventLogRepo.getTotalAmoutFromNetworkReceived(ENetworkName.ETH); - await this.commonConfigRepo.update(currentConfig.id, { - totalWethBurnt: eth.amountFromTotal, - totalWethMinted: mina.amountReceivedTotal, - }); + // TODO: update this to adapt adding new Token. + // const currentConfig = await this.commonConfigRepo.findOneBy({}); + // assert(currentConfig, 'please seed common config'); + // const mina = await this.eventLogRepo.getTotalAmoutFromNetworkReceived(ENetworkName.MINA); + // const eth = await this.eventLogRepo.getTotalAmoutFromNetworkReceived(ENetworkName.ETH); + // await this.commonConfigRepo.update(currentConfig.id, { + // totalWethBurnt: eth.amountFromTotal, + // totalWethMinted: mina.amountReceivedTotal, + // }); } } diff --git a/src/modules/users/admin.controller.ts b/src/modules/users/admin.controller.ts index b1bb1ac..5e4fff7 100644 --- a/src/modules/users/admin.controller.ts +++ b/src/modules/users/admin.controller.ts @@ -1,17 +1,24 @@ -import { Body, Controller, Get, Param, Put, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { GuardPublic } from '../../guards/guard.decorator.js'; import { AuthAdminGuard } from '../../shared/decorators/http.decorator.js'; -import { UpdateCommonConfigBodyDto } from './dto/common-config-request.dto.js'; +import { AdminService } from './admin.service.js'; +import { CreateTokenReqDto } from './dto/admin-request.dto.js'; +import { UpdateCommonConfigBodyDto, UpdateTokenPairVisibilityReqDto } from './dto/common-config-request.dto.js'; import { GetCommonConfigResponseDto } from './dto/common-config-response.dto.js'; import { GetHistoryDto, GetHistoryOfUserResponseDto } from './dto/history-response.dto.js'; +import { GetTokensReqDto } from './dto/user-request.dto.js'; import { UsersService } from './users.service.js'; @ApiTags('Admins') @Controller('admin') export class AdminController { - constructor(private readonly userService: UsersService) {} + constructor( + private readonly userService: UsersService, + private readonly adminService: AdminService, + ) {} @Get('history') @AuthAdminGuard() @@ -21,18 +28,45 @@ export class AdminController { return this.userService.getHistories(query); } - @Get('common-config') + @Get('tokens') @AuthAdminGuard() @UseGuards(AuthGuard('jwt')) @ApiOkResponse({ type: GetCommonConfigResponseDto }) - getCommonConfig() { - return this.userService.getCommonConfig(); + getCommonConfig(@Query() query: GetTokensReqDto) { + return this.adminService.getListToken(query); + } + @Get('check/:tokenAddress') + @AuthAdminGuard() + @UseGuards(AuthGuard('jwt')) + checkTokenExist(@Param('tokenAddress') tokenAddress: string) { + return this.adminService.checkTokenExist(tokenAddress); + } + + @Put('token/:id') + @AuthAdminGuard() + @UseGuards(AuthGuard('jwt')) + updateTokenPair(@Param('id') id: number, @Body() updateConfig: UpdateCommonConfigBodyDto) { + return this.userService.updateTokenConfig(id, updateConfig); + } + + @Put('token/visibility/:id') + @AuthAdminGuard() + @UseGuards(AuthGuard('jwt')) + updateTokenPairVisibility(@Param('id') id: number, @Body() updateConfig: UpdateTokenPairVisibilityReqDto) { + return this.userService.updateTokenVisibility(id, updateConfig); + } + + @Post('token/re-deploy/:id') + @AuthAdminGuard() + @UseGuards(AuthGuard('jwt')) + redeployToken(@Param('id') id: number) { + return this.adminService.redeployToken(id); } - @Put('update-common-config/:id') + @Post('new-token') @AuthAdminGuard() @UseGuards(AuthGuard('jwt')) - updateCommonConfig(@Param('id') id: number, @Body() updateConfig: UpdateCommonConfigBodyDto) { - return this.userService.updateCommonConfig(id, updateConfig); + addNewToken(@Body() payload: CreateTokenReqDto) { + return this.adminService.createNewToken(payload); } } diff --git a/src/modules/users/admin.service.ts b/src/modules/users/admin.service.ts new file mode 100644 index 0000000..29a6bc8 --- /dev/null +++ b/src/modules/users/admin.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import assert from 'assert'; +import { isNotEmpty, isNumber } from 'class-validator'; +import { DataSource, EntityManager } from 'typeorm'; + +import { ENetworkName, ETokenPairStatus } from '../../constants/blockchain.constant.js'; +import { EEnvKey } from '../../constants/env.constant.js'; +import { EError } from '../../constants/error.constant.js'; +import { toPageDto } from '../../core/paginate-typeorm.js'; +import { CommonConfigRepository } from '../../database/repositories/common-configuration.repository.js'; +import { TokenDeployer } from '../../modules/crawler/deploy-token.js'; +import { httpBadRequest } from '../../shared/exceptions/http-exeption.js'; +import { Erc20ContractTemplate } from '../../shared/modules/web3/web3.service.js'; +import { CommonConfig } from '../crawler/entities/common-config.entity.js'; +import { CreateTokenReqDto } from './dto/admin-request.dto.js'; +import { GetTokensReqDto } from './dto/user-request.dto.js'; + +@Injectable() +export class AdminService { + constructor( + private readonly dataSource: DataSource, + private readonly configService: ConfigService, + private readonly commonConfigRepo: CommonConfigRepository, + private readonly tokenDeployerService: TokenDeployer, + private readonly erc20ContractTemplate: Erc20ContractTemplate, + ) {} + async createNewToken(payload: CreateTokenReqDto) { + if (await this.checkTokenExist(payload.assetAddress)) { + return httpBadRequest(EError.DUPLICATED_ACTION); + } + const [symbol, decimals] = await Promise.all([ + this.erc20ContractTemplate.getTokenSymbol(payload.assetAddress), + this.erc20ContractTemplate.getTokenDecimals(payload.assetAddress).then(res => +res), + ]); + assert(isNotEmpty(symbol), 'symbol invalid'); + assert(isNumber(decimals) && decimals > 0, 'decimals invalid'); + const newTokenPair = await this.dataSource.transaction(async (e: EntityManager) => { + const commonConfigRepo = e.getRepository(CommonConfig); + // move create pair logic to helpers + const newCommonConfig = commonConfigRepo.create(); + newCommonConfig.asset = symbol; + newCommonConfig.fromAddress = payload.assetAddress; + newCommonConfig.dailyQuota = +payload.dailyQuota; + newCommonConfig.fromChain = ENetworkName.ETH; + newCommonConfig.toChain = ENetworkName.MINA; + newCommonConfig.fromDecimal = decimals; // get from network + newCommonConfig.toDecimal = 9; + newCommonConfig.fromScAddress = this.configService.get(EEnvKey.ETH_BRIDGE_CONTRACT_ADDRESS)!; + newCommonConfig.toScAddress = this.configService.get(EEnvKey.MINA_BRIDGE_CONTRACT_ADDRESS)!; + newCommonConfig.bridgeFee = payload.bridgeFee; + newCommonConfig.mintingFee = payload.mintingFee; + newCommonConfig.unlockingFee = payload.unlockingFee; + newCommonConfig.status = ETokenPairStatus.CREATED; + newCommonConfig.isHidden = true; + return newCommonConfig.save(); + }); + assert(isNumber(newTokenPair.id), 'Token pair invalid!'); + await this.tokenDeployerService.addJobDeployTokenMina(newTokenPair.id); + return newTokenPair; + } + async checkTokenExist(fromAddress: string) { + const isExist = await this.commonConfigRepo.findOneBy({ fromAddress }); + return !!isExist; + } + async getListToken(payload: GetTokensReqDto) { + const [tokens, count] = await this.commonConfigRepo.getManyAndPagination(payload); + return toPageDto(tokens, payload, count); + } + async redeployToken(tokenPairId: number) { + const tokenInfo = await this.commonConfigRepo.findOneBy({ id: tokenPairId }); + if (!tokenInfo) { + httpBadRequest(EError.RESOURCE_NOT_FOUND); + } + if (tokenInfo.status !== ETokenPairStatus.DEPLOY_FAILED) { + return httpBadRequest(EError.ACTION_CANNOT_PROCESSED); + } + await this.tokenDeployerService.addJobDeployTokenMina(tokenInfo.id); + tokenInfo.status = ETokenPairStatus.DEPLOYING; + return tokenInfo.save(); + } +} diff --git a/src/modules/users/dto/admin-request.dto.ts b/src/modules/users/dto/admin-request.dto.ts new file mode 100644 index 0000000..c0d6453 --- /dev/null +++ b/src/modules/users/dto/admin-request.dto.ts @@ -0,0 +1,42 @@ +import { NumberField, StringField } from '../../../shared/decorators/field.decorator.js'; + +export class CreateTokenReqDto { + @StringField({ + isEthereumAddress: true, // may support other chains than ETH in future. + }) + assetAddress: string; + + @StringField({ + minimum: 0, + number: true, + }) + minAmountToBridge: string; + + @StringField({ + minimum: 0, + number: true, + }) + maxAmountToBridge: string; + + @NumberField({ + minimum: 0, + }) + dailyQuota: number; + + @NumberField({ + minimum: 0, + }) + bridgeFee: number; + + @StringField({ + minimum: 0, + number: true, + }) + unlockingFee: string; + + @StringField({ + minimum: 0, + number: true, + }) + mintingFee: string; +} diff --git a/src/modules/users/dto/common-config-request.dto.ts b/src/modules/users/dto/common-config-request.dto.ts index 3580427..cf928fa 100644 --- a/src/modules/users/dto/common-config-request.dto.ts +++ b/src/modules/users/dto/common-config-request.dto.ts @@ -1,12 +1,12 @@ import { EEnvKey } from '../../../constants/env.constant.js'; -import { NumberField, StringField } from '../../../shared/decorators/field.decorator.js'; +import { BooleanField, NumberField, StringField } from '../../../shared/decorators/field.decorator.js'; export class UpdateCommonConfigBodyDto { @NumberField({ example: 50, required: false, }) - tip: number; + bridgeFee: number; @NumberField({ example: 500, @@ -21,7 +21,7 @@ export class UpdateCommonConfigBodyDto { }, required: false, }) - feeUnlockMina: string; + mintingFee: string; @StringField({ example: 500, @@ -30,5 +30,9 @@ export class UpdateCommonConfigBodyDto { }, required: false, }) - feeUnlockEth: string; + unlockingFee: string; +} +export class UpdateTokenPairVisibilityReqDto { + @BooleanField({ required: true }) + isHidden: boolean; } diff --git a/src/modules/users/dto/user-request.dto.ts b/src/modules/users/dto/user-request.dto.ts index db4050c..96afc95 100644 --- a/src/modules/users/dto/user-request.dto.ts +++ b/src/modules/users/dto/user-request.dto.ts @@ -1,4 +1,6 @@ +import { ETokenPairStatus } from '../../../constants/blockchain.constant.js'; import { NumberField, StringField } from '../../../shared/decorators/field.decorator.js'; +import { BasePaginationRequestDto } from '../../../shared/dtos/base-request.dto.js'; export class CreateUserDto { @StringField({ @@ -27,3 +29,13 @@ export class GetProtocolFeeBodyDto { }) pairId: number; } +export class GetTokensReqDto extends BasePaginationRequestDto { + @StringField({ isArray: true, example: ETokenPairStatus.CREATED, required: false }) + statuses: ETokenPairStatus[]; + + @StringField({ required: false }) + assetName: string; + + @StringField({ required: false }) + tokenAddress: string; +} diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 3d454fc..7b41bf7 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -5,7 +5,7 @@ import { ETableName } from '../../constants/entity.constant.js'; import { GuardPublic } from '../../guards/guard.decorator.js'; import { EstimateBridgeRequestDto } from './dto/estimate-bridge-request.dto.js'; import { GetHistoryOfUserDto, GetHistoryOfUserResponseDto } from './dto/history-response.dto.js'; -import { GetProtocolFeeBodyDto } from './dto/user-request.dto.js'; +import { GetProtocolFeeBodyDto, GetTokensReqDto } from './dto/user-request.dto.js'; import { EstimateBridgeResponseDto, GetListTokenPairResponseDto, @@ -27,17 +27,17 @@ export class UsersController { return this.userService.getHistoriesOfUser(address, query); } - @Get('daily-quota/:address') + @Get('daily-quota/:address/:token') @GuardPublic() - getDailyQuota(@Param('address') address: string) { - return this.userService.getDailyQuotaOfUser(address); + getDailyQuota(@Param('address') address: string, @Param('token') token: string) { + return this.userService.getDailyQuotaOfUser(address, token); } @Get('list-supported-pairs') @GuardPublic() @ApiOkResponse({ type: [GetListTokenPairResponseDto] }) - getListTokenPair() { - return this.userService.getListTokenPair(); + getListTokenPair(@Query() query: GetTokensReqDto) { + return this.userService.getListTokenPair(query); } @Post('bridge/protocol-fee') diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index 45b521b..cf89d60 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -6,7 +6,9 @@ import { EventLogRepository } from '../../database/repositories/event-log.reposi import { TokenPairRepository } from '../../database/repositories/token-pair.repository.js'; import { TokenPriceRepository } from '../../database/repositories/token-price.repository.js'; import { UserRepository } from '../../database/repositories/user.repository.js'; +import { TokenDeployer } from '../../modules/crawler/deploy-token.js'; import { AdminController } from './admin.controller.js'; +import { AdminService } from './admin.service.js'; import { UsersController } from './users.controller.js'; import { UsersService } from './users.service.js'; @@ -21,7 +23,7 @@ import { UsersService } from './users.service.js'; ]), ], controllers: [UsersController, AdminController], - providers: [UsersService], + providers: [UsersService, AdminService, TokenDeployer], exports: [UsersService], }) export class UsersModule {} diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index f1d4fb0..281ac34 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -4,22 +4,20 @@ import assert from 'assert'; import { BigNumber } from 'bignumber.js'; import { EAsset } from '../../constants/api.constant.js'; -import { DECIMAL_BASE, ENetworkName } from '../../constants/blockchain.constant.js'; +import { DECIMAL_BASE, ENetworkName, ETokenPairStatus } from '../../constants/blockchain.constant.js'; import { EEnvKey } from '../../constants/env.constant.js'; import { EError } from '../../constants/error.constant.js'; import { toPageDto } from '../../core/paginate-typeorm.js'; import { CommonConfigRepository } from '../../database/repositories/common-configuration.repository.js'; import { EventLogRepository } from '../../database/repositories/event-log.repository.js'; -import { TokenPairRepository } from '../../database/repositories/token-pair.repository.js'; import { TokenPriceRepository } from '../../database/repositories/token-price.repository.js'; import { UserRepository } from '../../database/repositories/user.repository.js'; -import { httpBadRequest, httpNotFound } from '../../shared/exceptions/http-exeption.js'; +import { httpBadRequest } from '../../shared/exceptions/http-exeption.js'; import { LoggerService } from '../../shared/modules/logger/logger.service.js'; import { RedisClientService } from '../../shared/modules/redis/redis-client.service.js'; -import { addDecimal } from '../../shared/utils/bignumber.js'; -import { UpdateCommonConfigBodyDto } from './dto/common-config-request.dto.js'; +import { UpdateCommonConfigBodyDto, UpdateTokenPairVisibilityReqDto } from './dto/common-config-request.dto.js'; import { GetHistoryDto, GetHistoryOfUserDto } from './dto/history-response.dto.js'; -import { GetProtocolFeeBodyDto } from './dto/user-request.dto.js'; +import { GetProtocolFeeBodyDto, GetTokensReqDto } from './dto/user-request.dto.js'; import { GetProofOfAssetsResponseDto, GetTokensPriceResponseDto } from './dto/user-response.dto.js'; @Injectable() @@ -31,7 +29,6 @@ export class UsersService { private readonly configService: ConfigService, private readonly loggerService: LoggerService, private readonly tokenPriceRepository: TokenPriceRepository, - private readonly tokenPairRepostitory: TokenPairRepository, private readonly redisClientService: RedisClientService, ) {} private readonly logger = this.loggerService.getLogger('USER_SERVICE'); @@ -55,49 +52,71 @@ export class UsersService { return toPageDto(data, options, count); } - async getCommonConfig() { - return this.commonConfigRepository.getCommonConfig(); - } - - async updateCommonConfig(id: number, updateConfig: UpdateCommonConfigBodyDto) { + async updateTokenConfig(id: number, updateConfig: UpdateCommonConfigBodyDto) { await this.commonConfigRepository.updateCommonConfig(id, updateConfig); return updateConfig; } + async updateTokenVisibility(id: number, updateConfig: UpdateTokenPairVisibilityReqDto) { + await this.commonConfigRepository.update(id, { + isHidden: updateConfig.isHidden, + }); + return updateConfig; + } - async getDailyQuotaOfUser(address: string) { + async getDailyQuotaOfUser(senderAddress: string, tokenReceivedAddress: string) { const [dailyQuota, totalamount] = await Promise.all([ - this.commonConfigRepository.getCommonConfig(), - this.eventLogRepository.sumAmountBridgeOfUserInDay(address), + this.commonConfigRepository.findOne({ + where: [ + { + fromAddress: tokenReceivedAddress, + }, + { toAddress: tokenReceivedAddress }, + ], + select: { + fromAddress: true, + id: true, + dailyQuota: true, + }, + }), + this.eventLogRepository.sumAmountBridgeOfUserInDay(senderAddress, tokenReceivedAddress), ]); return { dailyQuota, totalAmountOfToDay: totalamount?.totalamount || 0 }; } - async getListTokenPair() { - return this.tokenPairRepostitory.find(); + async getListTokenPair(payload: GetTokensReqDto) { + const [data] = await this.commonConfigRepository.getManyAndPagination( + { + ...payload, + statuses: [ETokenPairStatus.ENABLE], + }, + 'user', + ); + return data; } - async getProtocolFee({ pairId }: GetProtocolFeeBodyDto) { - let gasFee, decimal; - const [tokenPair, config] = await Promise.all([ - this.tokenPairRepostitory.findOneBy({ - id: pairId, - }), - this.commonConfigRepository.getCommonConfig(), - ]); - if (!tokenPair) { - httpNotFound(EError.RESOURCE_NOT_FOUND); - } - assert(config, 'system common config not found!'); - if (tokenPair!.toChain == ENetworkName.MINA) { - decimal = this.configService.get(EEnvKey.DECIMAL_TOKEN_MINA); - gasFee = addDecimal(config.feeUnlockMina, decimal); - } else { - decimal = this.configService.get(EEnvKey.DECIMAL_TOKEN_EVM); - gasFee = addDecimal(config.feeUnlockEth, decimal); - } - - return { gasFee, tipRate: config.tip, decimal }; + async getProtocolFee(dto: GetProtocolFeeBodyDto) { + // let gasFee, decimal; + // const [tokenPair, config] = await Promise.all([ + // this.commonConfigRepository.findOneBy({ + // id: pairId, + // }), + // this.commonConfigRepository.getCommonConfig(), + // ]); + // if (!tokenPair) { + // httpNotFound(EError.RESOURCE_NOT_FOUND); + // } + // assert(config, 'system common config not found!'); + // if (tokenPair!.toChain == ENetworkName.MINA) { + // decimal = this.configService.get(EEnvKey.DECIMAL_TOKEN_MINA); + // gasFee = addDecimal(config.mintingFee, decimal); + // } else { + // decimal = this.configService.get(EEnvKey.DECIMAL_TOKEN_EVM); + // gasFee = addDecimal(config.unlockingFee, decimal); + // } + + // return { gasFee, tipRate: config.tip, decimal }; + return 'use fees returned from pair detail'; } async getTokensPrices(): Promise { const result = { @@ -127,15 +146,15 @@ export class UsersService { assert(config, 'invalid config, please seed the value'); const totalWethInCirculation = new BigNumber(config.totalWethMinted) .minus(config.totalWethBurnt) - .div(BigNumber(DECIMAL_BASE).pow(+this.configService.get(EEnvKey.DECIMAL_TOKEN_MINA))) + .div(BigNumber(config.fromDecimal).pow(config.toDecimal)) .toString(); return { totalWethInCirculation, }; } - calcWaitingTime(receivedNetwork: ENetworkName, currentPendingTx: number): number { + private calcWaitingTime(receivedNetwork: ENetworkName, currentPendingTx: number): number { const receivedNetworkEstWaiting = { - [ENetworkName.MINA]: 10 * 60 * (1 + currentPendingTx), + [ENetworkName.MINA]: 10 * 60 * (1 + +currentPendingTx), [ENetworkName.ETH]: 10 * (1 + currentPendingTx), }; // total waiting tx * time_process_each + crawler delays from both lock and unlock diff --git a/src/shared/decorators/field.decorator.ts b/src/shared/decorators/field.decorator.ts index 98a4271..6708b6b 100644 --- a/src/shared/decorators/field.decorator.ts +++ b/src/shared/decorators/field.decorator.ts @@ -1,12 +1,13 @@ import { applyDecorators } from '@nestjs/common'; import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { IsArray, IsBoolean, IsDecimal, IsEmail, IsEnum, + IsEthereumAddress, IsInt, IsNotEmpty, isNumber, @@ -95,7 +96,16 @@ export function NumberField(options: Omit & INumberF export function StringField(options: Omit & IStringFieldOptions = {}): PropertyDecorator { const decorators = [Trim(), ...initSharedDecorator(options, String)]; - const { minLength, maxLength, toLowerCase, toUpperCase, number, isEmail, isArray = false } = options; + const { + minLength, + maxLength, + toLowerCase, + toUpperCase, + number, + isEmail, + isEthereumAddress, + isArray = false, + } = options; if (minLength) { decorators.push(MinLength(minLength)); @@ -108,10 +118,19 @@ export function StringField(options: Omit & IStringF if (toLowerCase) { decorators.push(ToLowerCase()); } - + if (isEthereumAddress) { + decorators.push(IsEthereumAddress()); + } if (toUpperCase) { decorators.push(ToUpperCase()); } + if (isArray) { + decorators.push( + Transform(({ value }) => { + if (typeof value === 'string') return [value]; + }), + ); + } // strings, number string validation if (typeof number == 'object') { decorators.push( diff --git a/src/shared/decorators/transform.decorator.ts b/src/shared/decorators/transform.decorator.ts index 0c5c7b8..c0de848 100644 --- a/src/shared/decorators/transform.decorator.ts +++ b/src/shared/decorators/transform.decorator.ts @@ -1,5 +1,6 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { Transform } from 'class-transformer'; +import { isNotEmpty } from 'class-validator'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import pkg from 'lodash'; @@ -18,12 +19,12 @@ export function Trim(): PropertyDecorator { } function toBoolean(value: string | number | boolean): boolean { if (typeof value === 'string') return value.toLowerCase() === 'true'; - return Boolean(value).valueOf(); + return !!value; } export function ToBoolean(): PropertyDecorator { return Transform( ({ value }) => { - if (value) return toBoolean(value); + if (isNotEmpty(value)) return toBoolean(value); }, { toClassOnly: true }, ); diff --git a/src/shared/exceptions/http-exeption.ts b/src/shared/exceptions/http-exeption.ts index eed3ba3..dcc7d30 100644 --- a/src/shared/exceptions/http-exeption.ts +++ b/src/shared/exceptions/http-exeption.ts @@ -9,7 +9,7 @@ import { import { EError } from '../../constants/error.constant.js'; // 400 -export function httpBadRequest(errorCode: EError, metaData?: object) { +export function httpBadRequest(errorCode: EError, metaData?: object): never { throw new BadRequestException({ statusCode: 400, errorCode, @@ -18,7 +18,7 @@ export function httpBadRequest(errorCode: EError, metaData?: object) { } // 401 -export function httpUnAuthorized(errorCode?: EError, metaData?: object) { +export function httpUnAuthorized(errorCode?: EError, metaData?: object): never { throw new UnauthorizedException({ statusCode: 401, errorCode: errorCode || EError.UNAUTHORIZED, @@ -27,7 +27,7 @@ export function httpUnAuthorized(errorCode?: EError, metaData?: object) { } // 403 -export function httpForbidden(errorCode?: EError, metaData?: object) { +export function httpForbidden(errorCode?: EError, metaData?: object): never { throw new ForbiddenException({ statusCode: 403, errorCode: errorCode || EError.FORBIDDEN_RESOURCE, @@ -36,7 +36,7 @@ export function httpForbidden(errorCode?: EError, metaData?: object) { } // 404 -export function httpNotFound(errorCode: EError, metaData?: object) { +export function httpNotFound(errorCode: EError, metaData?: object): never { throw new NotFoundException({ statusCode: 404, errorCode, diff --git a/src/shared/modules/web3/abis/erc-20.js b/src/shared/modules/web3/abis/erc-20.js new file mode 100644 index 0000000..031b30d --- /dev/null +++ b/src/shared/modules/web3/abis/erc-20.js @@ -0,0 +1,189 @@ +export const Erc20ABI = `[ + { + "inputs": [ + { "internalType": "string", "name": "name_", "type": "string" }, + { "internalType": "string", "name": "symbol_", "type": "string" }, + { "internalType": "uint8", "name": "decimals_", "type": "uint8" } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { "internalType": "address", "name": "spender", "type": "address" }, + { "internalType": "uint256", "name": "allowance", "type": "uint256" }, + { "internalType": "uint256", "name": "needed", "type": "uint256" } + ], + "name": "ERC20InsufficientAllowance", + "type": "error" + }, + { + "inputs": [ + { "internalType": "address", "name": "sender", "type": "address" }, + { "internalType": "uint256", "name": "balance", "type": "uint256" }, + { "internalType": "uint256", "name": "needed", "type": "uint256" } + ], + "name": "ERC20InsufficientBalance", + "type": "error" + }, + { + "inputs": [{ "internalType": "address", "name": "approver", "type": "address" }], + "name": "ERC20InvalidApprover", + "type": "error" + }, + { + "inputs": [{ "internalType": "address", "name": "receiver", "type": "address" }], + "name": "ERC20InvalidReceiver", + "type": "error" + }, + { + "inputs": [{ "internalType": "address", "name": "sender", "type": "address" }], + "name": "ERC20InvalidSender", + "type": "error" + }, + { + "inputs": [{ "internalType": "address", "name": "spender", "type": "address" }], + "name": "ERC20InvalidSpender", + "type": "error" + }, + { + "inputs": [{ "internalType": "address", "name": "owner", "type": "address" }], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [{ "internalType": "address", "name": "account", "type": "address" }], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "owner", "type": "address" }, + { "indexed": true, "internalType": "address", "name": "spender", "type": "address" }, + { "indexed": false, "internalType": "uint256", "name": "value", "type": "uint256" } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "previousOwner", "type": "address" }, + { "indexed": true, "internalType": "address", "name": "newOwner", "type": "address" } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "from", "type": "address" }, + { "indexed": true, "internalType": "address", "name": "to", "type": "address" }, + { "indexed": false, "internalType": "uint256", "name": "value", "type": "uint256" } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { "internalType": "address", "name": "owner", "type": "address" }, + { "internalType": "address", "name": "spender", "type": "address" } + ], + "name": "allowance", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "spender", "type": "address" }, + { "internalType": "uint256", "name": "value", "type": "uint256" } + ], + "name": "approve", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "account", "type": "address" }], + "name": "balanceOf", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [{ "internalType": "uint8", "name": "", "type": "uint8" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "to", "type": "address" }, + { "internalType": "uint256", "name": "amount", "type": "uint256" } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { "inputs": [], "name": "renounceOwnership", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "symbol", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "to", "type": "address" }, + { "internalType": "uint256", "name": "value", "type": "uint256" } + ], + "name": "transfer", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "from", "type": "address" }, + { "internalType": "address", "name": "to", "type": "address" }, + { "internalType": "uint256", "name": "value", "type": "uint256" } + ], + "name": "transferFrom", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "newOwner", "type": "address" }], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +]`; diff --git a/src/shared/modules/web3/web3.module.ts b/src/shared/modules/web3/web3.module.ts index 0688487..22dcc72 100644 --- a/src/shared/modules/web3/web3.module.ts +++ b/src/shared/modules/web3/web3.module.ts @@ -4,11 +4,11 @@ import { ConfigService } from '@nestjs/config'; // @ts-ignore import Web3 from 'web3/lib/index.js'; -import { initializeEthContract } from '../../../config/common.config.js'; +import { initializeEthContract, IRpcInit } from '../../../config/common.config.js'; import { EEnvKey } from '../../../constants/env.constant.js'; import { ASYNC_CONNECTION } from '../../../constants/service.constant.js'; import { sleep } from '../../utils/promise.js'; -import { ETHBridgeContract } from './web3.service.js'; +import { Erc20ContractTemplate, ETHBridgeContract } from './web3.service.js'; @Global() @Module({ @@ -24,13 +24,20 @@ import { ETHBridgeContract } from './web3.service.js'; }, { provide: ETHBridgeContract, - useFactory: (connection: ETHBridgeContract) => { - return connection; + useFactory: ({ bridgeContract }: IRpcInit) => { + return bridgeContract; + }, + inject: [ASYNC_CONNECTION], + }, + { + provide: Erc20ContractTemplate, + useFactory: ({ erc20Template }: IRpcInit) => { + return erc20Template; }, inject: [ASYNC_CONNECTION], }, ], - exports: [Web3Module, ETHBridgeContract], + exports: [Web3Module, ETHBridgeContract, Erc20ContractTemplate], }) export class Web3Module {} diff --git a/src/shared/modules/web3/web3.service.ts b/src/shared/modules/web3/web3.service.ts index b51d127..342938d 100644 --- a/src/shared/modules/web3/web3.service.ts +++ b/src/shared/modules/web3/web3.service.ts @@ -4,19 +4,21 @@ import { TransactionReceipt } from 'web3-core'; import { Contract, EventData } from 'web3-eth-contract'; import pkg from 'web3-utils'; +import { EEnvKey } from '../../../constants/env.constant.js'; import { sleep } from '../../utils/promise.js'; +import { Erc20ABI } from './abis/erc-20.js'; import { EthBridgeAbi } from './abis/eth-bridge-contract.js'; import { IRpcService } from './web3.module.js'; const { toBN, toHex } = pkg; export class DefaultContract { private readonly logger = new Logger('CONTRACT'); - private contract: Contract; + protected contract: Contract; private readonly contractAddress: string; private readonly abi: any; private readonly startBlock: number; constructor( - private rpcService: IRpcService, + protected rpcService: IRpcService, _abi: any, _contractAddress: any, _startBlock: number, @@ -92,8 +94,10 @@ export class DefaultContract { return gasLimit; } - public async write(method: string, param: Array): Promise { - const signer = this.rpcService.web3.eth.accounts.privateKeyToAccount(this.rpcService.privateKeys); + public async write(method: string, param: Array, customSignerPrivateKey?: string): Promise { + const signer = this.rpcService.web3.eth.accounts.privateKeyToAccount( + customSignerPrivateKey ?? this.rpcService.privateKeys, + ); const data = this.contract.methods[method](...param).encodeABI(); const gasPrice = await this.rpcService.web3.eth.getGasPrice(); @@ -117,47 +121,6 @@ export class DefaultContract { return this.rpcService.web3.eth.sendSignedTransaction(signedTx.rawTransaction); } - public async multiWrite( - writeData: any[], - specifySignerIndex?: number, - ): Promise<{ success: boolean; error: Error | null; data: TransactionReceipt[] | null }> { - try { - const signer = this.rpcService.web3.eth.accounts.privateKeyToAccount( - this.rpcService.privateKeys[specifySignerIndex ?? 0], - ); - - const response = []; - for (const element of writeData) { - // gas estimation - const nonce = await this.rpcService.getNonce(signer.address); - const { method, param } = element; - const data = this.contract.methods[method](...param).encodeABI(); - const gasPrice = await this.rpcService.web3.eth.getGasPrice(); - const rawTx = { - nonce: nonce, - gasPrice: toHex(toBN(gasPrice)), - from: signer.address, - to: this.contractAddress, - data: data, - }; - const gasLimit = await this.rpcService.web3.eth.estimateGas(rawTx as any); - - const signedTx = await signer.signTransaction({ - ...rawTx, - gasLimit: toHex(toBN(gasLimit).add(toBN(10000))), - } as any); - response.push(await this.rpcService.web3.eth.sendSignedTransaction(signedTx.rawTransaction)); - } - - return { - success: true, - error: null, - data: response, - }; - } catch (error: any) { - return { success: false, error, data: null }; - } - } public async getBlockTimeByBlockNumber(blockNumber: number) { return this.rpcService.web3.eth.getBlock(blockNumber); @@ -177,6 +140,17 @@ export class DefaultContract { this.logger.error('Error getting gas price:', error); } } + public async pollingTxStatus(txHash: string) { + const maxTries = 20; + for (let i = 0; i < maxTries; i++) { + const hash = await this.rpcService.web3.eth.getTransactionReceipt(txHash); + if (hash) { + return; + } + await sleep(5); + } + throw new Error(`polling for tx ${txHash} exceed max tries.`); + } } export class ETHBridgeContract extends DefaultContract { @@ -193,6 +167,18 @@ export class ETHBridgeContract extends DefaultContract { public getValidatorThreshold() { return this.call('threshold', []); } + public async whitelistToken( + tokenAddress: string, + ): Promise<{ isWhitelisted: boolean; tokenAddress: string; txHash?: string }> { + const isWhitelisted = await this.call('whitelistTokens', [tokenAddress]); + if (isWhitelisted) { + return { isWhitelisted: true, tokenAddress }; + } + const res = await this.write('setWhitelistToken', [tokenAddress, true], process.env[EEnvKey.ADMIN_EVM_PRIVATE_KEY]); + // pooling tx hash + await this.pollingTxStatus(res.transactionHash); + return { isWhitelisted: true, tokenAddress, txHash: res.transactionHash }; + } public mintNFT(toAddress: string) { return this.write('mint', [toAddress]); } @@ -219,3 +205,16 @@ export class ETHBridgeContract extends DefaultContract { return this.call('tokenURI', [tokenId]); } } + +export class Erc20ContractTemplate { + constructor(private readonly rpcETHService: IRpcService) {} + private getErc20Contract(address: string) { + return new DefaultContract(this.rpcETHService, Erc20ABI, address, 0); + } + public getTokenSymbol(address: string) { + return this.getErc20Contract(address).call('symbol', []); + } + public getTokenDecimals(address: string) { + return this.getErc20Contract(address).call('decimals', []); + } +}