feat(pool-account): 实现池账户区块链充值检测和提现功能
为100亿销毁池和200万挖矿池实现完整的区块链充值/提现流程: - 充值:独立扫描器检测 fUSDT 转入 → Kafka 事件 → wallet-service 入账 - 提现:前端 → admin-service 代理 → blockchain-service MPC签名转账 → wallet-service 记账 mining-blockchain-service: - 新增 PoolAccountDeposit/PoolAccountBlockCheckpoint Prisma 模型 - 新增 PoolAccountDepositDetectionService(每5秒扫描 fUSDT 充值,每30秒更新确认数) - 扩展 MPC 签名客户端支持 burnPool/miningPool 2-of-3 门限钱包 - 扩展 ERC20TransferService 支持池账户 fUSDT 转账 - 新增 API: POST /transfer/pool-account, GET /pool-accounts/:poolType/wallet-info - 新增 Kafka topic 映射: pool_account.deposit.confirmed → pool_account.deposits mining-wallet-service: - TransactionType 枚举新增 BLOCKCHAIN_DEPOSIT/BLOCKCHAIN_WITHDRAW - Seed: SHARE_POOL_A/B 初始余额改为0(完全靠链上充值) - PoolAccountService 新增 blockchainDeposit()/blockchainWithdraw() 方法 - 新增 PoolAccountDepositConsumer 监听 Kafka 充值确认事件(Redis+DB 双重幂等) - 新增 POST /pool-accounts/blockchain-withdraw 内部 API mining-admin-service: - 新增 PoolAccountController 代理到 wallet-service + blockchain-service - GET /admin/pool-accounts/:walletName/balance(并行查询链下余额+链上钱包信息) - POST /admin/pool-accounts/:walletName/blockchain-withdraw(先链上转账再记账) - 新增配置: MINING_WALLET_SERVICE_URL, MINING_BLOCKCHAIN_SERVICE_URL, 池钱包用户名 frontend (mining-admin-web): - 池账户 API 从 tradingClient (→trading-service) 改为 apiClient (→admin-service) - 移除未使用的 tradingClient 和 axios 依赖 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4d2bcc7568
commit
4f5b18be48
|
|
@ -20,6 +20,12 @@ JWT_EXPIRES_IN=24h
|
|||
CONTRIBUTION_SERVICE_URL=http://localhost:3020
|
||||
MINING_SERVICE_URL=http://localhost:3021
|
||||
TRADING_SERVICE_URL=http://localhost:3022
|
||||
MINING_WALLET_SERVICE_URL=http://localhost:3025
|
||||
MINING_BLOCKCHAIN_SERVICE_URL=http://localhost:3020
|
||||
|
||||
# Pool Account Wallet Names (MPC 用户名,用于 walletName → poolType 映射)
|
||||
BURN_POOL_WALLET_USERNAME=wallet-22fd661f
|
||||
MINING_POOL_WALLET_USERNAME=wallet-974e78f5
|
||||
|
||||
# Default Admin
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { BatchMiningController } from './controllers/batch-mining.controller';
|
|||
import { VersionController } from './controllers/version.controller';
|
||||
import { UpgradeVersionController } from './controllers/upgrade-version.controller';
|
||||
import { MobileVersionController } from './controllers/mobile-version.controller';
|
||||
import { PoolAccountController } from './controllers/pool-account.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -40,6 +41,7 @@ import { MobileVersionController } from './controllers/mobile-version.controller
|
|||
VersionController,
|
||||
UpgradeVersionController,
|
||||
MobileVersionController,
|
||||
PoolAccountController,
|
||||
],
|
||||
})
|
||||
export class ApiModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
import { Controller, Get, Post, Body, Param, Logger, BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// walletName → poolType 映射
|
||||
interface PoolMapping {
|
||||
blockchainPoolType: string; // blockchain-service 使用的类型 (BURN_POOL / MINING_POOL)
|
||||
walletPoolType: string; // wallet-service 使用的类型 (SHARE_POOL_A / SHARE_POOL_B)
|
||||
name: string;
|
||||
}
|
||||
|
||||
@ApiTags('Pool Accounts')
|
||||
@ApiBearerAuth()
|
||||
@Controller('admin/pool-accounts')
|
||||
export class PoolAccountController {
|
||||
private readonly logger = new Logger(PoolAccountController.name);
|
||||
private readonly walletServiceUrl: string;
|
||||
private readonly blockchainServiceUrl: string;
|
||||
private readonly walletNameMap: Record<string, PoolMapping>;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.walletServiceUrl = this.configService.get<string>(
|
||||
'MINING_WALLET_SERVICE_URL',
|
||||
'http://localhost:3025',
|
||||
);
|
||||
this.blockchainServiceUrl = this.configService.get<string>(
|
||||
'MINING_BLOCKCHAIN_SERVICE_URL',
|
||||
'http://localhost:3020',
|
||||
);
|
||||
|
||||
// 从环境变量构建 walletName → poolType 映射
|
||||
const burnPoolUsername = this.configService.get<string>('BURN_POOL_WALLET_USERNAME', '');
|
||||
const miningPoolUsername = this.configService.get<string>('MINING_POOL_WALLET_USERNAME', '');
|
||||
|
||||
this.walletNameMap = {};
|
||||
if (burnPoolUsername) {
|
||||
this.walletNameMap[burnPoolUsername] = {
|
||||
blockchainPoolType: 'BURN_POOL',
|
||||
walletPoolType: 'SHARE_POOL_A',
|
||||
name: '100亿销毁池',
|
||||
};
|
||||
}
|
||||
if (miningPoolUsername) {
|
||||
this.walletNameMap[miningPoolUsername] = {
|
||||
blockchainPoolType: 'MINING_POOL',
|
||||
walletPoolType: 'SHARE_POOL_B',
|
||||
name: '200万挖矿池',
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Pool account proxy initialized: walletService=${this.walletServiceUrl}, blockchainService=${this.blockchainServiceUrl}, pools=${Object.keys(this.walletNameMap).join(',')}`,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':walletName/balance')
|
||||
@ApiOperation({ summary: '获取池账户余额(代理到 wallet-service + blockchain-service)' })
|
||||
@ApiParam({ name: 'walletName', description: '池钱包名称(MPC用户名)' })
|
||||
async getPoolAccountBalance(@Param('walletName') walletName: string) {
|
||||
const poolInfo = this.walletNameMap[walletName];
|
||||
if (!poolInfo) {
|
||||
throw new BadRequestException(`Unknown wallet name: ${walletName}`);
|
||||
}
|
||||
|
||||
// 并行调用 wallet-service 和 blockchain-service
|
||||
const [walletResponse, blockchainResponse] = await Promise.all([
|
||||
fetch(`${this.walletServiceUrl}/api/v2/pool-accounts/${poolInfo.walletPoolType}`).catch((err) => {
|
||||
this.logger.error(`Failed to fetch wallet balance: ${err.message}`);
|
||||
return null;
|
||||
}),
|
||||
fetch(`${this.blockchainServiceUrl}/api/v1/transfer/pool-accounts/${poolInfo.blockchainPoolType}/wallet-info`).catch((err) => {
|
||||
this.logger.error(`Failed to fetch blockchain wallet info: ${err.message}`);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
|
||||
// 从 wallet-service 获取链下余额
|
||||
let balance = '0';
|
||||
let lastUpdated: string | undefined;
|
||||
if (walletResponse && walletResponse.ok) {
|
||||
const walletResult = await walletResponse.json();
|
||||
const data = walletResult.data || walletResult;
|
||||
balance = data.balance?.toString() || '0';
|
||||
lastUpdated = data.updatedAt;
|
||||
}
|
||||
|
||||
// 从 blockchain-service 获取钱包地址和链上余额
|
||||
let walletAddress = '';
|
||||
let onChainBalance = '0';
|
||||
if (blockchainResponse && blockchainResponse.ok) {
|
||||
const blockchainResult = await blockchainResponse.json();
|
||||
const data = blockchainResult.data || blockchainResult;
|
||||
walletAddress = data.address || '';
|
||||
onChainBalance = data.balance || '0';
|
||||
}
|
||||
|
||||
return {
|
||||
walletName,
|
||||
walletAddress,
|
||||
balance,
|
||||
availableBalance: balance,
|
||||
frozenBalance: '0',
|
||||
onChainBalance,
|
||||
threshold: '0',
|
||||
lastUpdated,
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':walletName/blockchain-withdraw')
|
||||
@ApiOperation({ summary: '池账户区块链提现(代理到 blockchain-service + wallet-service)' })
|
||||
@ApiParam({ name: 'walletName', description: '池钱包名称(MPC用户名)' })
|
||||
async poolAccountBlockchainWithdraw(
|
||||
@Param('walletName') walletName: string,
|
||||
@Body() body: { toAddress: string; amount: string },
|
||||
) {
|
||||
const poolInfo = this.walletNameMap[walletName];
|
||||
if (!poolInfo) {
|
||||
throw new BadRequestException(`Unknown wallet name: ${walletName}`);
|
||||
}
|
||||
|
||||
if (!body.toAddress || !body.amount) {
|
||||
throw new BadRequestException('toAddress and amount are required');
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[withdraw] ${poolInfo.name}: ${body.amount} fUSDT to ${body.toAddress}`,
|
||||
);
|
||||
|
||||
// Step 1: 通过 blockchain-service 执行区块链转账
|
||||
let blockchainData: any;
|
||||
try {
|
||||
const blockchainResponse = await fetch(
|
||||
`${this.blockchainServiceUrl}/api/v1/transfer/pool-account`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
poolType: poolInfo.blockchainPoolType,
|
||||
toAddress: body.toAddress,
|
||||
amount: body.amount,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const blockchainResult = await blockchainResponse.json();
|
||||
blockchainData = blockchainResult.data || blockchainResult;
|
||||
|
||||
if (!blockchainResponse.ok || !blockchainData.success) {
|
||||
this.logger.error(`[withdraw] Blockchain transfer failed: ${blockchainData.error}`);
|
||||
throw new HttpException(
|
||||
blockchainData.error || '区块链转账失败',
|
||||
blockchainResponse.status || HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`[withdraw] Failed to call blockchain service: ${error}`);
|
||||
throw new HttpException(
|
||||
`区块链服务调用失败: ${error instanceof Error ? error.message : error}`,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: 在 wallet-service 记录提现(扣减余额 + 分类账)
|
||||
try {
|
||||
const walletResponse = await fetch(
|
||||
`${this.walletServiceUrl}/api/v2/pool-accounts/blockchain-withdraw`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
poolType: poolInfo.walletPoolType,
|
||||
amount: body.amount,
|
||||
txHash: blockchainData.txHash,
|
||||
toAddress: body.toAddress,
|
||||
blockNumber: blockchainData.blockNumber?.toString(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!walletResponse.ok) {
|
||||
const errResult = await walletResponse.json().catch(() => ({}));
|
||||
this.logger.error(
|
||||
`[withdraw] Failed to record withdrawal in wallet-service: ${JSON.stringify(errResult)}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// 区块链转账已成功,wallet-service 记录失败不影响最终结果
|
||||
this.logger.error(`[withdraw] Failed to record withdrawal in wallet-service: ${error}`);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[withdraw] Success: ${poolInfo.name}, txHash=${blockchainData.txHash}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '提现成功',
|
||||
txHash: blockchainData.txHash,
|
||||
blockNumber: blockchainData.blockNumber,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -327,3 +327,67 @@ model MarketMakerBlockCheckpoint {
|
|||
@@unique([chainType, assetType], name: "uk_mm_chain_asset")
|
||||
@@map("market_maker_block_checkpoints")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 池账户充值交易表
|
||||
// 记录检测到的100亿销毁池和200万挖矿池的 fUSDT 充值
|
||||
// ============================================
|
||||
model PoolAccountDeposit {
|
||||
id BigInt @id @default(autoincrement()) @map("deposit_id")
|
||||
|
||||
chainType String @map("chain_type") @db.VarChar(20) // KAVA
|
||||
txHash String @map("tx_hash") @db.VarChar(66)
|
||||
|
||||
fromAddress String @map("from_address") @db.VarChar(42)
|
||||
toAddress String @map("to_address") @db.VarChar(42) // 池钱包地址
|
||||
|
||||
// 池类型: BURN_POOL (100亿销毁池) 或 MINING_POOL (200万挖矿池)
|
||||
poolType String @map("pool_type") @db.VarChar(20)
|
||||
tokenContract String @map("token_contract") @db.VarChar(42)
|
||||
amount Decimal @db.Decimal(78, 0) // 原始金额 (wei单位)
|
||||
amountFormatted Decimal @map("amount_formatted") @db.Decimal(36, 8) // 格式化金额
|
||||
|
||||
blockNumber BigInt @map("block_number")
|
||||
blockTimestamp DateTime @map("block_timestamp")
|
||||
logIndex Int @map("log_index")
|
||||
|
||||
// 确认状态
|
||||
confirmations Int @default(0)
|
||||
status String @default("DETECTED") @db.VarChar(20) // DETECTED, CONFIRMING, CONFIRMED, CREDITED, FAILED
|
||||
|
||||
// 记账状态
|
||||
creditedAt DateTime? @map("credited_at")
|
||||
creditReference String? @map("credit_reference") @db.VarChar(100)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([chainType, txHash, logIndex], name: "uk_pool_chain_tx_log")
|
||||
@@index([chainType, status], name: "idx_pool_chain_status")
|
||||
@@index([poolType], name: "idx_pool_type")
|
||||
@@index([blockNumber], name: "idx_pool_block")
|
||||
@@map("pool_account_deposits")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 池账户区块扫描检查点
|
||||
// 独立于做市商和用户的扫描进度
|
||||
// ============================================
|
||||
model PoolAccountBlockCheckpoint {
|
||||
id BigInt @id @default(autoincrement()) @map("checkpoint_id")
|
||||
|
||||
chainType String @map("chain_type") @db.VarChar(20)
|
||||
poolType String @map("pool_type") @db.VarChar(20) // BURN_POOL 或 MINING_POOL
|
||||
|
||||
lastScannedBlock BigInt @map("last_scanned_block")
|
||||
lastScannedAt DateTime @map("last_scanned_at")
|
||||
|
||||
isHealthy Boolean @default(true) @map("is_healthy")
|
||||
lastError String? @map("last_error") @db.Text
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([chainType, poolType], name: "uk_pool_chain_pool")
|
||||
@@map("pool_account_block_checkpoints")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Controller, Post, Body, Get, Param } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiProperty, ApiParam } from '@nestjs/swagger';
|
||||
import { IsString, IsNotEmpty, Matches, IsNumberString } from 'class-validator';
|
||||
import { Erc20TransferService, TransferResult, TokenType } from '@/domain/services/erc20-transfer.service';
|
||||
import { Erc20TransferService, TransferResult, TokenType, PoolWalletType } from '@/domain/services/erc20-transfer.service';
|
||||
import { ChainTypeEnum } from '@/domain/enums';
|
||||
|
||||
/**
|
||||
|
|
@ -210,4 +210,54 @@ export class TransferController {
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 池账户接口 ============
|
||||
|
||||
@Post('pool-account')
|
||||
@ApiOperation({ summary: '池账户 fUSDT 提现' })
|
||||
@ApiResponse({ status: 200, description: '转账结果', type: TransferResponseDto })
|
||||
async transferFromPoolAccount(
|
||||
@Body() body: { poolType: PoolWalletType; toAddress: string; amount: string },
|
||||
): Promise<TransferResponseDto> {
|
||||
const result: TransferResult = await this.erc20TransferService.transferTokenAsPoolAccount(
|
||||
ChainTypeEnum.KAVA,
|
||||
body.poolType,
|
||||
body.toAddress,
|
||||
body.amount,
|
||||
);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
txHash: result.txHash,
|
||||
error: result.error,
|
||||
gasUsed: result.gasUsed,
|
||||
blockNumber: result.blockNumber,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('pool-accounts/:poolType/wallet-info')
|
||||
@ApiOperation({ summary: '查询池钱包信息' })
|
||||
@ApiParam({ name: 'poolType', description: '池类型: BURN_POOL 或 MINING_POOL' })
|
||||
async getPoolWalletInfo(
|
||||
@Param('poolType') poolType: PoolWalletType,
|
||||
): Promise<{ address: string; balance: string; chain: string; configured: boolean }> {
|
||||
const address = this.erc20TransferService.getPoolWalletAddress(poolType);
|
||||
const configured = this.erc20TransferService.isPoolWalletConfigured(poolType, ChainTypeEnum.KAVA);
|
||||
|
||||
let balance = '0';
|
||||
if (address) {
|
||||
try {
|
||||
balance = await this.erc20TransferService.getPoolWalletTokenBalance(ChainTypeEnum.KAVA, poolType);
|
||||
} catch {
|
||||
balance = '0';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
address: address || '',
|
||||
balance,
|
||||
chain: 'KAVA',
|
||||
configured,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
|||
import { DomainModule } from '@/domain/domain.module';
|
||||
import { MpcTransferInitializerService } from './services/mpc-transfer-initializer.service';
|
||||
import { MarketMakerDepositDetectionService } from './services/market-maker-deposit-detection.service';
|
||||
import { PoolAccountDepositDetectionService } from './services/pool-account-deposit-detection.service';
|
||||
import { OutboxPublisherService } from './services/outbox-publisher.service';
|
||||
import { DepositAckConsumerService } from '@/infrastructure/kafka/deposit-ack-consumer.service';
|
||||
|
||||
|
|
@ -13,6 +14,8 @@ import { DepositAckConsumerService } from '@/infrastructure/kafka/deposit-ack-co
|
|||
MpcTransferInitializerService,
|
||||
// 做市商充值检测服务
|
||||
MarketMakerDepositDetectionService,
|
||||
// 池账户充值检测服务
|
||||
PoolAccountDepositDetectionService,
|
||||
// Outbox 事件发布服务(将确认的充值事件发布到 Kafka)
|
||||
OutboxPublisherService,
|
||||
// 充值 ACK 消费者(接收 wallet-service 的确认回执)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,285 @@
|
|||
import { Injectable, Logger, Inject, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { EvmProviderAdapter, TransferEvent } from '@/infrastructure/blockchain/evm-provider.adapter';
|
||||
import { ConfirmationPolicyService } from '@/domain/services/confirmation-policy.service';
|
||||
import { ChainConfigService } from '@/domain/services/chain-config.service';
|
||||
import {
|
||||
POOL_ACCOUNT_DEPOSIT_REPOSITORY,
|
||||
IPoolAccountDepositRepository,
|
||||
PoolAccountDepositDto,
|
||||
POOL_ACCOUNT_CHECKPOINT_REPOSITORY,
|
||||
IPoolAccountCheckpointRepository,
|
||||
} from '@/domain/repositories/pool-account-deposit.repository.interface';
|
||||
import {
|
||||
OUTBOX_EVENT_REPOSITORY,
|
||||
IOutboxEventRepository,
|
||||
} from '@/domain/repositories/outbox-event.repository.interface';
|
||||
import { ChainType, BlockNumber } from '@/domain/value-objects';
|
||||
import { ChainTypeEnum, PoolAccountType, PoolAccountDepositStatus } from '@/domain/enums';
|
||||
import { PoolAccountDepositConfirmedEvent } from '@/domain/events';
|
||||
|
||||
interface PoolWalletConfig {
|
||||
poolType: PoolAccountType;
|
||||
walletAddress: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 池账户充值检测服务
|
||||
*
|
||||
* 负责扫描区块链、检测100亿销毁池和200万挖矿池的 fUSDT 充值、更新确认状态
|
||||
* 当充值达到确认数后,通过 Kafka 通知 mining-wallet-service 进行入账
|
||||
*/
|
||||
@Injectable()
|
||||
export class PoolAccountDepositDetectionService implements OnModuleInit {
|
||||
private readonly logger = new Logger(PoolAccountDepositDetectionService.name);
|
||||
private readonly scanBatchSize: number;
|
||||
private readonly fUsdtContract: string;
|
||||
private readonly poolWalletConfigs: PoolWalletConfig[];
|
||||
private isEnabled: boolean = false;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly evmProvider: EvmProviderAdapter,
|
||||
private readonly confirmationPolicy: ConfirmationPolicyService,
|
||||
private readonly chainConfig: ChainConfigService,
|
||||
@Inject(POOL_ACCOUNT_DEPOSIT_REPOSITORY)
|
||||
private readonly depositRepo: IPoolAccountDepositRepository,
|
||||
@Inject(POOL_ACCOUNT_CHECKPOINT_REPOSITORY)
|
||||
private readonly checkpointRepo: IPoolAccountCheckpointRepository,
|
||||
@Inject(OUTBOX_EVENT_REPOSITORY)
|
||||
private readonly outboxRepo: IOutboxEventRepository,
|
||||
) {
|
||||
this.scanBatchSize = this.configService.get<number>('blockchain.scanBatchSize', 100);
|
||||
this.fUsdtContract = this.configService.get<string>('blockchain.kava.fUsdtContract', '');
|
||||
|
||||
const burnPoolAddress = this.configService.get<string>('blockchain.burnPool.walletAddress', '');
|
||||
const miningPoolAddress = this.configService.get<string>('blockchain.miningPool.walletAddress', '');
|
||||
|
||||
this.poolWalletConfigs = [
|
||||
{
|
||||
poolType: PoolAccountType.BURN_POOL,
|
||||
walletAddress: burnPoolAddress,
|
||||
name: '100亿销毁池',
|
||||
},
|
||||
{
|
||||
poolType: PoolAccountType.MINING_POOL,
|
||||
walletAddress: miningPoolAddress,
|
||||
name: '200万挖矿池',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
const validConfigs = this.poolWalletConfigs.filter((c) => c.walletAddress);
|
||||
|
||||
if (validConfigs.length === 0 || !this.fUsdtContract) {
|
||||
this.logger.warn('[INIT] 没有有效的池钱包配置或 fUSDT 合约未配置,池账户充值监控功能已禁用');
|
||||
this.isEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.poolWalletConfigs.forEach((c) => {
|
||||
if (!c.walletAddress) {
|
||||
this.logger.warn(`[INIT] ${c.name} 钱包地址未配置,该池充值监控已禁用`);
|
||||
}
|
||||
});
|
||||
|
||||
this.isEnabled = true;
|
||||
this.logger.log(`[INIT] PoolAccountDepositDetectionService initialized`);
|
||||
this.logger.log(`[INIT] fUSDT 合约: ${this.fUsdtContract}`);
|
||||
validConfigs.forEach((c) => {
|
||||
this.logger.log(`[INIT] ${c.name}: 钱包=${c.walletAddress}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时扫描区块(每5秒)
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_5_SECONDS)
|
||||
async scanBlocks(): Promise<void> {
|
||||
if (!this.isEnabled) return;
|
||||
|
||||
const chainType = ChainType.fromEnum(ChainTypeEnum.KAVA);
|
||||
|
||||
for (const walletConfig of this.poolWalletConfigs) {
|
||||
if (!walletConfig.walletAddress) continue;
|
||||
|
||||
try {
|
||||
await this.scanPoolDeposits(chainType, walletConfig);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error scanning ${walletConfig.name} deposits:`, error);
|
||||
await this.checkpointRepo.recordError(
|
||||
chainType,
|
||||
walletConfig.poolType,
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描单个池钱包的充值
|
||||
*/
|
||||
private async scanPoolDeposits(chainType: ChainType, walletConfig: PoolWalletConfig): Promise<void> {
|
||||
let lastBlock = await this.checkpointRepo.getLastScannedBlock(chainType, walletConfig.poolType);
|
||||
|
||||
if (!lastBlock) {
|
||||
const currentBlock = await this.evmProvider.getCurrentBlockNumber(chainType);
|
||||
lastBlock = currentBlock.subtract(10);
|
||||
await this.checkpointRepo.initializeIfNotExists(chainType, walletConfig.poolType, lastBlock);
|
||||
}
|
||||
|
||||
const currentBlock = await this.evmProvider.getCurrentBlockNumber(chainType);
|
||||
|
||||
const fromBlock = lastBlock.add(1);
|
||||
let toBlock = fromBlock.add(this.scanBatchSize - 1);
|
||||
|
||||
if (toBlock.isGreaterThan(currentBlock)) {
|
||||
toBlock = currentBlock;
|
||||
}
|
||||
|
||||
if (fromBlock.isGreaterThan(currentBlock)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Scanning ${walletConfig.name}: blocks ${fromBlock} to ${toBlock}`,
|
||||
);
|
||||
|
||||
// 扫描 fUSDT Transfer 事件
|
||||
const events = await this.evmProvider.scanTransferEvents(
|
||||
chainType,
|
||||
fromBlock,
|
||||
toBlock,
|
||||
this.fUsdtContract,
|
||||
);
|
||||
|
||||
// 过滤出充值到池钱包的交易
|
||||
const deposits = events.filter(
|
||||
(e) => e.to.toLowerCase() === walletConfig.walletAddress.toLowerCase(),
|
||||
);
|
||||
|
||||
for (const deposit of deposits) {
|
||||
await this.processDeposit(chainType, walletConfig, deposit);
|
||||
}
|
||||
|
||||
if (toBlock.isGreaterThan(lastBlock)) {
|
||||
await this.checkpointRepo.updateCheckpoint(chainType, walletConfig.poolType, toBlock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理检测到的充值
|
||||
*/
|
||||
private async processDeposit(
|
||||
chainType: ChainType,
|
||||
walletConfig: PoolWalletConfig,
|
||||
event: TransferEvent,
|
||||
): Promise<void> {
|
||||
if (await this.depositRepo.existsByTxHashAndLogIndex(event.txHash, event.logIndex)) {
|
||||
this.logger.debug(`Pool deposit already processed: ${event.txHash}:${event.logIndex}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenDecimals = await this.evmProvider.getTokenDecimals(
|
||||
chainType,
|
||||
this.fUsdtContract,
|
||||
);
|
||||
|
||||
const divisor = BigInt(10 ** tokenDecimals);
|
||||
const integerPart = event.value / divisor;
|
||||
const fractionalPart = event.value % divisor;
|
||||
const amountFormatted = `${integerPart}.${fractionalPart.toString().padStart(tokenDecimals, '0')}`;
|
||||
|
||||
const deposit: PoolAccountDepositDto = {
|
||||
chainType: chainType.toString(),
|
||||
txHash: event.txHash,
|
||||
fromAddress: event.from,
|
||||
toAddress: event.to,
|
||||
poolType: walletConfig.poolType,
|
||||
tokenContract: this.fUsdtContract,
|
||||
amount: event.value,
|
||||
amountFormatted,
|
||||
blockNumber: event.blockNumber,
|
||||
blockTimestamp: event.blockTimestamp,
|
||||
logIndex: event.logIndex,
|
||||
confirmations: 0,
|
||||
status: PoolAccountDepositStatus.DETECTED,
|
||||
};
|
||||
|
||||
await this.depositRepo.save(deposit);
|
||||
|
||||
this.logger.log(
|
||||
`[DEPOSIT] ${walletConfig.name} fUSDT deposit detected: ${event.txHash.slice(0, 10)}... -> ${walletConfig.walletAddress.slice(0, 10)}... (${amountFormatted})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时更新确认状态(每30秒)
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_30_SECONDS)
|
||||
async updateConfirmations(): Promise<void> {
|
||||
if (!this.isEnabled) return;
|
||||
|
||||
const chainType = ChainType.fromEnum(ChainTypeEnum.KAVA);
|
||||
|
||||
try {
|
||||
await this.updateChainConfirmations(chainType);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error updating pool account confirmations:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新确认状态
|
||||
*/
|
||||
private async updateChainConfirmations(chainType: ChainType): Promise<void> {
|
||||
const pendingDeposits = await this.depositRepo.findPendingConfirmation(chainType);
|
||||
if (pendingDeposits.length === 0) return;
|
||||
|
||||
const currentBlock = await this.evmProvider.getCurrentBlockNumber(chainType);
|
||||
const requiredConfirmations = this.confirmationPolicy.getRequiredConfirmations(chainType);
|
||||
|
||||
for (const deposit of pendingDeposits) {
|
||||
const confirmations = Number(currentBlock.value - deposit.blockNumber);
|
||||
const isConfirmed = confirmations >= requiredConfirmations;
|
||||
|
||||
const newStatus = isConfirmed
|
||||
? PoolAccountDepositStatus.CONFIRMED
|
||||
: PoolAccountDepositStatus.CONFIRMING;
|
||||
|
||||
await this.depositRepo.updateConfirmations(deposit.id!, confirmations, newStatus);
|
||||
|
||||
if (isConfirmed && deposit.status !== PoolAccountDepositStatus.CONFIRMED) {
|
||||
const event = new PoolAccountDepositConfirmedEvent({
|
||||
depositId: deposit.id!.toString(),
|
||||
chainType: deposit.chainType,
|
||||
txHash: deposit.txHash,
|
||||
fromAddress: deposit.fromAddress,
|
||||
toAddress: deposit.toAddress,
|
||||
poolType: deposit.poolType as 'BURN_POOL' | 'MINING_POOL',
|
||||
tokenContract: deposit.tokenContract,
|
||||
amount: deposit.amount.toString(),
|
||||
amountFormatted: deposit.amountFormatted,
|
||||
confirmations,
|
||||
blockNumber: deposit.blockNumber.toString(),
|
||||
blockTimestamp: deposit.blockTimestamp.toISOString(),
|
||||
});
|
||||
|
||||
// 写入 outbox,保证可靠投递
|
||||
await this.outboxRepo.create({
|
||||
eventType: event.eventType,
|
||||
aggregateId: deposit.id!.toString(),
|
||||
aggregateType: 'PoolAccountDeposit',
|
||||
payload: event.toPayload(),
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`[CONFIRMED] Pool deposit confirmed: ${deposit.txHash.slice(0, 10)}... (${confirmations} confirmations, ${deposit.poolType})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -92,5 +92,17 @@ export default registerAs('blockchain', () => {
|
|||
walletAddress: process.env.FUSDT_MARKET_MAKER_ADDRESS || '',
|
||||
mpcUsername: process.env.FUSDT_MARKET_MAKER_USERNAME || '',
|
||||
},
|
||||
|
||||
// 100亿销毁池 MPC 钱包配置 (2-of-3 门限)
|
||||
burnPool: {
|
||||
walletAddress: process.env.BURN_POOL_WALLET_ADDRESS || '',
|
||||
mpcUsername: process.env.BURN_POOL_WALLET_USERNAME || '',
|
||||
},
|
||||
|
||||
// 200万挖矿池 MPC 钱包配置 (2-of-3 门限)
|
||||
miningPool: {
|
||||
walletAddress: process.env.MINING_POOL_WALLET_ADDRESS || '',
|
||||
mpcUsername: process.env.MINING_POOL_WALLET_USERNAME || '',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,3 +3,5 @@ export * from './deposit-status.enum';
|
|||
export * from './transaction-status.enum';
|
||||
export * from './market-maker-asset-type.enum';
|
||||
export * from './market-maker-deposit-status.enum';
|
||||
export * from './pool-account-type.enum';
|
||||
export * from './pool-account-deposit-status.enum';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* 池账户充值状态
|
||||
*/
|
||||
export enum PoolAccountDepositStatus {
|
||||
/** 已检测到 */
|
||||
DETECTED = 'DETECTED',
|
||||
/** 确认中 */
|
||||
CONFIRMING = 'CONFIRMING',
|
||||
/** 已确认 (达到确认数) */
|
||||
CONFIRMED = 'CONFIRMED',
|
||||
/** 已入账 (wallet-service 确认入账) */
|
||||
CREDITED = 'CREDITED',
|
||||
/** 失败 */
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* 池账户类型
|
||||
*/
|
||||
export enum PoolAccountType {
|
||||
/** 100亿销毁池 */
|
||||
BURN_POOL = 'BURN_POOL',
|
||||
/** 200万挖矿池 */
|
||||
MINING_POOL = 'MINING_POOL',
|
||||
}
|
||||
|
|
@ -4,3 +4,4 @@ export * from './deposit-confirmed.event';
|
|||
export * from './wallet-address-created.event';
|
||||
export * from './transaction-broadcasted.event';
|
||||
export * from './market-maker-deposit-confirmed.event';
|
||||
export * from './pool-account-deposit-confirmed.event';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { DomainEvent } from './domain-event.base';
|
||||
|
||||
export interface PoolAccountDepositConfirmedPayload {
|
||||
depositId: string;
|
||||
chainType: string;
|
||||
txHash: string;
|
||||
fromAddress: string;
|
||||
toAddress: string;
|
||||
poolType: 'BURN_POOL' | 'MINING_POOL';
|
||||
tokenContract: string;
|
||||
amount: string;
|
||||
amountFormatted: string;
|
||||
confirmations: number;
|
||||
blockNumber: string;
|
||||
blockTimestamp: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 池账户充值确认事件
|
||||
* 当池钱包的 fUSDT 充值交易达到确认数时触发
|
||||
* 由 mining-wallet-service 消费,用于池账户入账
|
||||
*/
|
||||
export class PoolAccountDepositConfirmedEvent extends DomainEvent {
|
||||
readonly eventType = 'mining_blockchain.pool_account.deposit.confirmed';
|
||||
|
||||
constructor(private readonly payload: PoolAccountDepositConfirmedPayload) {
|
||||
super();
|
||||
}
|
||||
|
||||
toPayload(): PoolAccountDepositConfirmedPayload {
|
||||
return this.payload;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,3 +4,4 @@ export * from './block-checkpoint.repository.interface';
|
|||
export * from './transaction-request.repository.interface';
|
||||
export * from './outbox-event.repository.interface';
|
||||
export * from './market-maker-deposit.repository.interface';
|
||||
export * from './pool-account-deposit.repository.interface';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import { ChainType, BlockNumber } from '@/domain/value-objects';
|
||||
import { PoolAccountType, PoolAccountDepositStatus } from '@/domain/enums';
|
||||
|
||||
export const POOL_ACCOUNT_DEPOSIT_REPOSITORY = Symbol('POOL_ACCOUNT_DEPOSIT_REPOSITORY');
|
||||
|
||||
/**
|
||||
* 池账户充值记录 DTO
|
||||
*/
|
||||
export interface PoolAccountDepositDto {
|
||||
id?: bigint;
|
||||
chainType: string;
|
||||
txHash: string;
|
||||
fromAddress: string;
|
||||
toAddress: string;
|
||||
poolType: PoolAccountType;
|
||||
tokenContract: string;
|
||||
amount: bigint;
|
||||
amountFormatted: string;
|
||||
blockNumber: bigint;
|
||||
blockTimestamp: Date;
|
||||
logIndex: number;
|
||||
confirmations: number;
|
||||
status: PoolAccountDepositStatus;
|
||||
creditedAt?: Date | null;
|
||||
creditReference?: string | null;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface IPoolAccountDepositRepository {
|
||||
/**
|
||||
* 保存充值记录
|
||||
*/
|
||||
save(deposit: PoolAccountDepositDto): Promise<PoolAccountDepositDto>;
|
||||
|
||||
/**
|
||||
* 根据ID查找
|
||||
*/
|
||||
findById(id: bigint): Promise<PoolAccountDepositDto | null>;
|
||||
|
||||
/**
|
||||
* 检查交易是否存在(按txHash+logIndex)
|
||||
*/
|
||||
existsByTxHashAndLogIndex(txHash: string, logIndex: number): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 查找待确认的充值
|
||||
*/
|
||||
findPendingConfirmation(chainType: ChainType): Promise<PoolAccountDepositDto[]>;
|
||||
|
||||
/**
|
||||
* 更新确认状态
|
||||
*/
|
||||
updateConfirmations(
|
||||
id: bigint,
|
||||
confirmations: number,
|
||||
status: PoolAccountDepositStatus,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* 更新入账状态
|
||||
*/
|
||||
updateCreditedStatus(
|
||||
id: bigint,
|
||||
creditedAt: Date,
|
||||
creditReference: string,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export const POOL_ACCOUNT_CHECKPOINT_REPOSITORY = Symbol('POOL_ACCOUNT_CHECKPOINT_REPOSITORY');
|
||||
|
||||
export interface IPoolAccountCheckpointRepository {
|
||||
/**
|
||||
* 获取上次扫描的区块
|
||||
*/
|
||||
getLastScannedBlock(chainType: ChainType, poolType: PoolAccountType): Promise<BlockNumber | null>;
|
||||
|
||||
/**
|
||||
* 更新检查点
|
||||
*/
|
||||
updateCheckpoint(chainType: ChainType, poolType: PoolAccountType, blockNumber: BlockNumber): Promise<void>;
|
||||
|
||||
/**
|
||||
* 初始化检查点(如果不存在)
|
||||
*/
|
||||
initializeIfNotExists(chainType: ChainType, poolType: PoolAccountType, blockNumber: BlockNumber): Promise<void>;
|
||||
|
||||
/**
|
||||
* 记录错误
|
||||
*/
|
||||
recordError(chainType: ChainType, poolType: PoolAccountType, error: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 标记健康
|
||||
*/
|
||||
markHealthy(chainType: ChainType, poolType: PoolAccountType): Promise<void>;
|
||||
}
|
||||
|
|
@ -47,8 +47,19 @@ export interface IMpcSigningClient {
|
|||
isFusdtMarketMakerConfigured(): boolean;
|
||||
getFusdtMarketMakerAddress(): string;
|
||||
signMessageAsFusdtMarketMaker(messageHash: string): Promise<string>;
|
||||
// 100亿销毁池钱包
|
||||
isBurnPoolConfigured(): boolean;
|
||||
getBurnPoolAddress(): string;
|
||||
signMessageAsBurnPool(messageHash: string): Promise<string>;
|
||||
// 200万挖矿池钱包
|
||||
isMiningPoolConfigured(): boolean;
|
||||
getMiningPoolAddress(): string;
|
||||
signMessageAsMiningPool(messageHash: string): Promise<string>;
|
||||
}
|
||||
|
||||
// 池账户类型(用于 transferTokenAsPoolAccount)
|
||||
export type PoolWalletType = 'BURN_POOL' | 'MINING_POOL';
|
||||
|
||||
export const MPC_SIGNING_CLIENT = Symbol('MPC_SIGNING_CLIENT');
|
||||
|
||||
/**
|
||||
|
|
@ -66,6 +77,10 @@ export class Erc20TransferService {
|
|||
private readonly eusdtMarketMakerAddress: string;
|
||||
// fUSDT (积分值) 做市商钱包地址
|
||||
private readonly fusdtMarketMakerAddress: string;
|
||||
// 100亿销毁池钱包地址
|
||||
private readonly burnPoolAddress: string;
|
||||
// 200万挖矿池钱包地址
|
||||
private readonly miningPoolAddress: string;
|
||||
private mpcSigningClient: IMpcSigningClient | null = null;
|
||||
|
||||
constructor(
|
||||
|
|
@ -76,6 +91,8 @@ export class Erc20TransferService {
|
|||
this.hotWalletAddress = this.configService.get<string>('C2C_BOT_WALLET_ADDRESS', '');
|
||||
this.eusdtMarketMakerAddress = this.configService.get<string>('EUSDT_MARKET_MAKER_ADDRESS', '');
|
||||
this.fusdtMarketMakerAddress = this.configService.get<string>('FUSDT_MARKET_MAKER_ADDRESS', '');
|
||||
this.burnPoolAddress = this.configService.get<string>('BURN_POOL_WALLET_ADDRESS', '');
|
||||
this.miningPoolAddress = this.configService.get<string>('MINING_POOL_WALLET_ADDRESS', '');
|
||||
this.initializeWalletConfig();
|
||||
}
|
||||
|
||||
|
|
@ -895,4 +912,224 @@ export class Erc20TransferService {
|
|||
|
||||
return formatUnits(balance, decimals);
|
||||
}
|
||||
|
||||
// ============ 池账户钱包方法 ============
|
||||
|
||||
/**
|
||||
* 获取池钱包地址
|
||||
*/
|
||||
getPoolWalletAddress(poolType: PoolWalletType): string | null {
|
||||
const address = poolType === 'BURN_POOL' ? this.burnPoolAddress : this.miningPoolAddress;
|
||||
return address || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查池钱包是否已配置
|
||||
*/
|
||||
isPoolWalletConfigured(poolType: PoolWalletType, chainType: ChainTypeEnum): boolean {
|
||||
try {
|
||||
this.rpcProviderManager.getProvider(chainType);
|
||||
if (poolType === 'BURN_POOL') {
|
||||
return !!this.burnPoolAddress && !!this.mpcSigningClient?.isBurnPoolConfigured();
|
||||
} else {
|
||||
return !!this.miningPoolAddress && !!this.mpcSigningClient?.isMiningPoolConfigured();
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取池钱包的 fUSDT 链上余额
|
||||
*/
|
||||
async getPoolWalletTokenBalance(chainType: ChainTypeEnum, poolType: PoolWalletType): Promise<string> {
|
||||
const provider = this.getProvider(chainType);
|
||||
const walletAddress = poolType === 'BURN_POOL' ? this.burnPoolAddress : this.miningPoolAddress;
|
||||
const poolName = poolType === 'BURN_POOL' ? '100亿销毁池' : '200万挖矿池';
|
||||
|
||||
if (!walletAddress) {
|
||||
throw new Error(`${poolName} wallet address not configured`);
|
||||
}
|
||||
|
||||
const contractAddress = this.getTokenContract(chainType, 'FUSDT');
|
||||
if (!contractAddress) {
|
||||
throw new Error(`fUSDT not configured for chain ${chainType}`);
|
||||
}
|
||||
|
||||
const contract = new Contract(contractAddress, ERC20_TRANSFER_ABI, provider);
|
||||
const balance = await contract.balanceOf(walletAddress);
|
||||
const decimals = await contract.decimals();
|
||||
|
||||
return formatUnits(balance, decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* 池账户 fUSDT 转账(使用池钱包 MPC 签名)
|
||||
*/
|
||||
async transferTokenAsPoolAccount(
|
||||
chainType: ChainTypeEnum,
|
||||
poolType: PoolWalletType,
|
||||
toAddress: string,
|
||||
amount: string,
|
||||
): Promise<TransferResult> {
|
||||
const isBurnPool = poolType === 'BURN_POOL';
|
||||
const walletAddress = isBurnPool ? this.burnPoolAddress : this.miningPoolAddress;
|
||||
const poolName = isBurnPool ? '100亿销毁池' : '200万挖矿池';
|
||||
|
||||
this.logger.log(`[POOL-TRANSFER] Starting fUSDT ${poolName} transfer`);
|
||||
this.logger.log(`[POOL-TRANSFER] From: ${walletAddress}`);
|
||||
this.logger.log(`[POOL-TRANSFER] To: ${toAddress}`);
|
||||
this.logger.log(`[POOL-TRANSFER] Amount: ${amount} fUSDT`);
|
||||
|
||||
const provider = this.getProvider(chainType);
|
||||
|
||||
// 检查钱包是否配置
|
||||
if (isBurnPool) {
|
||||
if (!this.mpcSigningClient || !this.mpcSigningClient.isBurnPoolConfigured()) {
|
||||
const error = 'Burn Pool MPC signing not configured';
|
||||
this.logger.error(`[POOL-TRANSFER] ${error}`);
|
||||
return { success: false, error };
|
||||
}
|
||||
} else {
|
||||
if (!this.mpcSigningClient || !this.mpcSigningClient.isMiningPoolConfigured()) {
|
||||
const error = 'Mining Pool MPC signing not configured';
|
||||
this.logger.error(`[POOL-TRANSFER] ${error}`);
|
||||
return { success: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
if (!walletAddress) {
|
||||
const error = `${poolName} wallet address not configured`;
|
||||
this.logger.error(`[POOL-TRANSFER] ${error}`);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
try {
|
||||
const config = this.chainConfig.getConfig(ChainType.fromEnum(chainType));
|
||||
const contractAddress = this.getTokenContract(chainType, 'FUSDT');
|
||||
|
||||
if (!contractAddress) {
|
||||
const error = `fUSDT not configured for chain ${chainType}`;
|
||||
this.logger.error(`[POOL-TRANSFER] ${error}`);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
const contract = new Contract(contractAddress, ERC20_TRANSFER_ABI, provider);
|
||||
|
||||
const decimals = await contract.decimals();
|
||||
const amountInWei = parseUnits(amount, decimals);
|
||||
|
||||
// 检查余额
|
||||
const balance = await contract.balanceOf(walletAddress);
|
||||
this.logger.log(`[POOL-TRANSFER] ${poolName} balance: ${formatUnits(balance, decimals)} fUSDT`);
|
||||
|
||||
if (balance < amountInWei) {
|
||||
const error = `Insufficient fUSDT balance in ${poolName} wallet`;
|
||||
this.logger.error(`[POOL-TRANSFER] ${error}`);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
// 构建交易
|
||||
const nonce = await provider.getTransactionCount(walletAddress);
|
||||
const feeData = await provider.getFeeData();
|
||||
const transferData = contract.interface.encodeFunctionData('transfer', [toAddress, amountInWei]);
|
||||
|
||||
const gasEstimate = await provider.estimateGas({
|
||||
from: walletAddress,
|
||||
to: contractAddress,
|
||||
data: transferData,
|
||||
});
|
||||
const gasLimit = gasEstimate * BigInt(120) / BigInt(100);
|
||||
|
||||
const supportsEip1559 = feeData.maxFeePerGas && feeData.maxFeePerGas > BigInt(0);
|
||||
|
||||
let tx: Transaction;
|
||||
if (supportsEip1559) {
|
||||
tx = Transaction.from({
|
||||
type: 2,
|
||||
chainId: config.chainId,
|
||||
nonce,
|
||||
to: contractAddress,
|
||||
data: transferData,
|
||||
gasLimit,
|
||||
maxFeePerGas: feeData.maxFeePerGas,
|
||||
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
|
||||
});
|
||||
} else {
|
||||
const gasPrice = feeData.gasPrice || BigInt(1000000000);
|
||||
tx = Transaction.from({
|
||||
type: 0,
|
||||
chainId: config.chainId,
|
||||
nonce,
|
||||
to: contractAddress,
|
||||
data: transferData,
|
||||
gasLimit,
|
||||
gasPrice,
|
||||
});
|
||||
}
|
||||
|
||||
const unsignedTxHash = tx.unsignedHash;
|
||||
|
||||
// 使用对应的池钱包 MPC 签名
|
||||
this.logger.log(`[POOL-TRANSFER] Requesting ${poolName} MPC signature...`);
|
||||
const signatureHex = isBurnPool
|
||||
? await this.mpcSigningClient!.signMessageAsBurnPool(unsignedTxHash)
|
||||
: await this.mpcSigningClient!.signMessageAsMiningPool(unsignedTxHash);
|
||||
|
||||
// 解析签名
|
||||
const normalizedSig = signatureHex.startsWith('0x') ? signatureHex : `0x${signatureHex}`;
|
||||
const sigBytes = normalizedSig.slice(2);
|
||||
const r = `0x${sigBytes.slice(0, 64)}`;
|
||||
const s = `0x${sigBytes.slice(64, 128)}`;
|
||||
|
||||
let signature: Signature | null = null;
|
||||
for (const yParity of [0, 1] as const) {
|
||||
try {
|
||||
const testSig = Signature.from({ r, s, yParity });
|
||||
const recoveredAddress = recoverAddress(unsignedTxHash, testSig);
|
||||
if (recoveredAddress.toLowerCase() === walletAddress.toLowerCase()) {
|
||||
signature = testSig;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.debug(`[POOL-TRANSFER] yParity=${yParity} failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!signature) {
|
||||
throw new Error('Failed to recover correct signature - address mismatch');
|
||||
}
|
||||
|
||||
const signedTx = tx.clone();
|
||||
signedTx.signature = signature;
|
||||
|
||||
this.logger.log(`[POOL-TRANSFER] Broadcasting transaction...`);
|
||||
const txResponse = await provider.broadcastTransaction(signedTx.serialized);
|
||||
this.logger.log(`[POOL-TRANSFER] Transaction sent: ${txResponse.hash}`);
|
||||
|
||||
const receipt = await txResponse.wait();
|
||||
|
||||
if (receipt && receipt.status === 1) {
|
||||
this.logger.log(`[POOL-TRANSFER] Transaction confirmed! Block: ${receipt.blockNumber}`);
|
||||
this.rpcProviderManager.reportSuccess(chainType);
|
||||
return {
|
||||
success: true,
|
||||
txHash: txResponse.hash,
|
||||
gasUsed: receipt.gasUsed.toString(),
|
||||
blockNumber: receipt.blockNumber,
|
||||
};
|
||||
} else {
|
||||
return { success: false, txHash: txResponse.hash, error: 'Transaction failed (reverted)' };
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (this.isRpcConnectionError(error)) {
|
||||
this.rpcProviderManager.reportFailure(chainType, error);
|
||||
}
|
||||
this.logger.error(`[POOL-TRANSFER] Transfer failed:`, error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Unknown error during transfer',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import {
|
|||
OUTBOX_EVENT_REPOSITORY,
|
||||
MARKET_MAKER_DEPOSIT_REPOSITORY,
|
||||
MARKET_MAKER_CHECKPOINT_REPOSITORY,
|
||||
POOL_ACCOUNT_DEPOSIT_REPOSITORY,
|
||||
POOL_ACCOUNT_CHECKPOINT_REPOSITORY,
|
||||
} from '@/domain/repositories';
|
||||
import {
|
||||
DepositTransactionRepositoryImpl,
|
||||
|
|
@ -24,6 +26,8 @@ import {
|
|||
OutboxEventRepositoryImpl,
|
||||
MarketMakerDepositRepositoryImpl,
|
||||
MarketMakerCheckpointRepositoryImpl,
|
||||
PoolAccountDepositRepositoryImpl,
|
||||
PoolAccountCheckpointRepositoryImpl,
|
||||
} from './persistence/repositories';
|
||||
|
||||
@Global()
|
||||
|
|
@ -76,6 +80,14 @@ import {
|
|||
provide: MARKET_MAKER_CHECKPOINT_REPOSITORY,
|
||||
useClass: MarketMakerCheckpointRepositoryImpl,
|
||||
},
|
||||
{
|
||||
provide: POOL_ACCOUNT_DEPOSIT_REPOSITORY,
|
||||
useClass: PoolAccountDepositRepositoryImpl,
|
||||
},
|
||||
{
|
||||
provide: POOL_ACCOUNT_CHECKPOINT_REPOSITORY,
|
||||
useClass: PoolAccountCheckpointRepositoryImpl,
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
PrismaService,
|
||||
|
|
@ -96,6 +108,8 @@ import {
|
|||
OUTBOX_EVENT_REPOSITORY,
|
||||
MARKET_MAKER_DEPOSIT_REPOSITORY,
|
||||
MARKET_MAKER_CHECKPOINT_REPOSITORY,
|
||||
POOL_ACCOUNT_DEPOSIT_REPOSITORY,
|
||||
POOL_ACCOUNT_CHECKPOINT_REPOSITORY,
|
||||
],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
|
|||
'mining_blockchain.mpc.signing.requested': 'mining_mpc.SigningRequested',
|
||||
// 做市商充值事件 - 发送到 trading-service 消费的 topic
|
||||
'mining_blockchain.market_maker.deposit.confirmed': 'mining_blockchain.market_maker.deposits',
|
||||
// 池账户充值事件 - 发送到 mining-wallet-service 消费的 topic
|
||||
'mining_blockchain.pool_account.deposit.confirmed': 'mining_blockchain.pool_account.deposits',
|
||||
};
|
||||
return topicMap[eventType] || 'mining_blockchain.events';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ export class MpcSigningClient {
|
|||
// fUSDT (积分值) 做市商钱包
|
||||
private readonly fusdtMarketMakerUsername: string;
|
||||
private readonly fusdtMarketMakerAddress: string;
|
||||
// 100亿销毁池钱包
|
||||
private readonly burnPoolUsername: string;
|
||||
private readonly burnPoolAddress: string;
|
||||
// 200万挖矿池钱包
|
||||
private readonly miningPoolUsername: string;
|
||||
private readonly miningPoolAddress: string;
|
||||
// MPC system 配置
|
||||
private readonly mpcAccountServiceUrl: string;
|
||||
private readonly mpcJwtSecret: string;
|
||||
|
|
@ -62,6 +68,12 @@ export class MpcSigningClient {
|
|||
// fUSDT (积分值) 做市商钱包配置
|
||||
this.fusdtMarketMakerUsername = this.configService.get<string>('FUSDT_MARKET_MAKER_USERNAME', '');
|
||||
this.fusdtMarketMakerAddress = this.configService.get<string>('FUSDT_MARKET_MAKER_ADDRESS', '');
|
||||
// 100亿销毁池钱包配置
|
||||
this.burnPoolUsername = this.configService.get<string>('BURN_POOL_WALLET_USERNAME', '');
|
||||
this.burnPoolAddress = this.configService.get<string>('BURN_POOL_WALLET_ADDRESS', '');
|
||||
// 200万挖矿池钱包配置
|
||||
this.miningPoolUsername = this.configService.get<string>('MINING_POOL_WALLET_USERNAME', '');
|
||||
this.miningPoolAddress = this.configService.get<string>('MINING_POOL_WALLET_ADDRESS', '');
|
||||
// MPC system 配置
|
||||
this.mpcAccountServiceUrl = this.configService.get<string>('MPC_ACCOUNT_SERVICE_URL', 'http://localhost:4000');
|
||||
this.mpcJwtSecret = this.configService.get<string>('MPC_JWT_SECRET', '');
|
||||
|
|
@ -78,6 +90,12 @@ export class MpcSigningClient {
|
|||
if (!this.fusdtMarketMakerUsername || !this.fusdtMarketMakerAddress) {
|
||||
this.logger.warn('[INIT] fUSDT Market Maker not configured');
|
||||
}
|
||||
if (!this.burnPoolUsername || !this.burnPoolAddress) {
|
||||
this.logger.warn('[INIT] Burn Pool wallet not configured');
|
||||
}
|
||||
if (!this.miningPoolUsername || !this.miningPoolAddress) {
|
||||
this.logger.warn('[INIT] Mining Pool wallet not configured');
|
||||
}
|
||||
if (!this.mpcJwtSecret) {
|
||||
this.logger.warn('[INIT] MPC_JWT_SECRET not configured - signing will fail');
|
||||
}
|
||||
|
|
@ -85,6 +103,8 @@ export class MpcSigningClient {
|
|||
this.logger.log(`[INIT] C2C Bot Wallet: ${this.hotWalletAddress || '(not configured)'}`);
|
||||
this.logger.log(`[INIT] eUSDT Market Maker: ${this.eusdtMarketMakerAddress || '(not configured)'}`);
|
||||
this.logger.log(`[INIT] fUSDT Market Maker: ${this.fusdtMarketMakerAddress || '(not configured)'}`);
|
||||
this.logger.log(`[INIT] Burn Pool: ${this.burnPoolAddress || '(not configured)'}`);
|
||||
this.logger.log(`[INIT] Mining Pool: ${this.miningPoolAddress || '(not configured)'}`);
|
||||
this.logger.log(`[INIT] MPC Account Service: ${this.mpcAccountServiceUrl}`);
|
||||
this.logger.log(`[INIT] Using HTTP direct call to mpc-system`);
|
||||
}
|
||||
|
|
@ -152,6 +172,48 @@ export class MpcSigningClient {
|
|||
return this.fusdtMarketMakerUsername;
|
||||
}
|
||||
|
||||
// ============ 100亿销毁池钱包 ============
|
||||
|
||||
isBurnPoolConfigured(): boolean {
|
||||
return !!this.burnPoolUsername && !!this.burnPoolAddress;
|
||||
}
|
||||
|
||||
getBurnPoolAddress(): string {
|
||||
return this.burnPoolAddress;
|
||||
}
|
||||
|
||||
getBurnPoolUsername(): string {
|
||||
return this.burnPoolUsername;
|
||||
}
|
||||
|
||||
async signMessageAsBurnPool(messageHash: string): Promise<string> {
|
||||
if (!this.burnPoolUsername) {
|
||||
throw new Error('Burn Pool MPC username not configured');
|
||||
}
|
||||
return this.signMessageWithUsername(this.burnPoolUsername, messageHash);
|
||||
}
|
||||
|
||||
// ============ 200万挖矿池钱包 ============
|
||||
|
||||
isMiningPoolConfigured(): boolean {
|
||||
return !!this.miningPoolUsername && !!this.miningPoolAddress;
|
||||
}
|
||||
|
||||
getMiningPoolAddress(): string {
|
||||
return this.miningPoolAddress;
|
||||
}
|
||||
|
||||
getMiningPoolUsername(): string {
|
||||
return this.miningPoolUsername;
|
||||
}
|
||||
|
||||
async signMessageAsMiningPool(messageHash: string): Promise<string> {
|
||||
if (!this.miningPoolUsername) {
|
||||
throw new Error('Mining Pool MPC username not configured');
|
||||
}
|
||||
return this.signMessageWithUsername(this.miningPoolUsername, messageHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名消息(使用 C2C Bot 热钱包)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -5,3 +5,5 @@ export * from './transaction-request.repository.impl';
|
|||
export * from './outbox-event.repository.impl';
|
||||
export * from './market-maker-deposit.repository.impl';
|
||||
export * from './market-maker-checkpoint.repository.impl';
|
||||
export * from './pool-account-deposit.repository.impl';
|
||||
export * from './pool-account-checkpoint.repository.impl';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { IPoolAccountCheckpointRepository } from '@/domain/repositories/pool-account-deposit.repository.interface';
|
||||
import { ChainType, BlockNumber } from '@/domain/value-objects';
|
||||
import { PoolAccountType } from '@/domain/enums';
|
||||
|
||||
@Injectable()
|
||||
export class PoolAccountCheckpointRepositoryImpl implements IPoolAccountCheckpointRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getLastScannedBlock(
|
||||
chainType: ChainType,
|
||||
poolType: PoolAccountType,
|
||||
): Promise<BlockNumber | null> {
|
||||
const record = await this.prisma.poolAccountBlockCheckpoint.findUnique({
|
||||
where: {
|
||||
uk_pool_chain_pool: {
|
||||
chainType: chainType.toString(),
|
||||
poolType,
|
||||
},
|
||||
},
|
||||
});
|
||||
return record ? BlockNumber.create(record.lastScannedBlock) : null;
|
||||
}
|
||||
|
||||
async updateCheckpoint(
|
||||
chainType: ChainType,
|
||||
poolType: PoolAccountType,
|
||||
blockNumber: BlockNumber,
|
||||
): Promise<void> {
|
||||
await this.prisma.poolAccountBlockCheckpoint.upsert({
|
||||
where: {
|
||||
uk_pool_chain_pool: {
|
||||
chainType: chainType.toString(),
|
||||
poolType,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
lastScannedBlock: blockNumber.value,
|
||||
lastScannedAt: new Date(),
|
||||
isHealthy: true,
|
||||
lastError: null,
|
||||
},
|
||||
create: {
|
||||
chainType: chainType.toString(),
|
||||
poolType,
|
||||
lastScannedBlock: blockNumber.value,
|
||||
lastScannedAt: new Date(),
|
||||
isHealthy: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async initializeIfNotExists(
|
||||
chainType: ChainType,
|
||||
poolType: PoolAccountType,
|
||||
blockNumber: BlockNumber,
|
||||
): Promise<void> {
|
||||
const existing = await this.prisma.poolAccountBlockCheckpoint.findUnique({
|
||||
where: {
|
||||
uk_pool_chain_pool: {
|
||||
chainType: chainType.toString(),
|
||||
poolType,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await this.prisma.poolAccountBlockCheckpoint.create({
|
||||
data: {
|
||||
chainType: chainType.toString(),
|
||||
poolType,
|
||||
lastScannedBlock: blockNumber.value,
|
||||
lastScannedAt: new Date(),
|
||||
isHealthy: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async recordError(
|
||||
chainType: ChainType,
|
||||
poolType: PoolAccountType,
|
||||
error: string,
|
||||
): Promise<void> {
|
||||
await this.prisma.poolAccountBlockCheckpoint.upsert({
|
||||
where: {
|
||||
uk_pool_chain_pool: {
|
||||
chainType: chainType.toString(),
|
||||
poolType,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
isHealthy: false,
|
||||
lastError: error,
|
||||
},
|
||||
create: {
|
||||
chainType: chainType.toString(),
|
||||
poolType,
|
||||
lastScannedBlock: BigInt(0),
|
||||
lastScannedAt: new Date(),
|
||||
isHealthy: false,
|
||||
lastError: error,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async markHealthy(
|
||||
chainType: ChainType,
|
||||
poolType: PoolAccountType,
|
||||
): Promise<void> {
|
||||
await this.prisma.poolAccountBlockCheckpoint.updateMany({
|
||||
where: {
|
||||
chainType: chainType.toString(),
|
||||
poolType,
|
||||
},
|
||||
data: {
|
||||
isHealthy: true,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
IPoolAccountDepositRepository,
|
||||
PoolAccountDepositDto,
|
||||
} from '@/domain/repositories/pool-account-deposit.repository.interface';
|
||||
import { ChainType } from '@/domain/value-objects';
|
||||
import { PoolAccountType, PoolAccountDepositStatus } from '@/domain/enums';
|
||||
|
||||
@Injectable()
|
||||
export class PoolAccountDepositRepositoryImpl implements IPoolAccountDepositRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async save(deposit: PoolAccountDepositDto): Promise<PoolAccountDepositDto> {
|
||||
const data = {
|
||||
chainType: deposit.chainType,
|
||||
txHash: deposit.txHash,
|
||||
fromAddress: deposit.fromAddress,
|
||||
toAddress: deposit.toAddress,
|
||||
poolType: deposit.poolType,
|
||||
tokenContract: deposit.tokenContract,
|
||||
amount: new Prisma.Decimal(deposit.amount.toString()),
|
||||
amountFormatted: new Prisma.Decimal(deposit.amountFormatted),
|
||||
blockNumber: deposit.blockNumber,
|
||||
blockTimestamp: deposit.blockTimestamp,
|
||||
logIndex: deposit.logIndex,
|
||||
confirmations: deposit.confirmations,
|
||||
status: deposit.status,
|
||||
creditedAt: deposit.creditedAt,
|
||||
creditReference: deposit.creditReference,
|
||||
};
|
||||
|
||||
if (deposit.id) {
|
||||
const updated = await this.prisma.poolAccountDeposit.update({
|
||||
where: { id: deposit.id },
|
||||
data,
|
||||
});
|
||||
return this.mapToDto(updated);
|
||||
} else {
|
||||
const created = await this.prisma.poolAccountDeposit.create({
|
||||
data,
|
||||
});
|
||||
return this.mapToDto(created);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: bigint): Promise<PoolAccountDepositDto | null> {
|
||||
const record = await this.prisma.poolAccountDeposit.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
return record ? this.mapToDto(record) : null;
|
||||
}
|
||||
|
||||
async existsByTxHashAndLogIndex(txHash: string, logIndex: number): Promise<boolean> {
|
||||
const count = await this.prisma.poolAccountDeposit.count({
|
||||
where: {
|
||||
txHash,
|
||||
logIndex,
|
||||
},
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async findPendingConfirmation(chainType: ChainType): Promise<PoolAccountDepositDto[]> {
|
||||
const records = await this.prisma.poolAccountDeposit.findMany({
|
||||
where: {
|
||||
chainType: chainType.toString(),
|
||||
status: {
|
||||
in: [PoolAccountDepositStatus.DETECTED, PoolAccountDepositStatus.CONFIRMING],
|
||||
},
|
||||
},
|
||||
orderBy: { blockNumber: 'asc' },
|
||||
});
|
||||
return records.map(this.mapToDto);
|
||||
}
|
||||
|
||||
async updateConfirmations(
|
||||
id: bigint,
|
||||
confirmations: number,
|
||||
status: PoolAccountDepositStatus,
|
||||
): Promise<void> {
|
||||
await this.prisma.poolAccountDeposit.update({
|
||||
where: { id },
|
||||
data: { confirmations, status },
|
||||
});
|
||||
}
|
||||
|
||||
async updateCreditedStatus(
|
||||
id: bigint,
|
||||
creditedAt: Date,
|
||||
creditReference: string,
|
||||
): Promise<void> {
|
||||
await this.prisma.poolAccountDeposit.update({
|
||||
where: { id },
|
||||
data: {
|
||||
creditedAt,
|
||||
creditReference,
|
||||
status: PoolAccountDepositStatus.CREDITED,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private mapToDto(record: any): PoolAccountDepositDto {
|
||||
return {
|
||||
id: record.id,
|
||||
chainType: record.chainType,
|
||||
txHash: record.txHash,
|
||||
fromAddress: record.fromAddress,
|
||||
toAddress: record.toAddress,
|
||||
poolType: record.poolType as PoolAccountType,
|
||||
tokenContract: record.tokenContract,
|
||||
amount: BigInt(record.amount.toString()),
|
||||
amountFormatted: record.amountFormatted.toString(),
|
||||
blockNumber: record.blockNumber,
|
||||
blockTimestamp: record.blockTimestamp,
|
||||
logIndex: record.logIndex,
|
||||
confirmations: record.confirmations,
|
||||
status: record.status as PoolAccountDepositStatus,
|
||||
creditedAt: record.creditedAt,
|
||||
creditReference: record.creditReference,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -86,6 +86,10 @@ enum TransactionType {
|
|||
// 系统调整
|
||||
ADJUSTMENT // 系统调整
|
||||
INITIAL_INJECT // 初始注入
|
||||
|
||||
// 区块链充值/提现
|
||||
BLOCKCHAIN_DEPOSIT // 区块链充值
|
||||
BLOCKCHAIN_WITHDRAW // 区块链提现
|
||||
}
|
||||
|
||||
// 交易对手方类型
|
||||
|
|
|
|||
|
|
@ -71,14 +71,14 @@ async function main() {
|
|||
{
|
||||
poolType: 'SHARE_POOL_A',
|
||||
name: '积分股池A',
|
||||
balance: new Decimal('10000000000'), // 100亿初始发行量
|
||||
description: '销毁池来源,初始100亿',
|
||||
balance: new Decimal('0'), // 初始余额为0,通过区块链充值
|
||||
description: '销毁池来源(100亿),通过区块链充值',
|
||||
},
|
||||
{
|
||||
poolType: 'SHARE_POOL_B',
|
||||
name: '积分股池B',
|
||||
balance: new Decimal('2000000'), // 200万初始发行量
|
||||
description: '挖矿分配池,初始200万',
|
||||
balance: new Decimal('0'), // 初始余额为0,通过区块链充值
|
||||
description: '挖矿分配池(200万),通过区块链充值',
|
||||
},
|
||||
{
|
||||
poolType: 'BLACK_HOLE_POOL',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@ class BurnToBlackHoleDto {
|
|||
referenceId?: string;
|
||||
}
|
||||
|
||||
class BlockchainWithdrawDto {
|
||||
poolType: PoolAccountType;
|
||||
amount: string;
|
||||
txHash: string;
|
||||
toAddress: string;
|
||||
blockNumber?: string;
|
||||
}
|
||||
|
||||
@ApiTags('Pool Accounts')
|
||||
@Controller('pool-accounts')
|
||||
@ApiBearerAuth()
|
||||
|
|
@ -123,4 +131,21 @@ export class PoolAccountController {
|
|||
referenceId: dto.referenceId,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('blockchain-withdraw')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '记录区块链提现(仅限内网调用)' })
|
||||
@ApiResponse({ status: 200, description: '提现记录成功' })
|
||||
async recordBlockchainWithdraw(@Body() dto: BlockchainWithdrawDto) {
|
||||
await this.poolAccountService.blockchainWithdraw(
|
||||
dto.poolType,
|
||||
new Decimal(dto.amount),
|
||||
{
|
||||
txHash: dto.txHash,
|
||||
toAddress: dto.toAddress,
|
||||
blockNumber: dto.blockNumber,
|
||||
},
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { UserRegisteredConsumer } from '../infrastructure/kafka/consumers/user-r
|
|||
import { MiningDistributionConsumer } from '../infrastructure/kafka/consumers/mining-distribution.consumer';
|
||||
import { BurnConsumer } from '../infrastructure/kafka/consumers/burn.consumer';
|
||||
import { ManualMiningConsumer } from '../infrastructure/kafka/consumers/manual-mining.consumer';
|
||||
import { PoolAccountDepositConsumer } from '../infrastructure/kafka/consumers/pool-account-deposit.consumer';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot()],
|
||||
|
|
@ -27,6 +28,7 @@ import { ManualMiningConsumer } from '../infrastructure/kafka/consumers/manual-m
|
|||
MiningDistributionConsumer,
|
||||
BurnConsumer,
|
||||
ManualMiningConsumer,
|
||||
PoolAccountDepositConsumer,
|
||||
],
|
||||
providers: [
|
||||
// Services
|
||||
|
|
|
|||
|
|
@ -488,4 +488,91 @@ export class PoolAccountService {
|
|||
) {
|
||||
return this.poolAccountRepo.getTransactions(poolType, options);
|
||||
}
|
||||
|
||||
// ============ 区块链充值/提现 ============
|
||||
|
||||
/**
|
||||
* 区块链充值入账
|
||||
* 由 Kafka 消费者调用,处理 mining-blockchain-service 发布的充值确认事件
|
||||
*/
|
||||
async blockchainDeposit(
|
||||
poolType: PoolAccountType,
|
||||
amount: Decimal,
|
||||
metadata: {
|
||||
txHash: string;
|
||||
fromAddress: string;
|
||||
blockNumber: string;
|
||||
depositId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const poolName = poolType === 'SHARE_POOL_A' ? '100亿销毁池' : '200万挖矿池';
|
||||
|
||||
const memo = `区块链充值入账, ${poolName}, 金额${amount.toFixed(8)} fUSDT, 交易哈希${metadata.txHash.slice(0, 10)}..., 来源${metadata.fromAddress.slice(0, 10)}...`;
|
||||
|
||||
await this.poolAccountRepo.updateBalanceWithTransaction(
|
||||
poolType,
|
||||
amount,
|
||||
{
|
||||
transactionType: 'BLOCKCHAIN_DEPOSIT',
|
||||
counterpartyType: 'EXTERNAL',
|
||||
counterpartyAddress: metadata.fromAddress,
|
||||
referenceId: metadata.depositId,
|
||||
referenceType: 'BLOCKCHAIN_DEPOSIT',
|
||||
txHash: metadata.txHash,
|
||||
memo,
|
||||
metadata: {
|
||||
txHash: metadata.txHash,
|
||||
fromAddress: metadata.fromAddress,
|
||||
blockNumber: metadata.blockNumber,
|
||||
depositId: metadata.depositId,
|
||||
amount: amount.toString(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Blockchain deposit: ${amount.toFixed(8)} fUSDT to ${poolType} (tx: ${metadata.txHash.slice(0, 10)}...)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 区块链提现扣减
|
||||
* 由 Admin API 调用,管理员发起提现操作
|
||||
*/
|
||||
async blockchainWithdraw(
|
||||
poolType: PoolAccountType,
|
||||
amount: Decimal,
|
||||
metadata: {
|
||||
txHash: string;
|
||||
toAddress: string;
|
||||
blockNumber?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const poolName = poolType === 'SHARE_POOL_A' ? '100亿销毁池' : '200万挖矿池';
|
||||
|
||||
const memo = `区块链提现, ${poolName}, 金额${amount.toFixed(8)} fUSDT, 目标${metadata.toAddress.slice(0, 10)}..., 交易哈希${metadata.txHash.slice(0, 10)}...`;
|
||||
|
||||
await this.poolAccountRepo.updateBalanceWithTransaction(
|
||||
poolType,
|
||||
amount.negated(),
|
||||
{
|
||||
transactionType: 'BLOCKCHAIN_WITHDRAW',
|
||||
counterpartyType: 'EXTERNAL',
|
||||
counterpartyAddress: metadata.toAddress,
|
||||
referenceType: 'BLOCKCHAIN_WITHDRAW',
|
||||
txHash: metadata.txHash,
|
||||
memo,
|
||||
metadata: {
|
||||
txHash: metadata.txHash,
|
||||
toAddress: metadata.toAddress,
|
||||
blockNumber: metadata.blockNumber,
|
||||
amount: amount.toString(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Blockchain withdraw: ${amount.toFixed(8)} fUSDT from ${poolType} (tx: ${metadata.txHash.slice(0, 10)}...)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
import { Controller, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { EventPattern, Payload } from '@nestjs/microservices';
|
||||
import Decimal from 'decimal.js';
|
||||
import { RedisService } from '../../redis/redis.service';
|
||||
import { ProcessedEventRepository } from '../../persistence/repositories/processed-event.repository';
|
||||
import { PoolAccountService } from '../../../application/services/pool-account.service';
|
||||
import { PoolAccountType } from '@prisma/client';
|
||||
|
||||
// 4小时 TTL(秒)
|
||||
const IDEMPOTENCY_TTL_SECONDS = 4 * 60 * 60;
|
||||
|
||||
// 池类型到 PoolAccountType 的映射
|
||||
const POOL_TYPE_MAP: Record<string, PoolAccountType> = {
|
||||
'BURN_POOL': 'SHARE_POOL_A',
|
||||
'MINING_POOL': 'SHARE_POOL_B',
|
||||
};
|
||||
|
||||
interface PoolAccountDepositConfirmedPayload {
|
||||
depositId: string;
|
||||
chainType: string;
|
||||
txHash: string;
|
||||
fromAddress: string;
|
||||
toAddress: string;
|
||||
poolType: 'BURN_POOL' | 'MINING_POOL';
|
||||
tokenContract: string;
|
||||
amount: string;
|
||||
amountFormatted: string;
|
||||
confirmations: number;
|
||||
blockNumber: string;
|
||||
blockTimestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 池账户充值事件消费者
|
||||
* 监听 mining-blockchain-service 发布的池账户充值确认事件
|
||||
*/
|
||||
@Controller()
|
||||
export class PoolAccountDepositConsumer implements OnModuleInit {
|
||||
private readonly logger = new Logger(PoolAccountDepositConsumer.name);
|
||||
|
||||
constructor(
|
||||
private readonly redis: RedisService,
|
||||
private readonly processedEventRepo: ProcessedEventRepository,
|
||||
private readonly poolAccountService: PoolAccountService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.logger.log('PoolAccountDepositConsumer initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理池账户充值确认事件
|
||||
* Topic: mining_blockchain.pool_account.deposits
|
||||
*/
|
||||
@EventPattern('mining_blockchain.pool_account.deposits')
|
||||
async handlePoolAccountDeposit(@Payload() message: any): Promise<void> {
|
||||
const eventData = message.value || message;
|
||||
const eventId = eventData.eventId || message.eventId;
|
||||
|
||||
if (!eventId) {
|
||||
this.logger.warn('Received pool deposit event without eventId, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.processDeposit(eventId, eventData.payload || eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理充值入账
|
||||
*/
|
||||
private async processDeposit(
|
||||
eventId: string,
|
||||
payload: PoolAccountDepositConfirmedPayload,
|
||||
): Promise<void> {
|
||||
this.logger.debug(`Processing pool deposit event: ${eventId}`);
|
||||
|
||||
// 幂等性检查
|
||||
if (await this.isEventProcessed(eventId)) {
|
||||
this.logger.debug(`Event ${eventId} already processed, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const amount = new Decimal(payload.amountFormatted);
|
||||
|
||||
if (amount.isZero() || amount.isNegative()) {
|
||||
this.logger.warn(`Invalid deposit amount: ${payload.amountFormatted}, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 映射池类型
|
||||
const poolType = POOL_TYPE_MAP[payload.poolType];
|
||||
if (!poolType) {
|
||||
this.logger.error(`Unknown pool type: ${payload.poolType}, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用 pool account service 进行入账
|
||||
await this.poolAccountService.blockchainDeposit(poolType, amount, {
|
||||
txHash: payload.txHash,
|
||||
fromAddress: payload.fromAddress,
|
||||
blockNumber: payload.blockNumber,
|
||||
depositId: payload.depositId,
|
||||
});
|
||||
|
||||
// 标记为已处理
|
||||
await this.markEventProcessed(eventId, 'pool_account.deposit.confirmed');
|
||||
|
||||
const poolName = payload.poolType === 'BURN_POOL' ? '100亿销毁池' : '200万挖矿池';
|
||||
this.logger.log(
|
||||
`Pool deposit processed: ${amount.toFixed(8)} fUSDT to ${poolName} (tx: ${payload.txHash.slice(0, 10)}...)`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to process pool deposit event ${eventId}`,
|
||||
error instanceof Error ? error.stack : error,
|
||||
);
|
||||
throw error; // 让 Kafka 重试
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等性检查 - Redis + DB 双重检查
|
||||
*/
|
||||
private async isEventProcessed(eventId: string): Promise<boolean> {
|
||||
const redisKey = `processed-event:pool-deposit:${eventId}`;
|
||||
|
||||
const cached = await this.redis.get(redisKey);
|
||||
if (cached) return true;
|
||||
|
||||
const dbRecord = await this.processedEventRepo.findByEventId(eventId);
|
||||
if (dbRecord) {
|
||||
await this.redis.set(redisKey, '1', IDEMPOTENCY_TTL_SECONDS);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记事件为已处理
|
||||
*/
|
||||
private async markEventProcessed(
|
||||
eventId: string,
|
||||
eventType: string,
|
||||
): Promise<void> {
|
||||
await this.processedEventRepo.create({
|
||||
eventId,
|
||||
eventType,
|
||||
sourceService: 'mining-blockchain-service',
|
||||
});
|
||||
|
||||
const redisKey = `processed-event:pool-deposit:${eventId}`;
|
||||
await this.redis.set(redisKey, '1', IDEMPOTENCY_TTL_SECONDS);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +1,6 @@
|
|||
import { apiClient } from '@/lib/api/client';
|
||||
import axios from 'axios';
|
||||
import type { SystemConfig } from '@/types/config';
|
||||
|
||||
const tradingBaseURL = '/api/trading';
|
||||
|
||||
const tradingClient = axios.create({
|
||||
baseURL: tradingBaseURL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
tradingClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : null;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
tradingClient.interceptors.response.use(
|
||||
(response) => {
|
||||
if (response.data && response.data.data !== undefined) {
|
||||
response.data = response.data.data;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('admin_token');
|
||||
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export interface ContributionSyncStatus {
|
||||
isSynced: boolean;
|
||||
miningNetworkTotal: string;
|
||||
|
|
@ -114,13 +74,13 @@ export const configsApi = {
|
|||
await apiClient.post('/configs/p2p-transfer-fee', { fee, minTransferAmount });
|
||||
},
|
||||
|
||||
// 获取池账户余额
|
||||
// 获取池账户余额(通过 mining-admin-service 代理)
|
||||
getPoolAccountBalance: async (walletName: string): Promise<PoolAccountBalance> => {
|
||||
const response = await tradingClient.get(`/admin/pool-accounts/${walletName}/balance`);
|
||||
return response.data;
|
||||
const response = await apiClient.get(`/admin/pool-accounts/${walletName}/balance`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// 区块链提现(从池账户)
|
||||
// 区块链提现(从池账户,通过 mining-admin-service 代理)
|
||||
poolAccountBlockchainWithdraw: async (walletName: string, toAddress: string, amount: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
|
|
@ -129,7 +89,7 @@ export const configsApi = {
|
|||
newBalance?: string;
|
||||
error?: string;
|
||||
}> => {
|
||||
const response = await tradingClient.post(`/admin/pool-accounts/${walletName}/blockchain-withdraw`, { toAddress, amount });
|
||||
return response.data;
|
||||
const response = await apiClient.post(`/admin/pool-accounts/${walletName}/blockchain-withdraw`, { toAddress, amount });
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue