feat(c2c): 实现C2C Bot自动交易系统

- 创建独立的 mining-blockchain-service 服务 (基于 blockchain-service)
- 添加 dUSDT 转账接口供 C2C Bot 调用
- 实现 C2cBotService 自动购买卖单
- 实现 C2cBotScheduler 每10秒扫描待处理卖单
- 添加 BlockchainClient 和 IdentityClient 客户端
- 更新 C2cOrder 模型添加 Bot 购买相关字段
- 使用 MPC 热钱包签名交易

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-28 07:10:25 -08:00
parent cec98e9d3e
commit 042a52550b
171 changed files with 24706 additions and 1 deletions

View File

@ -0,0 +1,13 @@
node_modules
dist
.git
.gitignore
.env
.env.local
*.md
.vscode
.idea
coverage
test
*.log
npm-debug.log

View File

@ -0,0 +1,77 @@
# =============================================================================
# Mining Blockchain Service - Production Environment Configuration
# =============================================================================
#
# Role: dUSDT (绿积分) transfer for C2C Bot
#
# Responsibilities:
# - Transfer dUSDT from MPC hot wallet to user's Kava address
# - Query hot wallet balance
#
# Setup:
# 1. Copy to .env: cp .env.example .env
# 2. In Docker Compose mode, most values are overridden by docker-compose.yml
# =============================================================================
# =============================================================================
# Application
# =============================================================================
NODE_ENV=production
PORT=3020
SERVICE_NAME=mining-blockchain-service
API_PREFIX=api/v1
# =============================================================================
# Database (PostgreSQL)
# =============================================================================
DATABASE_URL=postgresql://rwa_user:your_password@localhost:5432/rwa_mining_blockchain?schema=public
# =============================================================================
# Redis
# =============================================================================
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=15
REDIS_PASSWORD=
# =============================================================================
# Kafka (用于 MPC 签名通信)
# =============================================================================
KAFKA_BROKERS=localhost:9092
KAFKA_CLIENT_ID=mining-blockchain-service
KAFKA_GROUP_ID=mining-blockchain-service-group
# =============================================================================
# Blockchain - KAVA (EVM-compatible Cosmos chain)
# =============================================================================
# Official KAVA EVM RPC endpoint
KAVA_RPC_URL=https://evm.kava.io
KAVA_CHAIN_ID=2222
# dUSDT (绿积分) 合约地址 - Durian USDT, 精度6位
# 合约链接: https://kavascan.com/address/0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3
KAVA_USDT_CONTRACT=0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3
# =============================================================================
# dUSDT Transfer Configuration
# =============================================================================
# 等待交易确认的最大时间(秒)
TX_CONFIRMATION_TIMEOUT=120
# =============================================================================
# MPC Hot Wallet (C2C Bot 热钱包)
# =============================================================================
# MPC 服务地址
MPC_SERVICE_URL=http://localhost:3013
# C2C Bot 热钱包用户名MPC 系统中的标识,需要预先通过 keygen 创建)
HOT_WALLET_USERNAME=c2c-bot-wallet
# C2C Bot 热钱包地址(从 MPC 公钥派生的 EVM 地址)
# 在 MPC keygen 完成后,从公钥计算得出
HOT_WALLET_ADDRESS=
# =============================================================================
# Logging
# =============================================================================
# Options: debug, info, warn, error
LOG_LEVEL=info

View File

@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
},
};

View File

@ -0,0 +1,32 @@
# Dependencies
node_modules/
# Build
dist/
# Environment
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
logs/
*.log
npm-debug.log*
# Test
coverage/
# Prisma
prisma/*.db
prisma/*.db-journal

View File

@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"printWidth": 100
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,82 @@
# =============================================================================
# Blockchain Service Dockerfile
# =============================================================================
# Build stage - use Alpine for smaller build context
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY tsconfig*.json ./
COPY nest-cli.json ./
# Copy Prisma schema
COPY prisma ./prisma/
# Install dependencies
RUN npm ci
# Generate Prisma client (dummy DATABASE_URL for build time only)
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate
# Copy source code
COPY src ./src
# Build TypeScript
RUN npm run build
# Verify build output exists
RUN ls -la dist/ && test -f dist/main.js
# Production stage - use Debian slim for OpenSSL compatibility
FROM node:20-slim
# Create non-root user with home directory (npm cache needs it)
RUN groupadd -g 1001 nodejs && \
useradd -u 1001 -g nodejs -m nestjs
# Install OpenSSL and curl for health checks
RUN apt-get update && apt-get install -y --no-install-recommends \
openssl \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create app directory with correct ownership
RUN mkdir -p /app && chown nestjs:nodejs /app
WORKDIR /app
# Switch to non-root user before installing dependencies
USER nestjs
# Install production dependencies only
COPY --chown=nestjs:nodejs package*.json ./
RUN npm ci --only=production
# Copy Prisma schema and generate client
COPY --chown=nestjs:nodejs prisma ./prisma/
RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate
# Copy built files
COPY --chown=nestjs:nodejs --from=builder /app/dist ./dist
# Create startup script that syncs schema before starting the app
RUN echo '#!/bin/sh\n\
set -e\n\
echo "Syncing database schema..."\n\
npx prisma db push --skip-generate --accept-data-loss\n\
echo "Starting application..."\n\
exec node dist/main.js\n' > /app/start.sh && chmod +x /app/start.sh
ENV NODE_ENV=production
# Expose port
EXPOSE 3012
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD curl -f http://localhost:3012/api/v1/health || exit 1
# Start service with migration
CMD ["/app/start.sh"]

View File

@ -0,0 +1,59 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/**
* @title TestUSDT
* @dev USDT mint
*
* :
* 1. 使 Remix IDE (https://remix.ethereum.org)
* 2. MetaMask BSC Testnet (Chain ID: 97) KAVA Testnet (Chain ID: 2221)
* 3.
* 4. mint()
*/
contract TestUSDT is ERC20, Ownable {
uint8 private _decimals;
constructor() ERC20("Test USDT", "USDT") Ownable(msg.sender) {
_decimals = 6; // USDT 6
// 1,000,000 USDT
_mint(msg.sender, 1_000_000 * 10 ** _decimals);
}
function decimals() public view virtual override returns (uint8) {
return _decimals;
}
/**
* @dev mint (使!)
* @param amount (: 10^6, 1000 USDT = 1000000000)
*/
function mint(uint256 amount) external {
_mint(msg.sender, amount);
}
/**
* @dev 便: USDT
* @param usdtAmount USDT ( 1000 1000 USDT)
*/
function mintUsdt(uint256 usdtAmount) external {
_mint(msg.sender, usdtAmount * 10 ** _decimals);
}
/**
* @dev Owner mint
*/
function mintTo(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
/**
* @dev : 10000 USDT
*/
function faucet() external {
_mint(msg.sender, 10_000 * 10 ** _decimals);
}
}

View File

@ -0,0 +1,142 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @title TestUSDT (Flattened)
* @dev USDT Remix
*
* :
* - BSC Testnet: Chain ID 97, RPC: https://data-seed-prebsc-1-s1.binance.org:8545
* - KAVA Testnet: Chain ID 2221, RPC: https://evm.testnet.kava.io
*/
abstract contract Context {
function _msgSender() internal view virtual returns (address) {
return msg.sender;
}
}
interface IERC20 {
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
interface IERC20Metadata is IERC20 {
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function decimals() external view returns (uint8);
}
abstract contract ERC20 is Context, IERC20, IERC20Metadata {
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
uint256 private _totalSupply;
string private _name;
string private _symbol;
constructor(string memory name_, string memory symbol_) {
_name = name_;
_symbol = symbol_;
}
function name() public view virtual returns (string memory) { return _name; }
function symbol() public view virtual returns (string memory) { return _symbol; }
function decimals() public view virtual returns (uint8) { return 18; }
function totalSupply() public view virtual returns (uint256) { return _totalSupply; }
function balanceOf(address account) public view virtual returns (uint256) { return _balances[account]; }
function transfer(address to, uint256 value) public virtual returns (bool) {
_transfer(_msgSender(), to, value);
return true;
}
function allowance(address owner, address spender) public view virtual returns (uint256) {
return _allowances[owner][spender];
}
function approve(address spender, uint256 value) public virtual returns (bool) {
_approve(_msgSender(), spender, value);
return true;
}
function transferFrom(address from, address to, uint256 value) public virtual returns (bool) {
address spender = _msgSender();
uint256 currentAllowance = allowance(from, spender);
if (currentAllowance != type(uint256).max) {
require(currentAllowance >= value, "ERC20: insufficient allowance");
unchecked { _approve(from, spender, currentAllowance - value); }
}
_transfer(from, to, value);
return true;
}
function _transfer(address from, address to, uint256 value) internal {
require(from != address(0), "ERC20: transfer from zero address");
require(to != address(0), "ERC20: transfer to zero address");
uint256 fromBalance = _balances[from];
require(fromBalance >= value, "ERC20: insufficient balance");
unchecked {
_balances[from] = fromBalance - value;
_balances[to] += value;
}
emit Transfer(from, to, value);
}
function _mint(address account, uint256 value) internal {
require(account != address(0), "ERC20: mint to zero address");
_totalSupply += value;
unchecked { _balances[account] += value; }
emit Transfer(address(0), account, value);
}
function _approve(address owner, address spender, uint256 value) internal {
require(owner != address(0) && spender != address(0), "ERC20: zero address");
_allowances[owner][spender] = value;
emit Approval(owner, spender, value);
}
}
contract TestUSDT is ERC20 {
uint8 private constant _decimals = 6;
address public owner;
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
constructor() ERC20("Test USDT", "USDT") {
owner = msg.sender;
_mint(msg.sender, 1_000_000 * 10 ** _decimals);
}
function decimals() public pure override returns (uint8) {
return _decimals;
}
/// @dev mint ()
function mint(uint256 amount) external {
_mint(msg.sender, amount);
}
/// @dev 便: USDT
function mintUsdt(uint256 usdtAmount) external {
_mint(msg.sender, usdtAmount * 10 ** _decimals);
}
/// @dev Owner mint
function mintTo(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
/// @dev : 10000 USDT
function faucet() external {
_mint(msg.sender, 10_000 * 10 ** _decimals);
}
}

View File

@ -0,0 +1,78 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
/**
* @title EnergyUSDT
* @dev Fixed supply ERC-20 token - NO MINTING CAPABILITY
* Total Supply: 10,002,000,000 (100.02 Billion) tokens with 6 decimals (matching USDT)
*
* IMPORTANT: This contract has NO mint function and NO way to increase supply.
* All tokens are minted to the deployer at construction time.
*/
contract EnergyUSDT {
string public constant name = "Energy USDT";
string public constant symbol = "eUSDT";
uint8 public constant decimals = 6;
// Fixed total supply: 100.02 billion tokens (10,002,000,000 * 10^6)
uint256 public constant totalSupply = 10_002_000_000 * 10**6;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
/**
* @dev Constructor - mints entire fixed supply to deployer
* No mint function exists - supply is permanently fixed
*/
constructor() {
_balances[msg.sender] = totalSupply;
emit Transfer(address(0), msg.sender, totalSupply);
}
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
function transfer(address to, uint256 amount) public returns (bool) {
require(to != address(0), "Transfer to zero address");
require(_balances[msg.sender] >= amount, "Insufficient balance");
unchecked {
_balances[msg.sender] -= amount;
_balances[to] += amount;
}
emit Transfer(msg.sender, to, amount);
return true;
}
function allowance(address owner, address spender) public view returns (uint256) {
return _allowances[owner][spender];
}
function approve(address spender, uint256 amount) public returns (bool) {
require(spender != address(0), "Approve to zero address");
_allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(from != address(0), "Transfer from zero address");
require(to != address(0), "Transfer to zero address");
require(_balances[from] >= amount, "Insufficient balance");
require(_allowances[from][msg.sender] >= amount, "Insufficient allowance");
unchecked {
_balances[from] -= amount;
_balances[to] += amount;
_allowances[from][msg.sender] -= amount;
}
emit Transfer(from, to, amount);
return true;
}
}

View File

@ -0,0 +1,81 @@
# eUSDT (Energy USDT)
## 代币信息
| 属性 | 值 |
|------|-----|
| 名称 | Energy USDT |
| 符号 | eUSDT |
| 精度 | 6 decimals |
| 总供应量 | 10,002,000,000 (100.02亿) |
| 标准 | ERC-20 |
| 部署链 | KAVA Mainnet (Chain ID: 2222) |
## 合约特性
- **固定供应量**100.02亿代币,部署时全部铸造给部署者
- **不可增发**:合约中没有 mint 函数,供应量永久固定
- **不可销毁**:合约层面无销毁功能
- **不可升级**:合约逻辑永久固定
- **标准ERC-20**完全兼容所有主流钱包和DEX
## 部署步骤
### 1. 安装依赖
```bash
cd backend/services/blockchain-service/contracts/eUSDT
npm install
```
### 2. 编译合约
```bash
node compile.mjs
```
编译后会在 `build/` 目录生成:
- `EnergyUSDT.abi` - 合约ABI
- `EnergyUSDT.bin` - 合约字节码
### 3. 部署合约
确保部署账户有足够的 KAVA 支付 gas 费(约 0.02 KAVA
```bash
node deploy.mjs
```
## 合约函数
| 函数 | 说明 |
|------|------|
| `name()` | 返回 "Energy USDT" |
| `symbol()` | 返回 "eUSDT" |
| `decimals()` | 返回 6 |
| `totalSupply()` | 返回 10,002,000,000 * 10^6 |
| `balanceOf(address)` | 查询账户余额 |
| `transfer(address, uint256)` | 转账 |
| `approve(address, uint256)` | 授权额度 |
| `transferFrom(address, address, uint256)` | 代理转账 |
| `allowance(address, address)` | 查询授权额度 |
## 事件
| 事件 | 说明 |
|------|------|
| `Transfer(from, to, value)` | 转账事件 |
| `Approval(owner, spender, value)` | 授权事件 |
## 部署信息
| 网络 | 合约地址 | 区块浏览器 |
|------|---------|-----------|
| KAVA Mainnet | `0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931` | https://kavascan.com/address/0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931 |
**部署详情:**
- 部署者/代币拥有者:`0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E`
- 私钥:`0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a`
- 初始持有量10,002,000,000 eUSDT全部代币
- 交易哈希:`0x5bebaa4a35378438ba5c891972024a1766935d2e01397a33502aa99e956a6b19`
- 部署时间2026-01-19

View File

@ -0,0 +1,51 @@
import solc from 'solc';
import fs from 'fs';
const source = fs.readFileSync('EnergyUSDT.sol', 'utf8');
const input = {
language: 'Solidity',
sources: {
'EnergyUSDT.sol': {
content: source
}
},
settings: {
optimizer: {
enabled: true,
runs: 200
},
evmVersion: 'paris', // Use paris to avoid PUSH0
outputSelection: {
'*': {
'*': ['abi', 'evm.bytecode']
}
}
}
};
const output = JSON.parse(solc.compile(JSON.stringify(input)));
if (output.errors) {
output.errors.forEach(err => {
console.log(err.formattedMessage);
});
// Check for actual errors (not just warnings)
const hasErrors = output.errors.some(err => err.severity === 'error');
if (hasErrors) {
process.exit(1);
}
}
const contract = output.contracts['EnergyUSDT.sol']['EnergyUSDT'];
const bytecode = contract.evm.bytecode.object;
const abi = contract.abi;
fs.mkdirSync('build', { recursive: true });
fs.writeFileSync('build/EnergyUSDT.bin', bytecode);
fs.writeFileSync('build/EnergyUSDT.abi', JSON.stringify(abi, null, 2));
console.log('Compiled successfully!');
console.log('Bytecode length:', bytecode.length);
console.log('ABI functions:', abi.filter(x => x.type === 'function').map(x => x.name).join(', '));

View File

@ -0,0 +1,86 @@
import { ethers } from 'ethers';
import fs from 'fs';
// Same deployer account as dUSDT
const PRIVATE_KEY = '0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a';
const RPC_URL = 'https://evm.kava.io';
// Contract bytecode
const BYTECODE = '0x' + fs.readFileSync('build/EnergyUSDT.bin', 'utf8');
const ABI = JSON.parse(fs.readFileSync('build/EnergyUSDT.abi', 'utf8'));
async function deploy() {
// Connect to Kava mainnet
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
console.log('Deployer address:', wallet.address);
// Check balance
const balance = await provider.getBalance(wallet.address);
console.log('Balance:', ethers.formatEther(balance), 'KAVA');
if (parseFloat(ethers.formatEther(balance)) < 0.01) {
console.error('Insufficient KAVA balance for deployment!');
process.exit(1);
}
// Get network info
const network = await provider.getNetwork();
console.log('Chain ID:', network.chainId.toString());
// Create contract factory
const factory = new ethers.ContractFactory(ABI, BYTECODE, wallet);
console.log('Deploying EnergyUSDT (eUSDT) contract...');
// Deploy
const contract = await factory.deploy();
console.log('Transaction hash:', contract.deploymentTransaction().hash);
// Wait for deployment
console.log('Waiting for confirmation...');
await contract.waitForDeployment();
const contractAddress = await contract.getAddress();
console.log('Contract deployed at:', contractAddress);
// Verify deployment
console.log('\nVerifying deployment...');
const name = await contract.name();
const symbol = await contract.symbol();
const decimals = await contract.decimals();
const totalSupply = await contract.totalSupply();
const ownerBalance = await contract.balanceOf(wallet.address);
console.log('Token name:', name);
console.log('Token symbol:', symbol);
console.log('Decimals:', decimals.toString());
console.log('Total supply:', ethers.formatUnits(totalSupply, 6), 'eUSDT');
console.log('Owner balance:', ethers.formatUnits(ownerBalance, 6), 'eUSDT');
console.log('\n=== DEPLOYMENT COMPLETE ===');
console.log('Contract Address:', contractAddress);
console.log('Explorer:', `https://kavascan.com/address/${contractAddress}`);
// Save deployment info
const deploymentInfo = {
network: 'KAVA Mainnet',
chainId: 2222,
contractAddress,
deployer: wallet.address,
transactionHash: contract.deploymentTransaction().hash,
deployedAt: new Date().toISOString(),
token: {
name,
symbol,
decimals: decimals.toString(),
totalSupply: totalSupply.toString()
}
};
fs.writeFileSync('deployment.json', JSON.stringify(deploymentInfo, null, 2));
console.log('\nDeployment info saved to deployment.json');
}
deploy().catch(console.error);

View File

@ -0,0 +1,14 @@
{
"network": "KAVA Mainnet",
"chainId": 2222,
"contractAddress": "0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931",
"deployer": "0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E",
"transactionHash": "0x5bebaa4a35378438ba5c891972024a1766935d2e01397a33502aa99e956a6b19",
"deployedAt": "2026-01-19T13:25:28.071Z",
"token": {
"name": "Energy USDT",
"symbol": "eUSDT",
"decimals": "6",
"totalSupply": "10002000000000000"
}
}

View File

@ -0,0 +1,222 @@
{
"name": "eusdt-contract",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "eusdt-contract",
"version": "1.0.0",
"dependencies": {
"ethers": "^6.9.0",
"solc": "^0.8.19"
}
},
"node_modules/@adraffy/ens-normalize": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz",
"integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==",
"license": "MIT"
},
"node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@types/node": {
"version": "22.7.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
"integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/aes-js": {
"version": "4.0.0-beta.5",
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
"integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
"license": "MIT"
},
"node_modules/command-exists": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
"integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==",
"license": "MIT"
},
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ethers": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz",
"integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/ethers-io/"
},
{
"type": "individual",
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"dependencies": {
"@adraffy/ens-normalize": "1.10.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.2",
"@types/node": "22.7.5",
"aes-js": "4.0.0-beta.5",
"tslib": "2.7.0",
"ws": "8.17.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
"license": "MIT"
},
"node_modules/memorystream": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
"integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==",
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/os-tmpdir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/solc": {
"version": "0.8.19",
"resolved": "https://registry.npmjs.org/solc/-/solc-0.8.19.tgz",
"integrity": "sha512-yqurS3wzC4LdEvmMobODXqprV4MYJcVtinuxgrp61ac8K2zz40vXA0eSAskSHPgv8dQo7Nux39i3QBsHx4pqyA==",
"license": "MIT",
"dependencies": {
"command-exists": "^1.2.8",
"commander": "^8.1.0",
"follow-redirects": "^1.12.1",
"js-sha3": "0.8.0",
"memorystream": "^0.3.1",
"semver": "^5.5.0",
"tmp": "0.0.33"
},
"bin": {
"solcjs": "solc.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
"license": "MIT",
"dependencies": {
"os-tmpdir": "~1.0.2"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/tslib": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"license": "0BSD"
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@ -0,0 +1,14 @@
{
"name": "eusdt-contract",
"version": "1.0.0",
"type": "module",
"description": "Energy USDT (eUSDT) ERC-20 Token Contract",
"scripts": {
"compile": "node compile.mjs",
"deploy": "node deploy.mjs"
},
"dependencies": {
"ethers": "^6.9.0",
"solc": "^0.8.19"
}
}

View File

@ -0,0 +1,78 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
/**
* @title FutureUSDT
* @dev Fixed supply ERC-20 token - NO MINTING CAPABILITY
* Total Supply: 1,000,000,000,000 (1 Trillion) tokens with 6 decimals (matching USDT)
*
* IMPORTANT: This contract has NO mint function and NO way to increase supply.
* All tokens are minted to the deployer at construction time.
*/
contract FutureUSDT {
string public constant name = "Future USDT";
string public constant symbol = "fUSDT";
uint8 public constant decimals = 6;
// Fixed total supply: 1 trillion tokens (1,000,000,000,000 * 10^6)
uint256 public constant totalSupply = 1_000_000_000_000 * 10**6;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
/**
* @dev Constructor - mints entire fixed supply to deployer
* No mint function exists - supply is permanently fixed
*/
constructor() {
_balances[msg.sender] = totalSupply;
emit Transfer(address(0), msg.sender, totalSupply);
}
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
function transfer(address to, uint256 amount) public returns (bool) {
require(to != address(0), "Transfer to zero address");
require(_balances[msg.sender] >= amount, "Insufficient balance");
unchecked {
_balances[msg.sender] -= amount;
_balances[to] += amount;
}
emit Transfer(msg.sender, to, amount);
return true;
}
function allowance(address owner, address spender) public view returns (uint256) {
return _allowances[owner][spender];
}
function approve(address spender, uint256 amount) public returns (bool) {
require(spender != address(0), "Approve to zero address");
_allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(from != address(0), "Transfer from zero address");
require(to != address(0), "Transfer to zero address");
require(_balances[from] >= amount, "Insufficient balance");
require(_allowances[from][msg.sender] >= amount, "Insufficient allowance");
unchecked {
_balances[from] -= amount;
_balances[to] += amount;
_allowances[from][msg.sender] -= amount;
}
emit Transfer(from, to, amount);
return true;
}
}

View File

@ -0,0 +1,81 @@
# fUSDT (Future USDT)
## 代币信息
| 属性 | 值 |
|------|-----|
| 名称 | Future USDT |
| 符号 | fUSDT |
| 精度 | 6 decimals |
| 总供应量 | 1,000,000,000,000 (1万亿) |
| 标准 | ERC-20 |
| 部署链 | KAVA Mainnet (Chain ID: 2222) |
## 合约特性
- **固定供应量**1万亿代币部署时全部铸造给部署者
- **不可增发**:合约中没有 mint 函数,供应量永久固定
- **不可销毁**:合约层面无销毁功能
- **不可升级**:合约逻辑永久固定
- **标准ERC-20**完全兼容所有主流钱包和DEX
## 部署步骤
### 1. 安装依赖
```bash
cd backend/services/blockchain-service/contracts/fUSDT
npm install
```
### 2. 编译合约
```bash
node compile.mjs
```
编译后会在 `build/` 目录生成:
- `FutureUSDT.abi` - 合约ABI
- `FutureUSDT.bin` - 合约字节码
### 3. 部署合约
确保部署账户有足够的 KAVA 支付 gas 费(约 0.02 KAVA
```bash
node deploy.mjs
```
## 合约函数
| 函数 | 说明 |
|------|------|
| `name()` | 返回 "Future USDT" |
| `symbol()` | 返回 "fUSDT" |
| `decimals()` | 返回 6 |
| `totalSupply()` | 返回 1,000,000,000,000 * 10^6 |
| `balanceOf(address)` | 查询账户余额 |
| `transfer(address, uint256)` | 转账 |
| `approve(address, uint256)` | 授权额度 |
| `transferFrom(address, address, uint256)` | 代理转账 |
| `allowance(address, address)` | 查询授权额度 |
## 事件
| 事件 | 说明 |
|------|------|
| `Transfer(from, to, value)` | 转账事件 |
| `Approval(owner, spender, value)` | 授权事件 |
## 部署信息
| 网络 | 合约地址 | 区块浏览器 |
|------|---------|-----------|
| KAVA Mainnet | `0x14dc4f7d3E4197438d058C3D156dd9826A161134` | https://kavascan.com/address/0x14dc4f7d3E4197438d058C3D156dd9826A161134 |
**部署详情:**
- 部署者/代币拥有者:`0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E`
- 私钥:`0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a`
- 初始持有量1,000,000,000,000 fUSDT全部代币
- 交易哈希:`0x071f535971bc3a134dd26c182b6f05c53f0c3783e91fe6ef471d6c914e4cdb06`
- 部署时间2026-01-19

View File

@ -0,0 +1,51 @@
import solc from 'solc';
import fs from 'fs';
const source = fs.readFileSync('FutureUSDT.sol', 'utf8');
const input = {
language: 'Solidity',
sources: {
'FutureUSDT.sol': {
content: source
}
},
settings: {
optimizer: {
enabled: true,
runs: 200
},
evmVersion: 'paris', // Use paris to avoid PUSH0
outputSelection: {
'*': {
'*': ['abi', 'evm.bytecode']
}
}
}
};
const output = JSON.parse(solc.compile(JSON.stringify(input)));
if (output.errors) {
output.errors.forEach(err => {
console.log(err.formattedMessage);
});
// Check for actual errors (not just warnings)
const hasErrors = output.errors.some(err => err.severity === 'error');
if (hasErrors) {
process.exit(1);
}
}
const contract = output.contracts['FutureUSDT.sol']['FutureUSDT'];
const bytecode = contract.evm.bytecode.object;
const abi = contract.abi;
fs.mkdirSync('build', { recursive: true });
fs.writeFileSync('build/FutureUSDT.bin', bytecode);
fs.writeFileSync('build/FutureUSDT.abi', JSON.stringify(abi, null, 2));
console.log('Compiled successfully!');
console.log('Bytecode length:', bytecode.length);
console.log('ABI functions:', abi.filter(x => x.type === 'function').map(x => x.name).join(', '));

View File

@ -0,0 +1,86 @@
import { ethers } from 'ethers';
import fs from 'fs';
// Same deployer account as dUSDT
const PRIVATE_KEY = '0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a';
const RPC_URL = 'https://evm.kava.io';
// Contract bytecode
const BYTECODE = '0x' + fs.readFileSync('build/FutureUSDT.bin', 'utf8');
const ABI = JSON.parse(fs.readFileSync('build/FutureUSDT.abi', 'utf8'));
async function deploy() {
// Connect to Kava mainnet
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
console.log('Deployer address:', wallet.address);
// Check balance
const balance = await provider.getBalance(wallet.address);
console.log('Balance:', ethers.formatEther(balance), 'KAVA');
if (parseFloat(ethers.formatEther(balance)) < 0.01) {
console.error('Insufficient KAVA balance for deployment!');
process.exit(1);
}
// Get network info
const network = await provider.getNetwork();
console.log('Chain ID:', network.chainId.toString());
// Create contract factory
const factory = new ethers.ContractFactory(ABI, BYTECODE, wallet);
console.log('Deploying FutureUSDT (fUSDT) contract...');
// Deploy
const contract = await factory.deploy();
console.log('Transaction hash:', contract.deploymentTransaction().hash);
// Wait for deployment
console.log('Waiting for confirmation...');
await contract.waitForDeployment();
const contractAddress = await contract.getAddress();
console.log('Contract deployed at:', contractAddress);
// Verify deployment
console.log('\nVerifying deployment...');
const name = await contract.name();
const symbol = await contract.symbol();
const decimals = await contract.decimals();
const totalSupply = await contract.totalSupply();
const ownerBalance = await contract.balanceOf(wallet.address);
console.log('Token name:', name);
console.log('Token symbol:', symbol);
console.log('Decimals:', decimals.toString());
console.log('Total supply:', ethers.formatUnits(totalSupply, 6), 'fUSDT');
console.log('Owner balance:', ethers.formatUnits(ownerBalance, 6), 'fUSDT');
console.log('\n=== DEPLOYMENT COMPLETE ===');
console.log('Contract Address:', contractAddress);
console.log('Explorer:', `https://kavascan.com/address/${contractAddress}`);
// Save deployment info
const deploymentInfo = {
network: 'KAVA Mainnet',
chainId: 2222,
contractAddress,
deployer: wallet.address,
transactionHash: contract.deploymentTransaction().hash,
deployedAt: new Date().toISOString(),
token: {
name,
symbol,
decimals: decimals.toString(),
totalSupply: totalSupply.toString()
}
};
fs.writeFileSync('deployment.json', JSON.stringify(deploymentInfo, null, 2));
console.log('\nDeployment info saved to deployment.json');
}
deploy().catch(console.error);

View File

@ -0,0 +1,14 @@
{
"network": "KAVA Mainnet",
"chainId": 2222,
"contractAddress": "0x14dc4f7d3E4197438d058C3D156dd9826A161134",
"deployer": "0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E",
"transactionHash": "0x071f535971bc3a134dd26c182b6f05c53f0c3783e91fe6ef471d6c914e4cdb06",
"deployedAt": "2026-01-19T13:26:05.111Z",
"token": {
"name": "Future USDT",
"symbol": "fUSDT",
"decimals": "6",
"totalSupply": "1000000000000000000"
}
}

View File

@ -0,0 +1,222 @@
{
"name": "fusdt-contract",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fusdt-contract",
"version": "1.0.0",
"dependencies": {
"ethers": "^6.9.0",
"solc": "^0.8.19"
}
},
"node_modules/@adraffy/ens-normalize": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz",
"integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==",
"license": "MIT"
},
"node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@types/node": {
"version": "22.7.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
"integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/aes-js": {
"version": "4.0.0-beta.5",
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
"integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
"license": "MIT"
},
"node_modules/command-exists": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
"integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==",
"license": "MIT"
},
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ethers": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz",
"integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/ethers-io/"
},
{
"type": "individual",
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"dependencies": {
"@adraffy/ens-normalize": "1.10.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.2",
"@types/node": "22.7.5",
"aes-js": "4.0.0-beta.5",
"tslib": "2.7.0",
"ws": "8.17.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
"license": "MIT"
},
"node_modules/memorystream": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
"integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==",
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/os-tmpdir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/solc": {
"version": "0.8.19",
"resolved": "https://registry.npmjs.org/solc/-/solc-0.8.19.tgz",
"integrity": "sha512-yqurS3wzC4LdEvmMobODXqprV4MYJcVtinuxgrp61ac8K2zz40vXA0eSAskSHPgv8dQo7Nux39i3QBsHx4pqyA==",
"license": "MIT",
"dependencies": {
"command-exists": "^1.2.8",
"commander": "^8.1.0",
"follow-redirects": "^1.12.1",
"js-sha3": "0.8.0",
"memorystream": "^0.3.1",
"semver": "^5.5.0",
"tmp": "0.0.33"
},
"bin": {
"solcjs": "solc.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
"license": "MIT",
"dependencies": {
"os-tmpdir": "~1.0.2"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/tslib": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"license": "0BSD"
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@ -0,0 +1,14 @@
{
"name": "fusdt-contract",
"version": "1.0.0",
"type": "module",
"description": "Future USDT (fUSDT) ERC-20 Token Contract",
"scripts": {
"compile": "node compile.mjs",
"deploy": "node deploy.mjs"
},
"dependencies": {
"ethers": "^6.9.0",
"solc": "^0.8.19"
}
}

View File

@ -0,0 +1,158 @@
#!/bin/bash
# =============================================================================
# Blockchain Service - Individual Deployment Script
# =============================================================================
set -e
SERVICE_NAME="blockchain-service"
CONTAINER_NAME="rwa-blockchain-service"
IMAGE_NAME="services-blockchain-service"
PORT=3012
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[OK]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Get script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICES_DIR="$(dirname "$SCRIPT_DIR")"
# Load environment
if [ -f "$SERVICES_DIR/.env" ]; then
export $(cat "$SERVICES_DIR/.env" | grep -v '^#' | xargs)
fi
case "$1" in
build)
log_info "Building $SERVICE_NAME..."
docker build -t "$IMAGE_NAME" "$SCRIPT_DIR"
log_success "$SERVICE_NAME built successfully"
;;
build-no-cache)
log_info "Building $SERVICE_NAME (no cache)..."
docker build --no-cache -t "$IMAGE_NAME" "$SCRIPT_DIR"
log_success "$SERVICE_NAME built successfully"
;;
start)
log_info "Starting $SERVICE_NAME..."
cd "$SERVICES_DIR"
docker compose up -d "$SERVICE_NAME"
log_success "$SERVICE_NAME started"
;;
stop)
log_info "Stopping $SERVICE_NAME..."
docker stop "$CONTAINER_NAME" 2>/dev/null || true
docker rm "$CONTAINER_NAME" 2>/dev/null || true
log_success "$SERVICE_NAME stopped"
;;
restart)
$0 stop
$0 start
;;
logs)
docker logs -f "$CONTAINER_NAME"
;;
logs-tail)
docker logs --tail 100 "$CONTAINER_NAME"
;;
status)
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
log_success "$SERVICE_NAME is running"
docker ps --filter "name=$CONTAINER_NAME" --format "table {{.Status}}\t{{.Ports}}"
else
log_warn "$SERVICE_NAME is not running"
fi
;;
health)
log_info "Checking health of $SERVICE_NAME..."
if curl -sf "http://localhost:$PORT/health" > /dev/null 2>&1; then
log_success "$SERVICE_NAME is healthy"
else
log_error "$SERVICE_NAME health check failed"
exit 1
fi
;;
migrate)
log_info "Running migrations for $SERVICE_NAME..."
docker exec "$CONTAINER_NAME" npx prisma migrate deploy
log_success "Migrations completed"
;;
migrate-dev)
log_info "Running dev migrations for $SERVICE_NAME..."
docker exec "$CONTAINER_NAME" npx prisma migrate dev
;;
prisma-studio)
log_info "Starting Prisma Studio..."
docker exec -it "$CONTAINER_NAME" npx prisma studio
;;
shell)
log_info "Opening shell in $SERVICE_NAME container..."
docker exec -it "$CONTAINER_NAME" sh
;;
test)
log_info "Running tests for $SERVICE_NAME..."
cd "$SCRIPT_DIR"
npm test
;;
scan-blocks)
log_info "Manually triggering block scan..."
curl -X POST "http://localhost:$PORT/internal/scan-blocks" \
-H "Content-Type: application/json"
;;
check-balance)
if [ -z "$2" ] || [ -z "$3" ]; then
log_error "Usage: $0 check-balance <chain> <address>"
log_info "Example: $0 check-balance KAVA 0x1234..."
exit 1
fi
log_info "Checking balance on $2 for $3..."
curl -s "http://localhost:$PORT/balance?chainType=$2&address=$3" | jq '.'
;;
*)
echo "Usage: $0 {build|build-no-cache|start|stop|restart|logs|logs-tail|status|health|migrate|migrate-dev|prisma-studio|shell|test|scan-blocks|check-balance}"
echo ""
echo "Commands:"
echo " build - Build Docker image"
echo " build-no-cache - Build Docker image without cache"
echo " start - Start the service"
echo " stop - Stop the service"
echo " restart - Restart the service"
echo " logs - Follow logs"
echo " logs-tail - Show last 100 log lines"
echo " status - Show service status"
echo " health - Check service health"
echo " migrate - Run database migrations"
echo " migrate-dev - Run dev migrations"
echo " prisma-studio - Open Prisma Studio"
echo " shell - Open shell in container"
echo " test - Run tests locally"
echo " scan-blocks - Manually trigger block scanning"
echo " check-balance - Check address balance (usage: check-balance <chain> <address>)"
exit 1
;;
esac

View File

@ -0,0 +1,53 @@
# =============================================================================
# Blockchain Service - Docker Compose (Development/Standalone)
# =============================================================================
# For production, use the root docker-compose.yml in ../
#
# For standalone development:
# 1. First start shared infrastructure: cd .. && ./deploy.sh up postgres redis kafka
# 2. Then: docker compose up -d --build
# =============================================================================
services:
blockchain-service:
build:
context: .
dockerfile: Dockerfile
container_name: rwa-blockchain-service
ports:
- "3012:3012"
environment:
# Application
NODE_ENV: production
APP_PORT: 3012
API_PREFIX: api/v1
# Database (shared PostgreSQL)
DATABASE_URL: postgresql://rwa_user:rwa_secure_password@rwa-postgres:5432/rwa_blockchain?schema=public
# Redis (shared)
REDIS_HOST: rwa-redis
REDIS_PORT: 6379
REDIS_DB: 11
# Kafka (shared)
KAFKA_BROKERS: rwa-kafka:29092
KAFKA_CLIENT_ID: blockchain-service
KAFKA_GROUP_ID: blockchain-service-group
# Blockchain RPC
KAVA_RPC_URL: https://evm.kava.io
BSC_RPC_URL: https://bsc-dataseed.binance.org
# MPC Hot Wallet (用于提现转账)
MPC_SERVICE_URL: http://rwa-mpc-service:3013
HOT_WALLET_USERNAME: ${HOT_WALLET_USERNAME:-}
HOT_WALLET_ADDRESS: ${HOT_WALLET_ADDRESS:-}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3012/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped
networks:
- rwa-network
networks:
rwa-network:
external: true

View File

@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"plugins": [
{
"name": "@nestjs/swagger",
"options": {
"classValidatorShim": true,
"introspectComments": true
}
}
]
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,105 @@
{
"name": "mining-blockchain-service",
"version": "1.0.0",
"description": "Mining Blockchain Service - dUSDT transfer for C2C Bot",
"author": "RWA Team",
"private": true,
"license": "UNLICENSED",
"prisma": {
"schema": "prisma/schema.prisma",
"seed": "ts-node prisma/seed.ts"
},
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:prod": "prisma migrate deploy",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@nestjs/axios": "^3.0.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/microservices": "^10.0.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.1.17",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"@prisma/client": "^5.7.0",
"@scure/bip32": "^1.3.2",
"@scure/bip39": "^1.6.0",
"bcrypt": "^6.0.0",
"bech32": "^2.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"ethers": "^6.9.0",
"ioredis": "^5.3.2",
"kafkajs": "^2.2.4",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^6.0.0",
"@types/express": "^4.17.17",
"@types/passport-jwt": "^4.0.1",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@types/uuid": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"prisma": "^5.7.0",
"solc": "^0.8.17",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1"
}
}
}

View File

@ -0,0 +1,141 @@
-- CreateTable
CREATE TABLE "monitored_addresses" (
"address_id" BIGSERIAL NOT NULL,
"chain_type" VARCHAR(20) NOT NULL,
"address" VARCHAR(42) NOT NULL,
"user_id" BIGINT NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "monitored_addresses_pkey" PRIMARY KEY ("address_id")
);
-- CreateTable
CREATE TABLE "deposit_transactions" (
"deposit_id" BIGSERIAL NOT NULL,
"chain_type" VARCHAR(20) NOT NULL,
"tx_hash" VARCHAR(66) NOT NULL,
"from_address" VARCHAR(42) NOT NULL,
"to_address" VARCHAR(42) NOT NULL,
"token_contract" VARCHAR(42) NOT NULL,
"amount" DECIMAL(36,18) NOT NULL,
"amount_formatted" DECIMAL(20,8) NOT NULL,
"block_number" BIGINT NOT NULL,
"block_timestamp" TIMESTAMP(3) NOT NULL,
"log_index" INTEGER NOT NULL,
"confirmations" INTEGER NOT NULL DEFAULT 0,
"status" VARCHAR(20) NOT NULL DEFAULT 'DETECTED',
"address_id" BIGINT NOT NULL,
"user_id" BIGINT NOT NULL,
"notified_at" TIMESTAMP(3),
"notify_attempts" INTEGER NOT NULL DEFAULT 0,
"last_notify_error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "deposit_transactions_pkey" PRIMARY KEY ("deposit_id")
);
-- CreateTable
CREATE TABLE "block_checkpoints" (
"checkpoint_id" BIGSERIAL NOT NULL,
"chain_type" VARCHAR(20) NOT NULL,
"last_scanned_block" BIGINT NOT NULL,
"last_scanned_at" TIMESTAMP(3) NOT NULL,
"is_healthy" BOOLEAN NOT NULL DEFAULT true,
"last_error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "block_checkpoints_pkey" PRIMARY KEY ("checkpoint_id")
);
-- CreateTable
CREATE TABLE "transaction_requests" (
"request_id" BIGSERIAL NOT NULL,
"chain_type" VARCHAR(20) NOT NULL,
"source_service" VARCHAR(50) NOT NULL,
"source_order_id" VARCHAR(100) NOT NULL,
"from_address" VARCHAR(42) NOT NULL,
"to_address" VARCHAR(42) NOT NULL,
"value" DECIMAL(36,18) NOT NULL,
"data" TEXT,
"signed_tx" TEXT,
"tx_hash" VARCHAR(66),
"status" VARCHAR(20) NOT NULL DEFAULT 'PENDING',
"gas_limit" BIGINT,
"gas_price" DECIMAL(36,18),
"nonce" INTEGER,
"error_message" TEXT,
"retry_count" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "transaction_requests_pkey" PRIMARY KEY ("request_id")
);
-- CreateTable
CREATE TABLE "blockchain_events" (
"event_id" BIGSERIAL NOT NULL,
"event_type" VARCHAR(50) NOT NULL,
"aggregate_id" VARCHAR(100) NOT NULL,
"aggregate_type" VARCHAR(50) NOT NULL,
"event_data" JSONB NOT NULL,
"chain_type" VARCHAR(20),
"tx_hash" VARCHAR(66),
"occurred_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "blockchain_events_pkey" PRIMARY KEY ("event_id")
);
-- CreateIndex
CREATE INDEX "idx_user" ON "monitored_addresses"("user_id");
-- CreateIndex
CREATE INDEX "idx_chain_active" ON "monitored_addresses"("chain_type", "is_active");
-- CreateIndex
CREATE UNIQUE INDEX "monitored_addresses_chain_type_address_key" ON "monitored_addresses"("chain_type", "address");
-- CreateIndex
CREATE UNIQUE INDEX "deposit_transactions_tx_hash_key" ON "deposit_transactions"("tx_hash");
-- CreateIndex
CREATE INDEX "idx_chain_status" ON "deposit_transactions"("chain_type", "status");
-- CreateIndex
CREATE INDEX "idx_deposit_user" ON "deposit_transactions"("user_id");
-- CreateIndex
CREATE INDEX "idx_block" ON "deposit_transactions"("block_number");
-- CreateIndex
CREATE INDEX "idx_pending_notify" ON "deposit_transactions"("status", "notified_at");
-- CreateIndex
CREATE UNIQUE INDEX "block_checkpoints_chain_type_key" ON "block_checkpoints"("chain_type");
-- CreateIndex
CREATE INDEX "idx_tx_chain_status" ON "transaction_requests"("chain_type", "status");
-- CreateIndex
CREATE INDEX "idx_tx_hash" ON "transaction_requests"("tx_hash");
-- CreateIndex
CREATE UNIQUE INDEX "transaction_requests_source_service_source_order_id_key" ON "transaction_requests"("source_service", "source_order_id");
-- CreateIndex
CREATE INDEX "idx_event_aggregate" ON "blockchain_events"("aggregate_type", "aggregate_id");
-- CreateIndex
CREATE INDEX "idx_event_type" ON "blockchain_events"("event_type");
-- CreateIndex
CREATE INDEX "idx_event_chain" ON "blockchain_events"("chain_type");
-- CreateIndex
CREATE INDEX "idx_event_occurred" ON "blockchain_events"("occurred_at");
-- AddForeignKey
ALTER TABLE "deposit_transactions" ADD CONSTRAINT "deposit_transactions_address_id_fkey" FOREIGN KEY ("address_id") REFERENCES "monitored_addresses"("address_id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,65 @@
-- =============================================
-- Migration: Add system accounts support and recovery mnemonics
-- =============================================
-- AlterTable: monitored_addresses
-- Add new columns for system accounts and account_sequence
ALTER TABLE "monitored_addresses" ADD COLUMN "address_type" VARCHAR(20) NOT NULL DEFAULT 'USER';
ALTER TABLE "monitored_addresses" ADD COLUMN "account_sequence" VARCHAR(20);
ALTER TABLE "monitored_addresses" ADD COLUMN "system_account_type" VARCHAR(50);
ALTER TABLE "monitored_addresses" ADD COLUMN "system_account_id" BIGINT;
ALTER TABLE "monitored_addresses" ADD COLUMN "region_code" VARCHAR(10);
-- Make user_id nullable (system accounts don't have user_id)
ALTER TABLE "monitored_addresses" ALTER COLUMN "user_id" DROP NOT NULL;
-- AlterTable: deposit_transactions
-- Add new columns for system accounts and account_sequence
ALTER TABLE "deposit_transactions" ADD COLUMN "address_type" VARCHAR(20) NOT NULL DEFAULT 'USER';
ALTER TABLE "deposit_transactions" ADD COLUMN "account_sequence" VARCHAR(20);
ALTER TABLE "deposit_transactions" ADD COLUMN "system_account_type" VARCHAR(50);
ALTER TABLE "deposit_transactions" ADD COLUMN "system_account_id" BIGINT;
-- Make user_id nullable (system account deposits don't have user_id)
ALTER TABLE "deposit_transactions" ALTER COLUMN "user_id" DROP NOT NULL;
-- CreateTable: recovery_mnemonics
CREATE TABLE "recovery_mnemonics" (
"id" BIGSERIAL NOT NULL,
"account_sequence" VARCHAR(20) NOT NULL,
"public_key" VARCHAR(130) NOT NULL,
"encrypted_mnemonic" TEXT NOT NULL,
"mnemonic_hash" VARCHAR(64) NOT NULL,
"status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
"is_backed_up" BOOLEAN NOT NULL DEFAULT false,
"revoked_at" TIMESTAMP(3),
"revoked_reason" VARCHAR(200),
"replaced_by_id" BIGINT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "recovery_mnemonics_pkey" PRIMARY KEY ("id")
);
-- =============================================
-- CreateIndex: monitored_addresses (new indexes)
-- =============================================
CREATE INDEX "idx_account_sequence" ON "monitored_addresses"("account_sequence");
CREATE INDEX "idx_type_active" ON "monitored_addresses"("address_type", "is_active");
CREATE INDEX "idx_system_account_type" ON "monitored_addresses"("system_account_type");
-- Rename unique constraint to match schema
DROP INDEX IF EXISTS "monitored_addresses_chain_type_address_key";
CREATE UNIQUE INDEX "uk_chain_address" ON "monitored_addresses"("chain_type", "address");
-- =============================================
-- CreateIndex: deposit_transactions (new indexes)
-- =============================================
CREATE INDEX "idx_deposit_account" ON "deposit_transactions"("account_sequence");
-- =============================================
-- CreateIndex: recovery_mnemonics
-- =============================================
CREATE UNIQUE INDEX "uk_account_active_mnemonic" ON "recovery_mnemonics"("account_sequence", "status");
CREATE INDEX "idx_recovery_account" ON "recovery_mnemonics"("account_sequence");
CREATE INDEX "idx_recovery_public_key" ON "recovery_mnemonics"("public_key");
CREATE INDEX "idx_recovery_status" ON "recovery_mnemonics"("status");

View File

@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "outbox_events" (
"event_id" BIGSERIAL NOT NULL,
"event_type" VARCHAR(100) NOT NULL,
"aggregate_id" VARCHAR(100) NOT NULL,
"aggregate_type" VARCHAR(50) NOT NULL,
"payload" JSONB NOT NULL,
"status" VARCHAR(20) NOT NULL DEFAULT 'PENDING',
"retry_count" INTEGER NOT NULL DEFAULT 0,
"max_retries" INTEGER NOT NULL DEFAULT 10,
"last_error" TEXT,
"next_retry_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"sent_at" TIMESTAMP(3),
"acked_at" TIMESTAMP(3),
CONSTRAINT "outbox_events_pkey" PRIMARY KEY ("event_id")
);
-- CreateIndex
CREATE INDEX "idx_outbox_pending" ON "outbox_events"("status", "next_retry_at");
-- CreateIndex
CREATE INDEX "idx_outbox_aggregate" ON "outbox_events"("aggregate_type", "aggregate_id");
-- CreateIndex
CREATE INDEX "idx_outbox_event_type" ON "outbox_events"("event_type");
-- CreateIndex
CREATE INDEX "idx_outbox_created" ON "outbox_events"("created_at");

View File

@ -0,0 +1,260 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================
// 监控地址表
// 存储需要监听充值的地址(用户地址和系统账户地址)
// ============================================
model MonitoredAddress {
id BigInt @id @default(autoincrement()) @map("address_id")
chainType String @map("chain_type") @db.VarChar(20) // KAVA, BSC
address String @db.VarChar(42) // 0x地址
// 地址类型: USER (用户钱包) 或 SYSTEM (系统账户)
addressType String @default("USER") @map("address_type") @db.VarChar(20)
// 用户地址关联 (addressType = USER 时使用)
accountSequence String? @map("account_sequence") @db.VarChar(20) // 跨服务关联标识 (格式: D + YYMMDD + 5位序号)
userId BigInt? @map("user_id") // 保留兼容
// 系统账户关联 (addressType = SYSTEM 时使用)
systemAccountType String? @map("system_account_type") @db.VarChar(50) // COST_ACCOUNT, OPERATION_ACCOUNT, etc.
systemAccountId BigInt? @map("system_account_id")
regionCode String? @map("region_code") @db.VarChar(10) // 省市代码(省市账户用)
isActive Boolean @default(true) @map("is_active") // 是否激活监听
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deposits DepositTransaction[]
@@unique([chainType, address], name: "uk_chain_address")
@@index([accountSequence], name: "idx_account_sequence")
@@index([userId], name: "idx_user")
@@index([addressType, isActive], name: "idx_type_active")
@@index([chainType, isActive], name: "idx_chain_active")
@@index([systemAccountType], name: "idx_system_account_type")
@@map("monitored_addresses")
}
// ============================================
// 充值交易表 (Append-Only)
// 记录检测到的所有充值交易
// ============================================
model DepositTransaction {
id BigInt @id @default(autoincrement()) @map("deposit_id")
chainType String @map("chain_type") @db.VarChar(20)
txHash String @unique @map("tx_hash") @db.VarChar(66)
fromAddress String @map("from_address") @db.VarChar(42)
toAddress String @map("to_address") @db.VarChar(42)
tokenContract String @map("token_contract") @db.VarChar(42) // USDT合约地址
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, NOTIFIED
// 关联 - 使用 accountSequence 作为跨服务主键
addressId BigInt @map("address_id")
addressType String @default("USER") @map("address_type") @db.VarChar(20) // USER 或 SYSTEM
// 用户地址关联
accountSequence String? @map("account_sequence") @db.VarChar(20) // 跨服务关联标识 (格式: D + YYMMDD + 5位序号)
userId BigInt? @map("user_id") // 保留兼容
// 系统账户关联(当 addressType = SYSTEM 时)
systemAccountType String? @map("system_account_type") @db.VarChar(50)
systemAccountId BigInt? @map("system_account_id")
// 通知状态
notifiedAt DateTime? @map("notified_at")
notifyAttempts Int @default(0) @map("notify_attempts")
lastNotifyError String? @map("last_notify_error") @db.Text
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
monitoredAddress MonitoredAddress @relation(fields: [addressId], references: [id])
@@index([chainType, status], name: "idx_chain_status")
@@index([accountSequence], name: "idx_deposit_account")
@@index([userId], name: "idx_deposit_user")
@@index([blockNumber], name: "idx_block")
@@index([status, notifiedAt], name: "idx_pending_notify")
@@map("deposit_transactions")
}
// ============================================
// 区块扫描检查点 (每条链一条记录)
// 记录扫描进度,用于断点续扫
// ============================================
model BlockCheckpoint {
id BigInt @id @default(autoincrement()) @map("checkpoint_id")
chainType String @unique @map("chain_type") @db.VarChar(20)
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")
@@map("block_checkpoints")
}
// ============================================
// 交易广播请求表
// 记录待广播和已广播的交易
// ============================================
model TransactionRequest {
id BigInt @id @default(autoincrement()) @map("request_id")
chainType String @map("chain_type") @db.VarChar(20)
// 请求来源
sourceService String @map("source_service") @db.VarChar(50)
sourceOrderId String @map("source_order_id") @db.VarChar(100)
// 交易数据
fromAddress String @map("from_address") @db.VarChar(42)
toAddress String @map("to_address") @db.VarChar(42)
value Decimal @db.Decimal(36, 18)
data String? @db.Text // 合约调用数据
// 签名数据 (由 MPC 服务提供)
signedTx String? @map("signed_tx") @db.Text
// 广播结果
txHash String? @map("tx_hash") @db.VarChar(66)
status String @default("PENDING") @db.VarChar(20) // PENDING, SIGNED, BROADCASTED, CONFIRMED, FAILED
// Gas 信息
gasLimit BigInt? @map("gas_limit")
gasPrice Decimal? @map("gas_price") @db.Decimal(36, 18)
nonce Int?
// 错误信息
errorMessage String? @map("error_message") @db.Text
retryCount Int @default(0) @map("retry_count")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([sourceService, sourceOrderId], name: "uk_source_order")
@@index([chainType, status], name: "idx_tx_chain_status")
@@index([txHash], name: "idx_tx_hash")
@@map("transaction_requests")
}
// ============================================
// 账户恢复助记词
// 与账户序列号关联,用于账户恢复验证
// ============================================
model RecoveryMnemonic {
id BigInt @id @default(autoincrement())
accountSequence String @map("account_sequence") @db.VarChar(20) // 账户序列号 (格式: D + YYMMDD + 5位序号)
publicKey String @map("public_key") @db.VarChar(130) // 关联的钱包公钥
// 助记词存储 (加密)
encryptedMnemonic String @map("encrypted_mnemonic") @db.Text // AES加密的助记词
mnemonicHash String @map("mnemonic_hash") @db.VarChar(64) // SHA256哈希用于验证
// 状态管理
status String @default("ACTIVE") @db.VarChar(20) // ACTIVE, REVOKED, REPLACED
isBackedUp Boolean @default(false) @map("is_backed_up") // 用户是否已备份
// 挂失/更换相关
revokedAt DateTime? @map("revoked_at")
revokedReason String? @map("revoked_reason") @db.VarChar(200)
replacedById BigInt? @map("replaced_by_id") // 被哪个新助记词替代
createdAt DateTime @default(now()) @map("created_at")
@@unique([accountSequence, status], name: "uk_account_active_mnemonic") // 一个账户只有一个ACTIVE助记词
@@index([accountSequence], name: "idx_recovery_account")
@@index([publicKey], name: "idx_recovery_public_key")
@@index([status], name: "idx_recovery_status")
@@map("recovery_mnemonics")
}
// ============================================
// Outbox 事件表 (发件箱模式)
// 保证事件发布的可靠性
// ============================================
model OutboxEvent {
id BigInt @id @default(autoincrement()) @map("event_id")
// 事件信息
eventType String @map("event_type") @db.VarChar(100)
aggregateId String @map("aggregate_id") @db.VarChar(100)
aggregateType String @map("aggregate_type") @db.VarChar(50)
payload Json @map("payload")
// 发送状态: PENDING -> SENT -> ACKED / FAILED
status String @default("PENDING") @db.VarChar(20)
// 重试信息
retryCount Int @default(0) @map("retry_count")
maxRetries Int @default(10) @map("max_retries")
lastError String? @map("last_error") @db.Text
nextRetryAt DateTime? @map("next_retry_at")
// 时间戳
createdAt DateTime @default(now()) @map("created_at")
sentAt DateTime? @map("sent_at")
ackedAt DateTime? @map("acked_at")
@@index([status, nextRetryAt], name: "idx_outbox_pending")
@@index([aggregateType, aggregateId], name: "idx_outbox_aggregate")
@@index([eventType], name: "idx_outbox_event_type")
@@index([createdAt], name: "idx_outbox_created")
@@map("outbox_events")
}
// ============================================
// 区块链事件日志 (Append-Only 审计)
// ============================================
model BlockchainEvent {
id BigInt @id @default(autoincrement()) @map("event_id")
eventType String @map("event_type") @db.VarChar(50)
aggregateId String @map("aggregate_id") @db.VarChar(100)
aggregateType String @map("aggregate_type") @db.VarChar(50)
eventData Json @map("event_data")
chainType String? @map("chain_type") @db.VarChar(20)
txHash String? @map("tx_hash") @db.VarChar(66)
occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamp(6)
@@index([aggregateType, aggregateId], name: "idx_event_aggregate")
@@index([eventType], name: "idx_event_type")
@@index([chainType], name: "idx_event_chain")
@@index([occurredAt], name: "idx_event_occurred")
@@map("blockchain_events")
}

View File

@ -0,0 +1,235 @@
# 测试和健康检查脚本
## 使用流程
### 1⃣ 启动基础服务
```bash
# 启动 Redis
redis-server --daemonize yes
# 或使用 Docker
docker compose up -d redis
```
### 2⃣ 启动 Blockchain Service
```bash
# 在项目根目录
npm run start:dev
```
### 3⃣ 运行健康检查
```bash
# 进入 scripts 目录
cd scripts
# 运行健康检查
./health-check.sh
```
**期望输出:**
```
🏥 开始健康检查...
=== 数据库服务 ===
Checking PostgreSQL ... ✓ OK
=== 缓存服务 ===
Checking Redis ... ✓ OK
=== 消息队列服务 ===
Checking Kafka ... ✓ OK
=== 区块链 RPC ===
Checking KAVA RPC ... ✓ OK
Checking BSC RPC ... ✓ OK
=== 应用服务 ===
Checking Blockchain Service ... ✓ OK
=== API 文档 ===
Checking Swagger UI ... ✓ OK
======================================
健康检查完成!
正常: 7
异常: 0
======================================
✓ 所有服务正常!
现在可以运行测试:
./scripts/quick-test.sh
```
### 4⃣ 运行快速功能测试
```bash
./quick-test.sh
```
这个脚本会自动测试所有核心功能:
- ✅ 健康检查
- ✅ 余额查询(单链/多链)
- ✅ 地址派生
- ✅ 用户地址查询
- ✅ 错误场景处理
- ✅ API 文档可访问性
---
## 脚本说明
### `health-check.sh`
- **作用**: 检查所有依赖服务是否正常运行
- **使用场景**: 部署前、调试时
- **检查项目**:
- PostgreSQL 数据库
- Redis 缓存
- Kafka 消息队列
- KAVA/BSC RPC 端点
- Blockchain Service 应用
### `quick-test.sh`
- **作用**: 快速测试所有核心 API 功能
- **使用场景**: 验证功能完整性、回归测试
- **前置条件**: `health-check.sh` 通过
### `start-all.sh`
- **作用**: 一键启动所有服务
- **使用场景**: 初次启动、快速启动环境
- **前置条件**: 依赖已安装
### `stop-service.sh`
- **作用**: 停止 Blockchain Service
- **使用场景**: 需要停止服务时
### `rebuild-kafka.sh`
- **作用**: 重建 Kafka 容器
- **使用场景**: Kafka 配置变更后
---
## 主要 API 端点
| 端点 | 方法 | 描述 |
|------|------|------|
| `/health` | GET | 健康检查 |
| `/health/ready` | GET | 就绪检查 |
| `/balance` | GET | 查询单链余额 |
| `/balance/multi-chain` | GET | 查询多链余额 |
| `/internal/derive-address` | POST | 从公钥派生地址 |
| `/internal/user/:userId/addresses` | GET | 获取用户地址 |
| `/api` | GET | Swagger 文档 |
---
## 部署脚本 (deploy.sh)
主部署脚本位于项目根目录,提供以下命令:
```bash
# 构建 Docker 镜像
./deploy.sh build
# 启动服务
./deploy.sh start
# 停止服务
./deploy.sh stop
# 重启服务
./deploy.sh restart
# 查看日志
./deploy.sh logs
# 健康检查
./deploy.sh health
# 运行数据库迁移
./deploy.sh migrate
# 打开 Prisma Studio
./deploy.sh prisma-studio
# 进入容器 shell
./deploy.sh shell
# 查询余额
./deploy.sh check-balance KAVA 0x1234...
# 触发区块扫描
./deploy.sh scan-blocks
```
---
## 常见问题
### Q: 为什么 RPC 检查失败?
**A:** 检查网络连接,或者 RPC 端点可能暂时不可用
### Q: Redis 启动失败?
**A:** 检查是否已经在运行
```bash
ps aux | grep redis
redis-cli shutdown # 如果已运行
redis-server --daemonize yes
```
### Q: Kafka 连接失败?
**A:** 重建 Kafka 容器
```bash
./scripts/rebuild-kafka.sh
```
---
## 完整测试流程
```bash
# 1. 进入项目目录
cd ~/work/rwadurian/backend/services/blockchain-service
# 2. 安装依赖(首次)
npm install
# 3. 生成 Prisma Client
npx prisma generate
# 4. 运行数据库迁移
npx prisma migrate dev
# 5. 启动所有服务
./scripts/start-all.sh
# 6. 运行健康检查
./scripts/health-check.sh
# 7. 运行快速测试
./scripts/quick-test.sh
# 8. 运行完整测试
npm test
npm run test:e2e
```
---
## 区块链特定测试
### 测试余额查询
```bash
# KAVA 链
curl "http://localhost:3012/balance?chainType=KAVA&address=0x..."
# 多链查询
curl "http://localhost:3012/balance/multi-chain?address=0x..."
```
### 测试地址派生
```bash
curl -X POST "http://localhost:3012/internal/derive-address" \
-H "Content-Type: application/json" \
-d '{
"userId": "12345",
"publicKey": "0x02..."
}'
```

View File

@ -0,0 +1,134 @@
/**
* Deploy TestUSDT to KAVA Testnet using inline Solidity compilation
*/
import { ethers } from 'ethers';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const solc = require('solc');
const KAVA_TESTNET_RPC = 'https://evm.testnet.kava.io';
const KAVA_TESTNET_CHAIN_ID = 2221;
const privateKey = '0xd42a6e6021ebd884f3f179d3793a32e97b9f1001db6ff44441ec455d748b9aa6';
const sourceCode = `
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;
contract TestUSDT {
string public constant name = "Test USDT";
string public constant symbol = "USDT";
uint8 public constant decimals = 6;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor() {
_mint(msg.sender, 1000000 * 1e6);
}
function _mint(address to, uint256 amount) internal {
totalSupply += amount;
balanceOf[to] += amount;
emit Transfer(address(0), to, amount);
}
function transfer(address to, uint256 amount) public returns (bool) {
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) public returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
allowance[from][msg.sender] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
function mint(uint256 amount) external {
_mint(msg.sender, amount);
}
function faucet() external {
_mint(msg.sender, 10000 * 1e6);
}
}
`;
async function deploy() {
console.log('🔨 Compiling contract...');
const input = {
language: 'Solidity',
sources: { 'TestUSDT.sol': { content: sourceCode } },
settings: { outputSelection: { '*': { '*': ['abi', 'evm.bytecode'] } } },
};
const output = JSON.parse(solc.compile(JSON.stringify(input)));
if (output.errors) {
const errors = output.errors.filter((e: any) => e.severity === 'error');
if (errors.length > 0) {
console.error('❌ Compilation errors:', errors);
process.exit(1);
}
}
const contract = output.contracts['TestUSDT.sol']['TestUSDT'];
const bytecode = contract.evm.bytecode.object;
const compiledAbi = contract.abi;
console.log('🌐 Connecting to KAVA Testnet...');
const provider = new ethers.JsonRpcProvider(KAVA_TESTNET_RPC, {
chainId: KAVA_TESTNET_CHAIN_ID,
name: 'kava-testnet',
});
const wallet = new ethers.Wallet(privateKey, provider);
console.log(`📍 Deployer: ${wallet.address}`);
const balance = await provider.getBalance(wallet.address);
console.log(`💰 Balance: ${ethers.formatEther(balance)} TKAVA`);
if (balance < ethers.parseEther('0.01')) {
console.error('❌ Insufficient TKAVA');
process.exit(1);
}
console.log('📦 Deploying...');
const factory = new ethers.ContractFactory(compiledAbi, bytecode, wallet);
const deployedContract = await factory.deploy({
gasLimit: 5000000,
});
console.log(`⏳ Waiting for confirmation...`);
console.log(` TX: https://testnet.kavascan.com/tx/${deployedContract.deploymentTransaction()?.hash}`);
await deployedContract.waitForDeployment();
const address = await deployedContract.getAddress();
console.log('');
console.log('='.repeat(60));
console.log(`✅ SUCCESS! TestUSDT deployed on KAVA Testnet`);
console.log(`📋 Contract Address: ${address}`);
console.log('='.repeat(60));
console.log('');
console.log(`🔗 KavaScan: https://testnet.kavascan.com/address/${address}`);
console.log('');
console.log('Next: Update KAVA_USDT_CONTRACT in .env');
}
deploy().catch((e) => {
console.error('❌ Error:', e.message);
process.exit(1);
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,26 @@
/**
* Generate a new wallet for BSC Testnet deployment
*
* Usage: npx ts-node scripts/generate-wallet.ts
*/
import { ethers } from 'ethers';
const wallet = ethers.Wallet.createRandom();
console.log(`
🔐 New Wallet Generated for BSC Testnet
========================================
Address: ${wallet.address}
Private Key: ${wallet.privateKey}
Mnemonic: ${wallet.mnemonic?.phrase}
Next steps:
1. Go to https://www.bnbchain.org/en/testnet-faucet
2. Paste your address: ${wallet.address}
3. Get 0.1 tBNB
4. Run: npx ts-node scripts/deploy-test-usdt.ts ${wallet.privateKey}
SAVE YOUR PRIVATE KEY! You'll need it for future contract interactions.
`);

View File

@ -0,0 +1,93 @@
#!/bin/bash
# 健康检查脚本 - 检查所有依赖服务是否正常
echo "🏥 开始健康检查..."
echo ""
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# 检查计数
PASS=0
FAIL=0
FAILED_SERVICES=()
# 检查函数
check_service() {
local service_name=$1
local check_command=$2
local fix_command=$3
echo -n "Checking $service_name ... "
if eval "$check_command" > /dev/null 2>&1; then
echo -e "${GREEN}✓ OK${NC}"
PASS=$((PASS + 1))
else
echo -e "${RED}✗ FAIL${NC}"
FAIL=$((FAIL + 1))
FAILED_SERVICES+=("$service_name:$fix_command")
fi
}
# 检查 PostgreSQL
echo -e "${YELLOW}=== 数据库服务 ===${NC}"
check_service "PostgreSQL" "pg_isready -h localhost -p 5432" "sudo systemctl start postgresql"
# 检查 Redis (支持 Docker 和本地)
echo -e "${YELLOW}=== 缓存服务 ===${NC}"
if command -v redis-cli &> /dev/null; then
check_service "Redis" "redis-cli -h localhost -p 6379 ping" "docker start blockchain-service-redis-1 或 redis-server --daemonize yes"
elif command -v docker &> /dev/null; then
check_service "Redis" "docker exec blockchain-service-redis-1 redis-cli ping" "docker start blockchain-service-redis-1"
else
check_service "Redis" "nc -zv localhost 6379" "docker start blockchain-service-redis-1"
fi
# 检查 Kafka
echo -e "${YELLOW}=== 消息队列服务 ===${NC}"
check_service "Kafka" "nc -zv localhost 9092" "启动 Kafka (需要手动启动)"
# 检查区块链 RPC
echo -e "${YELLOW}=== 区块链 RPC ===${NC}"
check_service "KAVA RPC" "curl -sf https://evm.kava.io -X POST -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"params\":[],\"id\":1}'" "检查网络连接或 RPC 端点"
check_service "BSC RPC" "curl -sf https://bsc-dataseed.binance.org -X POST -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"params\":[],\"id\":1}'" "检查网络连接或 RPC 端点"
# 检查应用服务
echo -e "${YELLOW}=== 应用服务 ===${NC}"
check_service "Blockchain Service" "curl -f http://localhost:3012/health" "npm run start:dev"
# 检查 Swagger 文档
echo -e "${YELLOW}=== API 文档 ===${NC}"
check_service "Swagger UI" "curl -f http://localhost:3012/api" "等待 Blockchain Service 启动"
echo ""
echo -e "${YELLOW}======================================${NC}"
echo -e "${YELLOW}健康检查完成!${NC}"
echo -e "${GREEN}正常: $PASS${NC}"
echo -e "${RED}异常: $FAIL${NC}"
echo -e "${YELLOW}======================================${NC}"
if [ $FAIL -eq 0 ]; then
echo -e "${GREEN}✓ 所有服务正常!${NC}"
echo ""
echo -e "${BLUE}现在可以运行测试:${NC}"
echo " ./scripts/quick-test.sh"
exit 0
else
echo -e "${RED}✗ 存在异常的服务!${NC}"
echo ""
echo -e "${BLUE}修复建议:${NC}"
for service_info in "${FAILED_SERVICES[@]}"; do
service_name="${service_info%%:*}"
fix_command="${service_info#*:}"
echo -e "${YELLOW}$service_name:${NC} $fix_command"
done
echo ""
echo -e "${BLUE}或者运行一键启动脚本:${NC}"
echo " ./scripts/start-all.sh"
exit 1
fi

View File

@ -0,0 +1,119 @@
#!/bin/bash
# 快速测试脚本 - 在本地环境快速验证核心功能
set -e
echo "🚀 开始快速测试 Blockchain Service..."
echo ""
# 颜色定义
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
BASE_URL="http://localhost:3012"
# 测试结果统计
PASS=0
FAIL=0
# 测试函数
test_api() {
local test_name=$1
local method=$2
local endpoint=$3
local data=$4
local expected_status=$5
echo -n "Testing: $test_name ... "
if [ -n "$data" ]; then
response=$(curl -s -w "\n%{http_code}" -X $method "$BASE_URL$endpoint" \
-H "Content-Type: application/json" \
-d "$data")
else
response=$(curl -s -w "\n%{http_code}" -X $method "$BASE_URL$endpoint" \
-H "Content-Type: application/json")
fi
status=$(echo "$response" | tail -n1)
body=$(echo "$response" | head -n-1)
if [ "$status" -eq "$expected_status" ]; then
echo -e "${GREEN}✓ PASS${NC}"
PASS=$((PASS + 1))
if command -v jq &> /dev/null && [ -n "$body" ]; then
echo "$body" | jq '.' 2>/dev/null || echo "$body"
else
echo "$body"
fi
else
echo -e "${RED}✗ FAIL${NC} (Expected: $expected_status, Got: $status)"
FAIL=$((FAIL + 1))
echo "$body"
fi
echo ""
}
# 1. 健康检查
echo -e "${YELLOW}=== 1. 健康检查 ===${NC}"
test_api "Health Check" "GET" "/health" "" 200
test_api "Ready Check" "GET" "/health/ready" "" 200
# 2. 余额查询测试
echo -e "${YELLOW}=== 2. 余额查询 ===${NC}"
# 使用一个已知的测试地址 (Binance Hot Wallet)
TEST_ADDRESS="0x8894E0a0c962CB723c1976a4421c95949bE2D4E3"
test_api "Query KAVA Balance" "GET" "/balance?chainType=KAVA&address=$TEST_ADDRESS" "" 200
test_api "Query Multi-Chain Balance" "GET" "/balance/multi-chain?address=$TEST_ADDRESS" "" 200
# 3. 地址派生测试
echo -e "${YELLOW}=== 3. 地址派生测试 ===${NC}"
# 测试用压缩公钥 (仅用于测试)
TEST_PUBLIC_KEY="0x02b4632d08485ff1df2db55b9dafd23347d1c47a457072a1e87be26896549a8737"
TEST_USER_ID="999999"
test_api "Derive Address" "POST" "/internal/derive-address" \
"{\"userId\": \"$TEST_USER_ID\", \"publicKey\": \"$TEST_PUBLIC_KEY\"}" \
201
# 4. 获取用户地址
echo -e "${YELLOW}=== 4. 获取用户地址 ===${NC}"
test_api "Get User Addresses" "GET" "/internal/user/$TEST_USER_ID/addresses" "" 200
# 5. 错误场景测试
echo -e "${YELLOW}=== 5. 错误场景测试 ===${NC}"
# 无效地址格式
test_api "Invalid Address Format" "GET" "/balance?chainType=KAVA&address=invalid" "" 400
# 无效链类型
test_api "Invalid Chain Type" "GET" "/balance?chainType=INVALID&address=$TEST_ADDRESS" "" 400
# 无效公钥格式
test_api "Invalid Public Key" "POST" "/internal/derive-address" \
"{\"userId\": \"1\", \"publicKey\": \"invalid\"}" \
400
# 6. API 文档测试
echo -e "${YELLOW}=== 6. API 文档 ===${NC}"
test_api "Swagger API Docs" "GET" "/api" "" 200
# 总结
echo ""
echo -e "${YELLOW}======================================${NC}"
echo -e "${YELLOW}测试完成!${NC}"
echo -e "${GREEN}通过: $PASS${NC}"
echo -e "${RED}失败: $FAIL${NC}"
echo -e "${YELLOW}======================================${NC}"
if [ $FAIL -eq 0 ]; then
echo -e "${GREEN}✓ 所有测试通过!${NC}"
exit 0
else
echo -e "${RED}✗ 存在失败的测试!${NC}"
exit 1
fi

View File

@ -0,0 +1,74 @@
#!/bin/bash
# 重建 Kafka 容器以应用新的配置
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${YELLOW}🔄 重建 Kafka 容器...${NC}"
echo ""
# 1. 停止 Blockchain Service (如果在运行)
echo -e "${BLUE}步骤 1: 停止 Blockchain Service${NC}"
PID=$(lsof -ti :3012 2>/dev/null)
if [ ! -z "$PID" ]; then
echo "停止 Blockchain Service (PID: $PID)..."
kill $PID
sleep 2
echo -e "${GREEN}✓ Blockchain Service 已停止${NC}"
else
echo -e "${YELLOW}⚠️ Blockchain Service 未在运行${NC}"
fi
echo ""
# 2. 停止并删除 Kafka 容器
echo -e "${BLUE}步骤 2: 停止并删除旧容器${NC}"
docker compose stop kafka 2>/dev/null || true
docker compose rm -f kafka 2>/dev/null || true
echo -e "${GREEN}✓ 旧容器已删除${NC}"
echo ""
# 3. 重新创建容器
echo -e "${BLUE}步骤 3: 创建新容器${NC}"
docker compose up -d kafka
echo "等待 Kafka 启动..."
sleep 20
echo -e "${GREEN}✓ Kafka 容器已创建${NC}"
echo ""
# 4. 验证配置
echo -e "${BLUE}步骤 4: 验证配置${NC}"
CONTAINER_NAME=$(docker compose ps -q kafka 2>/dev/null)
if [ ! -z "$CONTAINER_NAME" ]; then
ADVERTISED=$(docker inspect "$CONTAINER_NAME" 2>/dev/null | grep -A 1 "KAFKA_ADVERTISED_LISTENERS" | head -1)
if echo "$ADVERTISED" | grep -q "localhost:9092"; then
echo -e "${GREEN}✓ Kafka 配置已更新!${NC}"
echo "$ADVERTISED"
else
echo -e "${YELLOW}⚠ Kafka 配置可能需要检查${NC}"
fi
else
echo -e "${YELLOW}⚠ 未找到 Kafka 容器${NC}"
fi
echo ""
# 5. 测试连接
echo -e "${BLUE}步骤 5: 测试 Kafka 连接${NC}"
if nc -zv localhost 9092 2>&1 | grep -q "succeeded\|Connected"; then
echo -e "${GREEN}✓ Kafka 端口可访问${NC}"
else
echo -e "${RED}✗ Kafka 端口不可访问${NC}"
exit 1
fi
echo ""
echo -e "${YELLOW}======================================${NC}"
echo -e "${GREEN}✓ Kafka 重建完成!${NC}"
echo -e "${YELLOW}======================================${NC}"
echo ""
echo -e "${BLUE}下一步:${NC}"
echo "1. 启动 Blockchain Service: npm run start:dev"
echo "2. 运行健康检查: ./scripts/health-check.sh"
echo "3. 运行快速测试: ./scripts/quick-test.sh"

View File

@ -0,0 +1,66 @@
#!/bin/bash
# 一键启动所有服务
set -e
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${YELLOW}🚀 启动所有服务...${NC}"
echo ""
# 1. 启动 Redis
echo -e "${YELLOW}启动 Redis...${NC}"
if ! pgrep -x "redis-server" > /dev/null; then
if command -v redis-server &> /dev/null; then
redis-server --daemonize yes
echo -e "${GREEN}✓ Redis 已启动${NC}"
else
echo -e "${YELLOW}⚠ Redis 未安装,尝试使用 Docker...${NC}"
docker start blockchain-service-redis-1 2>/dev/null || docker compose up -d redis
fi
else
echo -e "${GREEN}✓ Redis 已在运行${NC}"
fi
# 2. 检查 PostgreSQL
echo -e "${YELLOW}检查 PostgreSQL...${NC}"
if pg_isready -h localhost -p 5432 > /dev/null 2>&1; then
echo -e "${GREEN}✓ PostgreSQL 已在运行${NC}"
else
echo -e "${YELLOW}⚠ PostgreSQL 未运行,请手动启动${NC}"
fi
# 3. 检查 Kafka
echo -e "${YELLOW}检查 Kafka...${NC}"
if nc -zv localhost 9092 > /dev/null 2>&1; then
echo -e "${GREEN}✓ Kafka 已在运行${NC}"
else
echo -e "${YELLOW}⚠ Kafka 未运行,请手动启动${NC}"
fi
# 4. 启动 Blockchain Service
echo -e "${YELLOW}启动 Blockchain Service...${NC}"
cd "$(dirname "$0")/.."
npm run start:dev &
# 等待服务启动
echo "等待服务启动 (最多 30 秒)..."
for i in {1..30}; do
if curl -f http://localhost:3012/health > /dev/null 2>&1; then
echo -e "${GREEN}✓ Blockchain Service 已启动${NC}"
break
fi
sleep 1
echo -n "."
done
echo ""
echo -e "${GREEN}✓ 所有服务已启动!${NC}"
echo ""
echo "运行健康检查:"
echo " ./scripts/health-check.sh"
echo ""
echo "运行快速测试:"
echo " ./scripts/quick-test.sh"

View File

@ -0,0 +1,44 @@
#!/bin/bash
# 停止 Blockchain Service
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
echo -e "${YELLOW}🛑 停止 Blockchain Service...${NC}"
# 查找监听 3012 端口的进程
PID=$(lsof -ti :3012)
if [ -z "$PID" ]; then
echo -e "${YELLOW}⚠️ Blockchain Service 未在运行${NC}"
exit 0
fi
echo "找到进程: PID=$PID"
# 尝试优雅停止
echo "发送 SIGTERM 信号..."
kill $PID
# 等待进程结束
for i in {1..10}; do
if ! kill -0 $PID 2>/dev/null; then
echo -e "${GREEN}✓ Blockchain Service 已停止${NC}"
exit 0
fi
sleep 1
echo -n "."
done
echo ""
echo -e "${YELLOW}⚠️ 进程未响应,强制停止...${NC}"
kill -9 $PID
if ! kill -0 $PID 2>/dev/null; then
echo -e "${GREEN}✓ Blockchain Service 已强制停止${NC}"
else
echo -e "${RED}✗ 无法停止进程${NC}"
exit 1
fi

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from '@/application/application.module';
import { DomainModule } from '@/domain/domain.module';
import { HealthController } from './controllers';
import { TransferController } from './controllers/transfer.controller';
@Module({
imports: [
ApplicationModule,
DomainModule,
],
controllers: [HealthController, TransferController],
})
export class ApiModule {}

View File

@ -0,0 +1,36 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { BalanceQueryService } from '@/application/services/balance-query.service';
import { QueryBalanceDto, QueryMultiChainBalanceDto } from '../dto/request';
import { BalanceResponseDto, MultiChainBalanceResponseDto } from '../dto/response';
import { ChainType } from '@/domain/value-objects';
@ApiTags('Balance')
@Controller('balance')
export class BalanceController {
constructor(private readonly balanceService: BalanceQueryService) {}
@Get()
@ApiOperation({ summary: '查询单链余额' })
@ApiResponse({ status: 200, description: '余额信息', type: BalanceResponseDto })
async getBalance(@Query() dto: QueryBalanceDto): Promise<BalanceResponseDto> {
if (!dto.chainType) {
throw new Error('chainType is required');
}
const chainType = ChainType.create(dto.chainType);
return this.balanceService.getBalance(chainType, dto.address);
}
@Get('multi-chain')
@ApiOperation({ summary: '查询多链余额' })
@ApiResponse({ status: 200, description: '多链余额信息', type: MultiChainBalanceResponseDto })
async getMultiChainBalance(
@Query() dto: QueryMultiChainBalanceDto,
): Promise<MultiChainBalanceResponseDto> {
const balances = await this.balanceService.getBalances(dto.address, dto.chainTypes);
return {
address: dto.address,
balances,
};
}
}

View File

@ -0,0 +1,72 @@
import { Controller, Get, Post, Param, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { DepositRepairService } from '@/application/services/deposit-repair.service';
/**
*
*
* API
*/
@ApiTags('Deposit Repair')
@Controller('internal/deposit-repair')
export class DepositRepairController {
private readonly logger = new Logger(DepositRepairController.name);
constructor(private readonly repairService: DepositRepairService) {}
@Get('diagnose')
@ApiOperation({ summary: '诊断充值状态' })
@ApiResponse({
status: 200,
description: '返回需要修复的充值统计',
})
async diagnose() {
this.logger.log('Running deposit diagnosis...');
const result = await this.repairService.diagnose();
this.logger.log(
`Diagnosis complete: ${result.confirmedNotNotified.length} deposits need repair`,
);
return result;
}
@Post('repair/:depositId')
@ApiOperation({ summary: '修复单个充值' })
@ApiResponse({
status: 200,
description: '修复结果',
})
async repairDeposit(@Param('depositId') depositId: string) {
this.logger.log(`Repairing deposit ${depositId}...`);
const result = await this.repairService.repairDeposit(BigInt(depositId));
this.logger.log(`Repair result: ${result.message}`);
return result;
}
@Post('repair-all')
@ApiOperation({ summary: '批量修复所有未通知的充值' })
@ApiResponse({
status: 200,
description: '批量修复结果',
})
async repairAll() {
this.logger.log('Starting batch repair...');
const result = await this.repairService.repairAll();
this.logger.log(
`Batch repair complete: ${result.success}/${result.total} success`,
);
return result;
}
@Post('reset-failed-outbox')
@ApiOperation({ summary: '重置失败的 Outbox 事件' })
@ApiResponse({
status: 200,
description: '重置结果',
})
async resetFailedOutbox() {
this.logger.log('Resetting failed outbox events...');
const result = await this.repairService.resetFailedOutboxEvents();
this.logger.log(`Reset ${result.reset} failed events`);
return result;
}
}

View File

@ -0,0 +1,102 @@
/**
* Deposit Controller
*
* Provides deposit-related endpoints for the mobile app.
* Queries on-chain USDT balances for user's monitored addresses.
*/
import { Controller, Get, Logger, Inject, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { Request } from 'express';
import { BalanceQueryService } from '@/application/services/balance-query.service';
import { ChainTypeEnum } from '@/domain/enums';
import {
IMonitoredAddressRepository,
MONITORED_ADDRESS_REPOSITORY,
} from '@/domain/repositories/monitored-address.repository.interface';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
interface JwtPayload {
userId: string;
accountSequence: string;
deviceId: string;
}
interface UsdtBalanceDto {
chainType: string;
address: string;
balance: string;
rawBalance: string;
decimals: number;
}
interface DepositBalancesResponseDto {
kava: UsdtBalanceDto | null;
bsc: UsdtBalanceDto | null;
}
@ApiTags('Deposit')
@Controller('deposit')
export class DepositController {
private readonly logger = new Logger(DepositController.name);
constructor(
private readonly balanceService: BalanceQueryService,
@Inject(MONITORED_ADDRESS_REPOSITORY)
private readonly monitoredAddressRepo: IMonitoredAddressRepository,
) {}
@Get('balances')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '查询用户 USDT 余额' })
@ApiResponse({ status: 200, description: '余额信息' })
async getBalances(@Req() req: Request): Promise<DepositBalancesResponseDto> {
const user = (req as Request & { user: JwtPayload }).user;
const userId = BigInt(user.userId);
this.logger.log(`Querying deposit balances for user ${userId}`);
// Get user's monitored addresses
const addresses = await this.monitoredAddressRepo.findByUserId(userId);
const response: DepositBalancesResponseDto = {
kava: null,
bsc: null,
};
// Query balance for each chain
for (const addr of addresses) {
try {
const chainType = addr.chainType;
const addressStr = addr.address.toString();
const chainTypeStr = chainType.toString();
const balance = await this.balanceService.getBalance(chainType, addressStr);
const balanceDto: UsdtBalanceDto = {
chainType: chainTypeStr,
address: addressStr,
balance: balance.usdtBalance,
rawBalance: balance.usdtRawBalance,
decimals: balance.usdtDecimals,
};
if (chainTypeStr === ChainTypeEnum.KAVA) {
response.kava = balanceDto;
} else if (chainTypeStr === ChainTypeEnum.BSC) {
response.bsc = balanceDto;
}
} catch (error) {
this.logger.error(`Error querying balance for ${addr.chainType}:${addr.address}`, error);
}
}
this.logger.log(
`Balance query complete for user ${userId}: ` +
`KAVA=${response.kava?.balance || '0'}, BSC=${response.bsc?.balance || '0'}`,
);
return response;
}
}

View File

@ -0,0 +1,59 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { ChainConfigService } from '@/domain/services/chain-config.service';
import { ChainType } from '@/domain/value-objects';
@ApiTags('Health')
@Controller('health')
export class HealthController {
constructor(private readonly chainConfig: ChainConfigService) {}
@Get()
@ApiOperation({ summary: '健康检查' })
@ApiResponse({ status: 200, description: '服务健康' })
check() {
return {
status: 'ok',
service: 'blockchain-service',
timestamp: new Date().toISOString(),
};
}
@Get('ready')
@ApiOperation({ summary: '就绪检查' })
@ApiResponse({ status: 200, description: '服务就绪' })
ready() {
return {
status: 'ready',
service: 'blockchain-service',
timestamp: new Date().toISOString(),
};
}
@Get('network')
@ApiOperation({ summary: '网络配置信息' })
@ApiResponse({ status: 200, description: '返回当前网络配置' })
network() {
const supportedChains = this.chainConfig.getSupportedChains();
const chains: Record<string, unknown> = {};
for (const chainTypeEnum of supportedChains) {
const chainType = ChainType.fromEnum(chainTypeEnum);
const config = this.chainConfig.getConfig(chainType);
chains[chainTypeEnum] = {
chainId: config.chainId,
rpcUrl: config.rpcUrl,
usdtContract: config.usdtContract,
isTestnet: config.isTestnet,
};
}
return {
service: 'blockchain-service',
networkMode: this.chainConfig.getNetworkMode(),
isTestnet: this.chainConfig.isTestnetMode(),
chains,
timestamp: new Date().toISOString(),
};
}
}

View File

@ -0,0 +1,5 @@
export * from './health.controller';
export * from './balance.controller';
export * from './internal.controller';
export * from './deposit.controller';
export * from './deposit-repair.controller';

View File

@ -0,0 +1,113 @@
import { Controller, Post, Body, Get, Param, Put } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AddressDerivationService } from '@/application/services/address-derivation.service';
import { MnemonicVerificationService } from '@/application/services/mnemonic-verification.service';
import { MnemonicDerivationAdapter } from '@/infrastructure/blockchain';
import { DeriveAddressDto, VerifyMnemonicDto, VerifyMnemonicHashDto, MarkMnemonicBackupDto, RevokeMnemonicDto } from '../dto/request';
import { DeriveAddressResponseDto } from '../dto/response';
/**
* API
*
*/
@ApiTags('Internal')
@Controller('internal')
export class InternalController {
constructor(
private readonly addressDerivationService: AddressDerivationService,
private readonly mnemonicVerification: MnemonicVerificationService,
private readonly mnemonicDerivation: MnemonicDerivationAdapter,
) {}
@Post('derive-address')
@ApiOperation({ summary: '从公钥派生地址' })
@ApiResponse({ status: 201, description: '派生成功', type: DeriveAddressResponseDto })
async deriveAddress(@Body() dto: DeriveAddressDto): Promise<DeriveAddressResponseDto> {
const result = await this.addressDerivationService.deriveAndRegister({
userId: BigInt(dto.userId),
accountSequence: dto.accountSequence,
publicKey: dto.publicKey,
});
return {
userId: result.userId.toString(),
publicKey: result.publicKey,
addresses: result.addresses.map((a) => ({
chainType: a.chainType,
address: a.address,
})),
};
}
@Get('user/:userId/addresses')
@ApiOperation({ summary: '获取用户的所有地址' })
async getUserAddresses(@Param('userId') userId: string) {
const addresses = await this.addressDerivationService.getUserAddresses(BigInt(userId));
return {
userId,
addresses: addresses.map((a) => ({
chainType: a.chainType.toString(),
address: a.address.toString(),
isActive: a.isActive,
})),
};
}
@Post('verify-mnemonic')
@ApiOperation({ summary: '验证助记词是否匹配指定地址' })
@ApiResponse({ status: 200, description: '验证结果' })
async verifyMnemonic(@Body() dto: VerifyMnemonicDto) {
const result = this.mnemonicDerivation.verifyMnemonic(dto.mnemonic, dto.expectedAddresses);
return {
valid: result.valid,
matchedAddresses: result.matchedAddresses,
mismatchedAddresses: result.mismatchedAddresses,
};
}
@Post('derive-from-mnemonic')
@ApiOperation({ summary: '从助记词派生所有链地址' })
@ApiResponse({ status: 200, description: '派生的地址列表' })
async deriveFromMnemonic(@Body() dto: { mnemonic: string }) {
const addresses = this.mnemonicDerivation.deriveAllAddresses(dto.mnemonic);
return {
addresses: addresses.map((a) => ({
chainType: a.chainType,
address: a.address,
})),
};
}
@Post('verify-mnemonic-hash')
@ApiOperation({ summary: '通过账户序列号验证助记词' })
@ApiResponse({ status: 200, description: '验证结果' })
async verifyMnemonicHash(@Body() dto: VerifyMnemonicHashDto) {
const result = await this.mnemonicVerification.verifyMnemonicByAccount({
accountSequence: dto.accountSequence,
mnemonic: dto.mnemonic,
});
return {
valid: result.valid,
message: result.message,
};
}
@Put('mnemonic/backup')
@ApiOperation({ summary: '标记助记词已备份' })
@ApiResponse({ status: 200, description: '标记成功' })
async markMnemonicBackedUp(@Body() dto: MarkMnemonicBackupDto) {
await this.mnemonicVerification.markAsBackedUp(dto.accountSequence);
return {
success: true,
message: 'Mnemonic marked as backed up',
};
}
@Post('mnemonic/revoke')
@ApiOperation({ summary: '挂失助记词' })
@ApiResponse({ status: 200, description: '挂失结果' })
async revokeMnemonic(@Body() dto: RevokeMnemonicDto) {
const result = await this.mnemonicVerification.revokeMnemonic(dto.accountSequence, dto.reason);
return result;
}
}

View File

@ -0,0 +1,114 @@
import { Controller, Post, Body, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, Matches, IsNumberString } from 'class-validator';
import { Erc20TransferService, TransferResult } from '@/domain/services/erc20-transfer.service';
import { ChainTypeEnum } from '@/domain/enums';
/**
* dUSDT DTO
*/
class TransferDusdtDto {
@ApiProperty({ description: '接收者 Kava 地址', example: '0x1234567890abcdef1234567890abcdef12345678' })
@IsString()
@IsNotEmpty()
@Matches(/^0x[a-fA-F0-9]{40}$/, { message: 'Invalid EVM address format' })
toAddress: string;
@ApiProperty({ description: '转账金额(人类可读格式)', example: '100.5' })
@IsString()
@IsNotEmpty()
@IsNumberString({}, { message: 'Amount must be a valid number string' })
amount: string;
}
/**
* DTO
*/
class TransferResponseDto {
@ApiProperty({ description: '是否成功' })
success: boolean;
@ApiProperty({ description: '交易哈希', required: false })
txHash?: string;
@ApiProperty({ description: '错误信息', required: false })
error?: string;
@ApiProperty({ description: '消耗的 Gas', required: false })
gasUsed?: string;
@ApiProperty({ description: '区块高度', required: false })
blockNumber?: number;
}
/**
* DTO
*/
class BalanceResponseDto {
@ApiProperty({ description: '热钱包地址' })
address: string;
@ApiProperty({ description: 'dUSDT 余额' })
balance: string;
@ApiProperty({ description: '链类型' })
chain: string;
}
/**
* dUSDT
* trading-service C2C Bot
*/
@ApiTags('Transfer')
@Controller('transfer')
export class TransferController {
constructor(private readonly erc20TransferService: Erc20TransferService) {}
@Post('dusdt')
@ApiOperation({ summary: '转账 dUSDT 到指定地址' })
@ApiResponse({ status: 200, description: '转账结果', type: TransferResponseDto })
@ApiResponse({ status: 400, description: '参数错误' })
@ApiResponse({ status: 500, description: '转账失败' })
async transferDusdt(@Body() dto: TransferDusdtDto): Promise<TransferResponseDto> {
const result: TransferResult = await this.erc20TransferService.transferUsdt(
ChainTypeEnum.KAVA,
dto.toAddress,
dto.amount,
);
return {
success: result.success,
txHash: result.txHash,
error: result.error,
gasUsed: result.gasUsed,
blockNumber: result.blockNumber,
};
}
@Get('dusdt/balance')
@ApiOperation({ summary: '查询热钱包 dUSDT 余额' })
@ApiResponse({ status: 200, description: '余额信息', type: BalanceResponseDto })
async getHotWalletBalance(): Promise<BalanceResponseDto> {
const address = this.erc20TransferService.getHotWalletAddress(ChainTypeEnum.KAVA);
const balance = await this.erc20TransferService.getHotWalletBalance(ChainTypeEnum.KAVA);
return {
address: address || '',
balance,
chain: 'KAVA',
};
}
@Get('status')
@ApiOperation({ summary: '检查转账服务状态' })
@ApiResponse({ status: 200, description: '服务状态' })
async getStatus(): Promise<{ configured: boolean; hotWalletAddress: string | null }> {
const configured = this.erc20TransferService.isConfigured(ChainTypeEnum.KAVA);
const hotWalletAddress = this.erc20TransferService.getHotWalletAddress(ChainTypeEnum.KAVA);
return {
configured,
hotWalletAddress,
};
}
}

View File

@ -0,0 +1,2 @@
export * from './request';
export * from './response';

View File

@ -0,0 +1,19 @@
import { IsString, IsNumberString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class DeriveAddressDto {
@ApiProperty({ description: '用户ID', example: '12345' })
@IsNumberString()
userId: string;
@ApiProperty({ description: '账户序列号 (格式: D + YYMMDD + 5位序号)', example: 'D2512110008' })
@IsString()
accountSequence: string;
@ApiProperty({
description: '压缩公钥 (33 bytes, 0x02/0x03 开头)',
example: '0x02abc123...',
})
@IsString()
publicKey: string;
}

View File

@ -0,0 +1,6 @@
export * from './query-balance.dto';
export * from './derive-address.dto';
export * from './verify-mnemonic.dto';
export * from './verify-mnemonic-hash.dto';
export * from './mark-mnemonic-backup.dto';
export * from './revoke-mnemonic.dto';

View File

@ -0,0 +1,8 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class MarkMnemonicBackupDto {
@ApiProperty({ description: '账户序列号 (格式: D + YYMMDD + 5位序号)', example: 'D2512110008' })
@IsString()
accountSequence: string;
}

View File

@ -0,0 +1,34 @@
import { IsString, IsOptional, IsEnum, IsArray } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ChainTypeEnum } from '@/domain/enums';
export class QueryBalanceDto {
@ApiProperty({ description: '钱包地址', example: '0x1234...' })
@IsString()
address: string;
@ApiPropertyOptional({
description: '链类型',
enum: ChainTypeEnum,
example: ChainTypeEnum.KAVA,
})
@IsOptional()
@IsEnum(ChainTypeEnum)
chainType?: ChainTypeEnum;
}
export class QueryMultiChainBalanceDto {
@ApiProperty({ description: '钱包地址', example: '0x1234...' })
@IsString()
address: string;
@ApiPropertyOptional({
description: '链类型列表',
type: [String],
enum: ChainTypeEnum,
})
@IsOptional()
@IsArray()
@IsEnum(ChainTypeEnum, { each: true })
chainTypes?: ChainTypeEnum[];
}

View File

@ -0,0 +1,15 @@
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RevokeMnemonicDto {
@ApiProperty({ example: 'D2512110001', description: '账户序列号' })
@IsString()
@IsNotEmpty()
accountSequence: string;
@ApiProperty({ example: '助记词泄露', description: '挂失原因' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
reason: string;
}

View File

@ -0,0 +1,18 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class VerifyMnemonicHashDto {
@ApiProperty({
description: '账户序列号 (格式: D + YYMMDD + 5位序号)',
example: 'D2512110008',
})
@IsString()
accountSequence: string;
@ApiProperty({
description: '助记词 (12个单词空格分隔)',
example: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
})
@IsString()
mnemonic: string;
}

View File

@ -0,0 +1,22 @@
import { IsString, IsArray, ArrayMinSize } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class VerifyMnemonicDto {
@ApiProperty({
description: '助记词 (12或24个单词空格分隔)',
example: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
})
@IsString()
mnemonic: string;
@ApiProperty({
description: '期望的钱包地址列表,用于验证助记词',
example: [{ chainType: 'KAVA', address: 'kava1abc...' }],
})
@IsArray()
@ArrayMinSize(1)
expectedAddresses: Array<{
chainType: string;
address: string;
}>;
}

View File

@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
export class DerivedAddressDto {
@ApiProperty({ description: '链类型' })
chainType: string;
@ApiProperty({ description: '钱包地址' })
address: string;
}
export class DeriveAddressResponseDto {
@ApiProperty({ description: '用户ID' })
userId: string;
@ApiProperty({ description: '公钥' })
publicKey: string;
@ApiProperty({ description: '派生的地址列表', type: [DerivedAddressDto] })
addresses: DerivedAddressDto[];
}

View File

@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
export class BalanceResponseDto {
@ApiProperty({ description: '链类型' })
chainType: string;
@ApiProperty({ description: '钱包地址' })
address: string;
@ApiProperty({ description: 'USDT 余额' })
usdtBalance: string;
@ApiProperty({ description: '原生代币余额' })
nativeBalance: string;
@ApiProperty({ description: '原生代币符号' })
nativeSymbol: string;
}
export class MultiChainBalanceResponseDto {
@ApiProperty({ description: '钱包地址' })
address: string;
@ApiProperty({ description: '各链余额', type: [BalanceResponseDto] })
balances: BalanceResponseDto[];
}

View File

@ -0,0 +1,2 @@
export * from './balance.dto';
export * from './address.dto';

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ApiModule } from '@/api/api.module';
import { appConfig, databaseConfig, redisConfig, kafkaConfig, blockchainConfig } from '@/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig, redisConfig, kafkaConfig, blockchainConfig],
}),
ScheduleModule.forRoot(),
ApiModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
import { DomainModule } from '@/domain/domain.module';
import { MpcTransferInitializerService } from './services/mpc-transfer-initializer.service';
@Module({
imports: [InfrastructureModule, DomainModule],
providers: [
// MPC 签名客户端注入
MpcTransferInitializerService,
],
exports: [],
})
export class ApplicationModule {}

View File

@ -0,0 +1,2 @@
export * from './mpc-keygen-completed.handler';
export * from './withdrawal-requested.handler';

View File

@ -0,0 +1,74 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { AddressDerivationService } from '../services/address-derivation.service';
import { MpcEventConsumerService, KeygenCompletedPayload } from '@/infrastructure/kafka/mpc-event-consumer.service';
/**
* MPC
*
* mpc.KeygenCompleted
* blockchain.WalletAddressCreated identity-service
*/
@Injectable()
export class MpcKeygenCompletedHandler implements OnModuleInit {
private readonly logger = new Logger(MpcKeygenCompletedHandler.name);
constructor(
private readonly addressDerivationService: AddressDerivationService,
private readonly mpcEventConsumer: MpcEventConsumerService,
) {}
onModuleInit() {
// Register handler for keygen completed events
this.mpcEventConsumer.onKeygenCompleted(this.handleKeygenCompleted.bind(this));
this.logger.log(`[INIT] MpcKeygenCompletedHandler registered with MpcEventConsumer`);
}
/**
* MPC
* mpc-service KeygenCompleted publicKeyuserId accountSequence
*/
private async handleKeygenCompleted(payload: KeygenCompletedPayload): Promise<void> {
this.logger.log(`[HANDLE] Received KeygenCompleted event`);
this.logger.log(`[HANDLE] sessionId: ${payload.sessionId}`);
this.logger.log(`[HANDLE] publicKey: ${payload.publicKey?.substring(0, 30)}...`);
this.logger.log(`[HANDLE] extraPayload: ${JSON.stringify(payload.extraPayload)}`);
// Extract userId and accountSequence from extraPayload
const userId = payload.extraPayload?.userId;
const accountSequence = payload.extraPayload?.accountSequence;
if (!userId) {
this.logger.error(`[ERROR] Missing userId in extraPayload, cannot derive addresses`);
return;
}
if (!accountSequence) {
this.logger.error(`[ERROR] Missing accountSequence in extraPayload, cannot derive addresses`);
return;
}
const publicKey = payload.publicKey;
if (!publicKey) {
this.logger.error(`[ERROR] Missing publicKey in payload, cannot derive addresses`);
return;
}
try {
this.logger.log(`[DERIVE] Starting address derivation for user: ${userId}, account: ${accountSequence}`);
const result = await this.addressDerivationService.deriveAndRegister({
userId: BigInt(userId),
accountSequence: accountSequence,
publicKey,
});
this.logger.log(`[DERIVE] Successfully derived ${result.addresses.length} addresses for account ${accountSequence}`);
result.addresses.forEach((addr) => {
this.logger.log(`[DERIVE] - ${addr.chainType}: ${addr.address}`);
});
} catch (error) {
this.logger.error(`[ERROR] Failed to derive addresses for account ${accountSequence}:`, error);
throw error;
}
}
}

View File

@ -0,0 +1,140 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
WithdrawalEventConsumerService,
SystemWithdrawalRequestedPayload,
} from '@/infrastructure/kafka/withdrawal-event-consumer.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { Erc20TransferService } from '@/domain/services/erc20-transfer.service';
import { ChainTypeEnum } from '@/domain/enums';
/**
* System Withdrawal Requested Event Handler
*
* Handles system account withdrawal requests from wallet-service.
* Executes ERC20 USDT transfers from hot wallet to user's address.
*/
@Injectable()
export class SystemWithdrawalRequestedHandler implements OnModuleInit {
private readonly logger = new Logger(SystemWithdrawalRequestedHandler.name);
constructor(
private readonly withdrawalEventConsumer: WithdrawalEventConsumerService,
private readonly eventPublisher: EventPublisherService,
private readonly transferService: Erc20TransferService,
) {}
onModuleInit() {
this.withdrawalEventConsumer.onSystemWithdrawalRequested(
this.handleSystemWithdrawalRequested.bind(this),
);
this.logger.log(`[INIT] SystemWithdrawalRequestedHandler registered`);
}
/**
* Handle system withdrawal requested event from wallet-service
*
* Flow:
* 1. Receive system withdrawal request
* 2. Execute ERC20 transfer from hot wallet
* 3. Publish final status (CONFIRMED or FAILED)
*/
private async handleSystemWithdrawalRequested(
payload: SystemWithdrawalRequestedPayload,
): Promise<void> {
this.logger.log(`[HANDLE] ========== System Withdrawal Request ==========`);
this.logger.log(`[HANDLE] orderNo: ${payload.orderNo}`);
this.logger.log(`[HANDLE] fromAccountSequence: ${payload.fromAccountSequence}`);
this.logger.log(`[HANDLE] fromAccountName: ${payload.fromAccountName}`);
this.logger.log(`[HANDLE] toAccountSequence: ${payload.toAccountSequence}`);
this.logger.log(`[HANDLE] toAddress: ${payload.toAddress}`);
this.logger.log(`[HANDLE] amount: ${payload.amount}`);
this.logger.log(`[HANDLE] chainType: ${payload.chainType}`);
try {
// Step 1: 验证链类型
const chainType = this.parseChainType(payload.chainType);
if (!chainType) {
throw new Error(`Unsupported chain type: ${payload.chainType}`);
}
// Step 2: 检查转账服务是否配置
if (!this.transferService.isConfigured(chainType)) {
throw new Error(`Hot wallet not configured for chain: ${chainType}`);
}
// Step 3: 执行 ERC20 转账
this.logger.log(`[PROCESS] Executing ERC20 transfer for system withdrawal...`);
const result = await this.transferService.transferUsdt(
chainType,
payload.toAddress,
payload.amount,
);
if (result.success && result.txHash) {
// Step 4a: 转账成功,发布确认状态
this.logger.log(`[SUCCESS] System withdrawal ${payload.orderNo} confirmed!`);
this.logger.log(`[SUCCESS] TxHash: ${result.txHash}`);
this.logger.log(`[SUCCESS] Block: ${result.blockNumber}`);
await this.eventPublisher.publish({
eventType: 'blockchain.system-withdrawal.confirmed',
toPayload: () => ({
orderNo: payload.orderNo,
fromAccountSequence: payload.fromAccountSequence,
fromAccountName: payload.fromAccountName,
toAccountSequence: payload.toAccountSequence,
status: 'CONFIRMED',
txHash: result.txHash,
blockNumber: result.blockNumber,
chainType: payload.chainType,
toAddress: payload.toAddress,
amount: payload.amount,
}),
eventId: `sys-wd-confirmed-${payload.orderNo}-${Date.now()}`,
occurredAt: new Date(),
});
this.logger.log(`[COMPLETE] System withdrawal ${payload.orderNo} completed successfully`);
} else {
// Step 4b: 转账失败
throw new Error(result.error || 'Transfer failed');
}
} catch (error) {
this.logger.error(
`[ERROR] Failed to process system withdrawal ${payload.orderNo}`,
error,
);
// 发布失败事件
await this.eventPublisher.publish({
eventType: 'blockchain.system-withdrawal.failed',
toPayload: () => ({
orderNo: payload.orderNo,
fromAccountSequence: payload.fromAccountSequence,
fromAccountName: payload.fromAccountName,
toAccountSequence: payload.toAccountSequence,
status: 'FAILED',
error: error instanceof Error ? error.message : 'Unknown error',
chainType: payload.chainType,
toAddress: payload.toAddress,
amount: payload.amount,
}),
eventId: `sys-wd-failed-${payload.orderNo}-${Date.now()}`,
occurredAt: new Date(),
});
throw error;
}
}
/**
*
*/
private parseChainType(chainType: string): ChainTypeEnum | null {
const normalized = chainType.toUpperCase();
if (normalized === 'KAVA') return ChainTypeEnum.KAVA;
if (normalized === 'BSC') return ChainTypeEnum.BSC;
return null;
}
}

View File

@ -0,0 +1,165 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
WithdrawalEventConsumerService,
WithdrawalRequestedPayload,
} from '@/infrastructure/kafka/withdrawal-event-consumer.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { Erc20TransferService } from '@/domain/services/erc20-transfer.service';
import { ChainTypeEnum } from '@/domain/enums';
/**
* Withdrawal Requested Event Handler
*
* Handles withdrawal requests from wallet-service.
* Executes ERC20 USDT transfers on the specified chain (KAVA/BSC).
*/
@Injectable()
export class WithdrawalRequestedHandler implements OnModuleInit {
private readonly logger = new Logger(WithdrawalRequestedHandler.name);
constructor(
private readonly withdrawalEventConsumer: WithdrawalEventConsumerService,
private readonly eventPublisher: EventPublisherService,
private readonly transferService: Erc20TransferService,
) {}
onModuleInit() {
this.withdrawalEventConsumer.onWithdrawalRequested(
this.handleWithdrawalRequested.bind(this),
);
this.logger.log(`[INIT] WithdrawalRequestedHandler registered`);
}
/**
* Handle withdrawal requested event from wallet-service
*
* Flow:
* 1. Receive withdrawal request
* 2. Publish "PROCESSING" status
* 3. Execute ERC20 transfer
* 4. Publish final status (CONFIRMED or FAILED)
*/
private async handleWithdrawalRequested(
payload: WithdrawalRequestedPayload,
): Promise<void> {
this.logger.log(`[HANDLE] ========== Withdrawal Request ==========`);
this.logger.log(`[HANDLE] orderNo: ${payload.orderNo}`);
this.logger.log(`[HANDLE] accountSequence: ${payload.accountSequence}`);
this.logger.log(`[HANDLE] userId: ${payload.userId}`);
this.logger.log(`[HANDLE] chainType: ${payload.chainType}`);
this.logger.log(`[HANDLE] toAddress: ${payload.toAddress}`);
this.logger.log(`[HANDLE] amount: ${payload.amount}`);
this.logger.log(`[HANDLE] fee: ${payload.fee}`);
this.logger.log(`[HANDLE] netAmount: ${payload.netAmount}`);
try {
// Step 1: 验证链类型
const chainType = this.parseChainType(payload.chainType);
if (!chainType) {
throw new Error(`Unsupported chain type: ${payload.chainType}`);
}
// Step 2: 检查转账服务是否配置
if (!this.transferService.isConfigured(chainType)) {
throw new Error(`Hot wallet not configured for chain: ${chainType}`);
}
// Step 3: 发布处理中状态
this.logger.log(`[PROCESS] Starting withdrawal ${payload.orderNo}`);
await this.publishStatus(payload, 'PROCESSING', 'Withdrawal is being processed');
// Step 4: 执行 ERC20 转账
this.logger.log(`[PROCESS] Executing ERC20 transfer...`);
const result = await this.transferService.transferUsdt(
chainType,
payload.toAddress,
payload.netAmount.toString(),
);
if (result.success && result.txHash) {
// Step 5a: 转账成功,发布确认状态
this.logger.log(`[SUCCESS] Withdrawal ${payload.orderNo} confirmed!`);
this.logger.log(`[SUCCESS] TxHash: ${result.txHash}`);
this.logger.log(`[SUCCESS] Block: ${result.blockNumber}`);
await this.eventPublisher.publish({
eventType: 'blockchain.withdrawal.confirmed',
toPayload: () => ({
orderNo: payload.orderNo,
accountSequence: payload.accountSequence,
userId: payload.userId,
status: 'CONFIRMED',
txHash: result.txHash,
blockNumber: result.blockNumber,
chainType: payload.chainType,
toAddress: payload.toAddress,
netAmount: payload.netAmount,
}),
eventId: `wd-confirmed-${payload.orderNo}-${Date.now()}`,
occurredAt: new Date(),
});
this.logger.log(`[COMPLETE] Withdrawal ${payload.orderNo} completed successfully`);
} else {
// Step 5b: 转账失败
throw new Error(result.error || 'Transfer failed');
}
} catch (error) {
this.logger.error(
`[ERROR] Failed to process withdrawal ${payload.orderNo}`,
error,
);
// 发布失败事件
await this.eventPublisher.publish({
eventType: 'blockchain.withdrawal.failed',
toPayload: () => ({
orderNo: payload.orderNo,
accountSequence: payload.accountSequence,
userId: payload.userId,
status: 'FAILED',
error: error instanceof Error ? error.message : 'Unknown error',
chainType: payload.chainType,
toAddress: payload.toAddress,
netAmount: payload.netAmount,
}),
eventId: `wd-failed-${payload.orderNo}-${Date.now()}`,
occurredAt: new Date(),
});
throw error;
}
}
/**
*
*/
private async publishStatus(
payload: WithdrawalRequestedPayload,
status: string,
message: string,
): Promise<void> {
await this.eventPublisher.publish({
eventType: 'blockchain.withdrawal.status',
toPayload: () => ({
orderNo: payload.orderNo,
accountSequence: payload.accountSequence,
status,
message,
}),
eventId: `wd-status-${payload.orderNo}-${status}-${Date.now()}`,
occurredAt: new Date(),
});
}
/**
*
*/
private parseChainType(chainType: string): ChainTypeEnum | null {
const normalized = chainType.toUpperCase();
if (normalized === 'KAVA') return ChainTypeEnum.KAVA;
if (normalized === 'BSC') return ChainTypeEnum.BSC;
return null;
}
}

View File

@ -0,0 +1,122 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { Erc20TransferService } from '@/domain/services/erc20-transfer.service';
import { EvmProviderAdapter } from '@/infrastructure/blockchain/evm-provider.adapter';
import { ChainType } from '@/domain/value-objects';
import { ChainTypeEnum } from '@/domain/enums';
import Redis from 'ioredis';
/**
*
* [2026-01-07] (KAVA)
*
* 5 Redis
* - dUSDT (绿)
* - (KAVA/BNB)
*
* wallet-service
* reporting-service
*
* 使 Redis DB 0便
*
* Redis Key :
* - hot_wallet:dusdt_balance:{chainType} - dUSDT
* - hot_wallet:native_balance:{chainType} - (KAVA/BNB)
* Redis Value: 余额字符串 "10000.00"
* TTL: 30
*
*
*/
@Injectable()
export class HotWalletBalanceScheduler implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(HotWalletBalanceScheduler.name);
// Redis key 前缀
private readonly REDIS_KEY_PREFIX_DUSDT = 'hot_wallet:dusdt_balance:';
private readonly REDIS_KEY_PREFIX_NATIVE = 'hot_wallet:native_balance:';
// 缓存过期时间(秒)
private readonly CACHE_TTL_SECONDS = 30;
// 支持的链类型
private readonly SUPPORTED_CHAINS = [ChainTypeEnum.KAVA, ChainTypeEnum.BSC];
// 使用独立的 Redis 连接,连接到 DB 0公共数据库
private readonly sharedRedis: Redis;
constructor(
private readonly configService: ConfigService,
private readonly transferService: Erc20TransferService,
private readonly evmProvider: EvmProviderAdapter,
) {
// 创建连接到 DB 0 的 Redis 客户端(公共数据库,所有服务可读取)
this.sharedRedis = new Redis({
host: this.configService.get<string>('redis.host') || 'localhost',
port: this.configService.get<number>('redis.port') || 6379,
password: this.configService.get<string>('redis.password') || undefined,
db: 0, // 使用 DB 0 作为公共数据库
});
this.sharedRedis.on('connect', () => {
this.logger.log('[REDIS] Connected to shared Redis DB 0 for hot wallet balance');
});
this.sharedRedis.on('error', (err) => {
this.logger.error('[REDIS] Shared Redis connection error', err);
});
}
onModuleDestroy() {
this.sharedRedis.disconnect();
}
async onModuleInit() {
this.logger.log('[INIT] HotWalletBalanceScheduler initialized');
// 启动时立即执行一次
await this.updateHotWalletBalances();
}
/**
* 5 Redis
*/
@Cron('*/5 * * * * *') // 每 5 秒执行
async updateHotWalletBalances(): Promise<void> {
for (const chainType of this.SUPPORTED_CHAINS) {
try {
// 检查该链是否已配置
if (!this.transferService.isConfigured(chainType)) {
this.logger.debug(`[SKIP] Chain ${chainType} not configured, skipping balance update`);
continue;
}
// 获取热钱包地址
const hotWalletAddress = this.transferService.getHotWalletAddress(chainType);
if (!hotWalletAddress) {
this.logger.debug(`[SKIP] Hot wallet address not configured for ${chainType}`);
continue;
}
// 查询 dUSDT 余额
const dusdtBalance = await this.transferService.getHotWalletBalance(chainType);
const dusdtKey = `${this.REDIS_KEY_PREFIX_DUSDT}${chainType}`;
await this.sharedRedis.setex(dusdtKey, this.CACHE_TTL_SECONDS, dusdtBalance);
// [2026-01-07] 新增:查询原生代币余额 (KAVA/BNB)
const nativeBalance = await this.evmProvider.getNativeBalance(
ChainType.fromEnum(chainType),
hotWalletAddress,
);
const nativeKey = `${this.REDIS_KEY_PREFIX_NATIVE}${chainType}`;
await this.sharedRedis.setex(nativeKey, this.CACHE_TTL_SECONDS, nativeBalance.formatted);
this.logger.debug(
`[UPDATE] ${chainType} hot wallet: dUSDT=${dusdtBalance}, native=${nativeBalance.formatted}`,
);
} catch (error) {
this.logger.error(`[ERROR] Failed to update ${chainType} hot wallet balance`, error);
// 单链失败不影响其他链的更新
}
}
}
}

View File

@ -0,0 +1 @@
export * from './hot-wallet-balance.scheduler';

View File

@ -0,0 +1,183 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import {
AddressDerivationAdapter,
DerivedAddress,
} from '@/infrastructure/blockchain/address-derivation.adapter';
import { RecoveryMnemonicAdapter } from '@/infrastructure/blockchain/recovery-mnemonic.adapter';
import { AddressCacheService } from '@/infrastructure/redis/address-cache.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import {
MONITORED_ADDRESS_REPOSITORY,
IMonitoredAddressRepository,
} from '@/domain/repositories/monitored-address.repository.interface';
import { MonitoredAddress } from '@/domain/aggregates/monitored-address';
import { WalletAddressCreatedEvent } from '@/domain/events';
import { ChainType, EvmAddress } from '@/domain/value-objects';
import { ChainTypeEnum } from '@/domain/enums';
export interface DeriveAddressParams {
userId: bigint;
accountSequence: string;
publicKey: string;
}
export interface DeriveAddressResult {
userId: bigint;
accountSequence: string;
publicKey: string;
addresses: DerivedAddress[];
}
/**
*
* MPC
*
*
* - KAVA: EVM (0x...) - Kava EVM
* - DST: Cosmos bech32 (dst1...)
* - BSC: EVM (0x...)
*
*
* - EVM (BSC, KAVA)
* - Cosmos (DST)
*/
@Injectable()
export class AddressDerivationService {
private readonly logger = new Logger(AddressDerivationService.name);
// EVM 链类型列表,用于判断是否需要注册监控
private readonly evmChains = new Set([ChainTypeEnum.BSC, ChainTypeEnum.KAVA]);
constructor(
private readonly addressDerivation: AddressDerivationAdapter,
private readonly recoveryMnemonic: RecoveryMnemonicAdapter,
private readonly addressCache: AddressCacheService,
private readonly eventPublisher: EventPublisherService,
private readonly prisma: PrismaService,
@Inject(MONITORED_ADDRESS_REPOSITORY)
private readonly monitoredAddressRepo: IMonitoredAddressRepository,
) {}
/**
*
*/
async deriveAndRegister(params: DeriveAddressParams): Promise<DeriveAddressResult> {
const { userId, accountSequence, publicKey } = params;
this.logger.log(`[DERIVE] Starting address derivation for user ${userId}, account ${accountSequence}`);
this.logger.log(`[DERIVE] Public key: ${publicKey.substring(0, 30)}...`);
// 1. 派生所有链的地址 (包括 Cosmos 和 EVM)
const derivedAddresses = this.addressDerivation.deriveAllAddresses(publicKey);
this.logger.log(`[DERIVE] Derived ${derivedAddresses.length} addresses`);
// 2. 只为 EVM 链注册监控地址 (用于充值检测)
for (const derived of derivedAddresses) {
if (this.evmChains.has(derived.chainType)) {
await this.registerEvmAddressForMonitoring(userId, accountSequence, derived);
} else {
this.logger.log(`[DERIVE] Skipping monitoring registration for Cosmos chain: ${derived.chainType} - ${derived.address}`);
}
}
// 3. 生成恢复助记词 (与账户序列号关联)
this.logger.log(`[MNEMONIC] Generating recovery mnemonic for account ${accountSequence}`);
const mnemonicResult = await this.recoveryMnemonic.generateMnemonic({
userId: userId.toString(),
publicKey,
});
this.logger.log(`[MNEMONIC] Recovery mnemonic generated, hash: ${mnemonicResult.mnemonicHash.slice(0, 16)}...`);
// 4. 存储恢复助记词到 blockchain-service 数据库 (使用 accountSequence 关联)
// 检查是否已存在,避免重复创建
const existingMnemonic = await this.prisma.recoveryMnemonic.findFirst({
where: {
accountSequence,
status: 'ACTIVE',
},
});
if (existingMnemonic) {
this.logger.warn(`[MNEMONIC] Recovery mnemonic already exists for account ${accountSequence}, skipping creation`);
} else {
await this.prisma.recoveryMnemonic.create({
data: {
accountSequence,
publicKey,
encryptedMnemonic: mnemonicResult.encryptedMnemonic,
mnemonicHash: mnemonicResult.mnemonicHash,
status: 'ACTIVE',
isBackedUp: false,
},
});
this.logger.log(`[MNEMONIC] Recovery mnemonic saved for account ${accountSequence}`);
}
// 5. 发布钱包地址创建事件 (包含所有链的地址和助记词)
const event = new WalletAddressCreatedEvent({
userId: userId.toString(),
accountSequence,
publicKey,
addresses: derivedAddresses.map((a) => ({
chainType: a.chainType,
address: a.address,
})),
// 恢复助记词 (明文仅在事件中传递给客户端首次显示)
mnemonic: mnemonicResult.mnemonic,
encryptedMnemonic: mnemonicResult.encryptedMnemonic,
mnemonicHash: mnemonicResult.mnemonicHash,
});
this.logger.log(`[PUBLISH] Publishing WalletAddressCreated event for account ${accountSequence}`);
this.logger.log(`[PUBLISH] Addresses: ${JSON.stringify(derivedAddresses)}`);
await this.eventPublisher.publish(event);
this.logger.log(`[PUBLISH] WalletAddressCreated event published successfully`);
return {
userId,
accountSequence,
publicKey,
addresses: derivedAddresses,
};
}
/**
* EVM
*/
private async registerEvmAddressForMonitoring(
userId: bigint,
accountSequence: string,
derived: DerivedAddress,
): Promise<void> {
const chainType = ChainType.fromEnum(derived.chainType);
const address = EvmAddress.create(derived.address);
// 检查是否已存在
const exists = await this.monitoredAddressRepo.existsByChainAndAddress(chainType, address);
if (!exists) {
// 创建监控地址 - 使用 accountSequence 作为跨服务关联键
const monitored = MonitoredAddress.create({
chainType,
address,
accountSequence,
userId,
});
await this.monitoredAddressRepo.save(monitored);
// 添加到缓存
await this.addressCache.addAddress(chainType, address.lowercase);
this.logger.log(`[MONITOR] Registered EVM address for monitoring: ${derived.chainType} - ${derived.address} (account ${accountSequence})`);
} else {
this.logger.debug(`[MONITOR] Address already registered: ${derived.chainType} - ${derived.address}`);
}
}
/**
*
*/
async getUserAddresses(userId: bigint): Promise<MonitoredAddress[]> {
return this.monitoredAddressRepo.findByUserId(userId);
}
}

View File

@ -0,0 +1,93 @@
import { Injectable, Logger } from '@nestjs/common';
import { EvmProviderAdapter } from '@/infrastructure/blockchain/evm-provider.adapter';
import { ChainConfigService } from '@/domain/services/chain-config.service';
import { ChainType } from '@/domain/value-objects';
import { ChainTypeEnum } from '@/domain/enums';
export interface BalanceResult {
chainType: string;
address: string;
usdtBalance: string;
usdtRawBalance: string;
usdtDecimals: number;
nativeBalance: string;
nativeSymbol: string;
}
/**
*
*/
@Injectable()
export class BalanceQueryService {
private readonly logger = new Logger(BalanceQueryService.name);
constructor(
private readonly evmProvider: EvmProviderAdapter,
private readonly chainConfig: ChainConfigService,
) {}
/**
*
*/
async getBalance(chainType: ChainType, address: string): Promise<BalanceResult> {
const config = this.chainConfig.getConfig(chainType);
const [usdtBalance, nativeBalance] = await Promise.all([
this.evmProvider.getTokenBalance(chainType, config.usdtContract, address),
this.evmProvider.getNativeBalance(chainType, address),
]);
return {
chainType: chainType.toString(),
address,
usdtBalance: usdtBalance.formatted,
usdtRawBalance: usdtBalance.raw.toString(),
usdtDecimals: usdtBalance.decimals,
nativeBalance: nativeBalance.formatted,
nativeSymbol: config.nativeSymbol,
};
}
/**
*
*/
async getBalances(address: string, chainTypes?: ChainTypeEnum[]): Promise<BalanceResult[]> {
const chains = chainTypes || this.chainConfig.getSupportedChains();
const results: BalanceResult[] = [];
for (const chainTypeEnum of chains) {
try {
const chainType = ChainType.fromEnum(chainTypeEnum);
const balance = await this.getBalance(chainType, address);
results.push(balance);
} catch (error) {
this.logger.error(`Error querying balance for ${chainTypeEnum}:`, error);
}
}
return results;
}
/**
*
*/
async getBatchBalances(
chainType: ChainType,
addresses: string[],
): Promise<Map<string, BalanceResult>> {
const results = new Map<string, BalanceResult>();
await Promise.all(
addresses.map(async (address) => {
try {
const balance = await this.getBalance(chainType, address);
results.set(address.toLowerCase(), balance);
} catch (error) {
this.logger.error(`Error querying balance for ${address}:`, error);
}
}),
);
return results;
}
}

View File

@ -0,0 +1,253 @@
import { Injectable, Logger, Inject, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import {
BlockScannerService,
DepositEvent,
} from '@/infrastructure/blockchain/block-scanner.service';
import { EvmProviderAdapter } from '@/infrastructure/blockchain/evm-provider.adapter';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { AddressCacheService } from '@/infrastructure/redis/address-cache.service';
import { ConfirmationPolicyService } from '@/domain/services/confirmation-policy.service';
import { ChainConfigService } from '@/domain/services/chain-config.service';
import { Erc20TransferService } from '@/domain/services/erc20-transfer.service';
import {
DEPOSIT_TRANSACTION_REPOSITORY,
IDepositTransactionRepository,
} from '@/domain/repositories/deposit-transaction.repository.interface';
import {
MONITORED_ADDRESS_REPOSITORY,
IMonitoredAddressRepository,
} from '@/domain/repositories/monitored-address.repository.interface';
import {
BLOCK_CHECKPOINT_REPOSITORY,
IBlockCheckpointRepository,
} from '@/domain/repositories/block-checkpoint.repository.interface';
import {
OUTBOX_EVENT_REPOSITORY,
IOutboxEventRepository,
} from '@/domain/repositories/outbox-event.repository.interface';
import { DepositTransaction } from '@/domain/aggregates/deposit-transaction';
import { ChainType, TxHash, EvmAddress, TokenAmount, BlockNumber } from '@/domain/value-objects';
import { DepositConfirmedEvent } from '@/domain/events';
/**
*
*
*/
@Injectable()
export class DepositDetectionService implements OnModuleInit {
private readonly logger = new Logger(DepositDetectionService.name);
constructor(
private readonly blockScanner: BlockScannerService,
private readonly evmProvider: EvmProviderAdapter,
private readonly eventPublisher: EventPublisherService,
private readonly addressCache: AddressCacheService,
private readonly confirmationPolicy: ConfirmationPolicyService,
private readonly chainConfig: ChainConfigService,
private readonly transferService: Erc20TransferService,
@Inject(DEPOSIT_TRANSACTION_REPOSITORY)
private readonly depositRepo: IDepositTransactionRepository,
@Inject(MONITORED_ADDRESS_REPOSITORY)
private readonly monitoredAddressRepo: IMonitoredAddressRepository,
@Inject(BLOCK_CHECKPOINT_REPOSITORY)
private readonly checkpointRepo: IBlockCheckpointRepository,
@Inject(OUTBOX_EVENT_REPOSITORY)
private readonly outboxRepo: IOutboxEventRepository,
) {}
async onModuleInit() {
// 初始化地址缓存
await this.initializeAddressCache();
this.logger.log('DepositDetectionService initialized');
}
/**
*
*/
private async initializeAddressCache(): Promise<void> {
for (const chainTypeEnum of this.chainConfig.getSupportedChains()) {
const chainType = ChainType.fromEnum(chainTypeEnum);
const addresses = await this.monitoredAddressRepo.getAllActiveAddresses(chainType);
await this.addressCache.reloadCache(chainType, addresses);
this.logger.log(`Loaded ${addresses.length} addresses for ${chainTypeEnum} into cache`);
}
}
/**
* 5
*/
@Cron(CronExpression.EVERY_5_SECONDS)
async scanBlocks(): Promise<void> {
for (const chainTypeEnum of this.chainConfig.getSupportedChains()) {
try {
await this.scanChain(ChainType.fromEnum(chainTypeEnum));
} catch (error) {
this.logger.error(`Error scanning ${chainTypeEnum}:`, error);
await this.checkpointRepo.recordError(
ChainType.fromEnum(chainTypeEnum),
error instanceof Error ? error.message : 'Unknown error',
);
}
}
}
/**
*
*/
private async scanChain(chainType: ChainType): Promise<void> {
// 检查缓存是否为空,如果为空则自动从数据库重新加载
const cacheCount = await this.addressCache.getCount(chainType);
if (cacheCount === 0) {
this.logger.warn(`Address cache empty for ${chainType}, reloading from database...`);
const addresses = await this.monitoredAddressRepo.getAllActiveAddresses(chainType);
await this.addressCache.reloadCache(chainType, addresses);
this.logger.log(`Reloaded ${addresses.length} addresses for ${chainType} into cache`);
}
// 获取上次扫描位置
let lastBlock = await this.checkpointRepo.getLastScannedBlock(chainType);
if (!lastBlock) {
// 首次扫描,从当前区块开始
const currentBlock = await this.evmProvider.getCurrentBlockNumber(chainType);
lastBlock = currentBlock.subtract(10); // 从10个块前开始
await this.checkpointRepo.initializeIfNotExists(chainType, lastBlock);
}
// 执行扫描
const { deposits, newLastBlock } = await this.blockScanner.executeScan(chainType, lastBlock);
// 处理检测到的充值
for (const deposit of deposits) {
await this.processDeposit(deposit);
}
// 更新检查点
if (newLastBlock.isGreaterThan(lastBlock)) {
await this.checkpointRepo.updateCheckpoint(chainType, newLastBlock);
}
}
/**
*
*/
private async processDeposit(event: DepositEvent): Promise<void> {
const txHash = TxHash.create(event.txHash);
// 检查是否已处理
if (await this.depositRepo.existsByTxHash(txHash)) {
this.logger.debug(`Deposit already processed: ${event.txHash}`);
return;
}
const chainType = ChainType.fromEnum(event.chainType);
// 过滤从热钱包发出的转账(内部转账/提现),避免重复入账
const hotWalletAddress = this.transferService.getHotWalletAddress(event.chainType);
if (hotWalletAddress && event.from.toLowerCase() === hotWalletAddress.toLowerCase()) {
this.logger.debug(`Skipping hot wallet outgoing transfer: ${event.txHash} from ${event.from}`);
return;
}
// 查找监控地址以获取用户ID或系统账户信息
const monitoredAddress = await this.monitoredAddressRepo.findByChainAndAddress(
chainType,
EvmAddress.fromUnchecked(event.to),
);
if (!monitoredAddress || !monitoredAddress.id) {
this.logger.warn(`Monitored address not found: ${event.to}`);
return;
}
// 获取代币的实际 decimalsUSDT 通常是 6 位,而不是 18 位)
const tokenDecimals = await this.evmProvider.getTokenDecimals(chainType, event.tokenContract);
// 创建充值记录 - 用户地址
const deposit = DepositTransaction.create({
chainType,
txHash,
fromAddress: EvmAddress.fromUnchecked(event.from),
toAddress: EvmAddress.fromUnchecked(event.to),
tokenContract: EvmAddress.fromUnchecked(event.tokenContract),
amount: TokenAmount.fromRaw(event.value, tokenDecimals),
blockNumber: BlockNumber.create(event.blockNumber),
blockTimestamp: event.blockTimestamp,
logIndex: event.logIndex,
addressId: monitoredAddress.id,
accountSequence: monitoredAddress.accountSequence,
userId: monitoredAddress.userId,
});
// 保存
await this.depositRepo.save(deposit);
// 发布事件
for (const domainEvent of deposit.domainEvents) {
await this.eventPublisher.publish(domainEvent);
}
deposit.clearDomainEvents();
this.logger.log(
`User deposit saved: ${txHash.toShort()} -> ${event.to} (${deposit.amount.formatted} USDT)`,
);
}
/**
* 30
*/
@Cron(CronExpression.EVERY_30_SECONDS)
async updateConfirmations(): Promise<void> {
for (const chainTypeEnum of this.chainConfig.getSupportedChains()) {
try {
await this.updateChainConfirmations(ChainType.fromEnum(chainTypeEnum));
} catch (error) {
this.logger.error(`Error updating confirmations for ${chainTypeEnum}:`, 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) {
deposit.updateConfirmations(currentBlock, requiredConfirmations);
await this.depositRepo.save(deposit);
// 处理领域事件
for (const event of deposit.domainEvents) {
if (event instanceof DepositConfirmedEvent) {
// 重要事件写入 outbox保证可靠投递
await this.outboxRepo.create({
eventType: event.eventType,
aggregateId: deposit.id?.toString() || deposit.txHash.toString(),
aggregateType: 'DepositTransaction',
payload: event.toPayload(),
});
this.logger.log(
`Deposit confirmed event saved to outbox: ${deposit.txHash.toShort()} (${deposit.confirmations} confirmations)`,
);
} else {
// 非关键事件直接发送(如 DepositDetectedEvent
await this.eventPublisher.publish(event);
}
}
deposit.clearDomainEvents();
if (deposit.isConfirmed) {
this.logger.log(
`Deposit confirmed: ${deposit.txHash.toShort()} (${deposit.confirmations} confirmations)`,
);
}
}
}
}

View File

@ -0,0 +1,195 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import {
DEPOSIT_TRANSACTION_REPOSITORY,
IDepositTransactionRepository,
} from '@/domain/repositories/deposit-transaction.repository.interface';
import {
OUTBOX_EVENT_REPOSITORY,
IOutboxEventRepository,
OutboxEventStatus,
} from '@/domain/repositories/outbox-event.repository.interface';
import { DepositConfirmedEvent } from '@/domain/events';
/**
*
*
*
* 1. CONFIRMED Outbox
* 2. Outbox FAILED
* 3.
*/
@Injectable()
export class DepositRepairService {
private readonly logger = new Logger(DepositRepairService.name);
constructor(
@Inject(DEPOSIT_TRANSACTION_REPOSITORY)
private readonly depositRepo: IDepositTransactionRepository,
@Inject(OUTBOX_EVENT_REPOSITORY)
private readonly outboxRepo: IOutboxEventRepository,
) {}
/**
*
*/
async diagnose(): Promise<{
confirmedNotNotified: Array<{
id: string;
txHash: string;
userId: string;
accountSequence: string;
amount: string;
confirmedAt: string;
}>;
outboxPending: number;
outboxSent: number;
outboxFailed: number;
}> {
// 查找 CONFIRMED 但未通知的充值
const pendingDeposits = await this.depositRepo.findPendingNotification();
// 统计 Outbox 中各状态的事件数量
const [pending, sent, failed] = await Promise.all([
this.outboxRepo.findPendingEvents(1000),
this.outboxRepo.findUnackedEvents(0, 1000), // SENT 状态
this.getFailedOutboxCount(),
]);
return {
confirmedNotNotified: pendingDeposits.map((d) => ({
id: d.id?.toString() ?? '',
txHash: d.txHash.toString(),
userId: d.userId.toString(),
accountSequence: d.accountSequence,
amount: d.amount.toFixed(6),
confirmedAt: d.createdAt?.toISOString() ?? '',
})),
outboxPending: pending.length,
outboxSent: sent.length,
outboxFailed: failed,
};
}
/**
* Outbox
*/
async repairDeposit(depositId: bigint): Promise<{
success: boolean;
message: string;
}> {
const deposit = await this.depositRepo.findById(depositId);
if (!deposit) {
return { success: false, message: `Deposit ${depositId} not found` };
}
if (deposit.notifiedAt) {
return {
success: false,
message: `Deposit ${depositId} already notified at ${deposit.notifiedAt.toISOString()}`,
};
}
// 创建 DepositConfirmedEvent
const event = new DepositConfirmedEvent({
depositId: deposit.id?.toString() ?? '',
chainType: deposit.chainType.toString(),
txHash: deposit.txHash.toString(),
toAddress: deposit.toAddress.toString(),
amount: deposit.amount.raw.toString(),
amountFormatted: deposit.amount.toFixed(8),
confirmations: deposit.confirmations,
accountSequence: deposit.accountSequence,
userId: deposit.userId.toString(),
});
// 写入 Outbox
await this.outboxRepo.create({
eventType: event.eventType,
aggregateId: deposit.id?.toString() ?? deposit.txHash.toString(),
aggregateType: 'DepositTransaction',
payload: event.toPayload(),
});
this.logger.log(`Created repair outbox event for deposit ${depositId}`);
return {
success: true,
message: `Created outbox event for deposit ${depositId}, will be sent in next cycle`,
};
}
/**
*
*/
async repairAll(): Promise<{
total: number;
success: number;
failed: number;
details: Array<{ id: string; success: boolean; message: string }>;
}> {
const pendingDeposits = await this.depositRepo.findPendingNotification();
this.logger.log(`Starting batch repair for ${pendingDeposits.length} deposits`);
const results: Array<{ id: string; success: boolean; message: string }> = [];
let successCount = 0;
let failedCount = 0;
for (const deposit of pendingDeposits) {
const depositId = deposit.id;
if (!depositId) {
results.push({ id: 'unknown', success: false, message: 'No deposit ID' });
failedCount++;
continue;
}
try {
const result = await this.repairDeposit(depositId);
results.push({ id: depositId.toString(), ...result });
if (result.success) {
successCount++;
} else {
failedCount++;
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
results.push({ id: depositId.toString(), success: false, message });
failedCount++;
this.logger.error(`Failed to repair deposit ${depositId}: ${message}`);
}
}
this.logger.log(`Batch repair completed: ${successCount} success, ${failedCount} failed`);
return {
total: pendingDeposits.length,
success: successCount,
failed: failedCount,
details: results,
};
}
/**
* Outbox PENDING
* FAILED
*
*/
async resetFailedOutboxEvents(): Promise<{
reset: number;
message: string;
}> {
// 当前接口不支持查询 FAILED 状态的事件
// 需要在 IOutboxEventRepository 中添加 findFailedEvents 方法
this.logger.warn('resetFailedOutboxEvents: Not implemented - interface does not support finding FAILED events');
return {
reset: 0,
message: 'Not implemented - use direct database query to find and reset FAILED events',
};
}
private async getFailedOutboxCount(): Promise<number> {
// 当前接口不支持查询 FAILED 状态的事件
return 0;
}
}

View File

@ -0,0 +1,7 @@
export * from './address-derivation.service';
export * from './deposit-detection.service';
export * from './balance-query.service';
export * from './mnemonic-verification.service';
export * from './outbox-publisher.service';
export * from './deposit-repair.service';
export * from './mpc-transfer-initializer.service';

View File

@ -0,0 +1,173 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import { RecoveryMnemonicAdapter } from '@/infrastructure/blockchain/recovery-mnemonic.adapter';
export interface VerifyMnemonicByAccountParams {
accountSequence: string;
mnemonic: string;
}
export interface VerifyMnemonicResult {
valid: boolean;
message?: string;
}
/**
*
*
*/
@Injectable()
export class MnemonicVerificationService {
private readonly logger = new Logger(MnemonicVerificationService.name);
constructor(
private readonly prisma: PrismaService,
private readonly recoveryMnemonic: RecoveryMnemonicAdapter,
) {}
/**
*
*/
async verifyMnemonicByAccount(params: VerifyMnemonicByAccountParams): Promise<VerifyMnemonicResult> {
const { accountSequence, mnemonic } = params;
this.logger.log(`Verifying mnemonic for account ${accountSequence}`);
// 1. 先检查是否有已挂失的助记词 (安全检查)
const revokedRecord = await this.prisma.recoveryMnemonic.findFirst({
where: {
accountSequence,
status: 'REVOKED',
},
orderBy: { revokedAt: 'desc' },
});
if (revokedRecord) {
this.logger.warn(`Account ${accountSequence} has revoked mnemonic, rejecting recovery attempt`);
return {
valid: false,
message: '该助记词已被挂失,无法用于账户恢复。如需帮助请联系客服。',
};
}
// 2. 查询账户的 ACTIVE 助记词记录
const recoveryRecord = await this.prisma.recoveryMnemonic.findFirst({
where: {
accountSequence,
status: 'ACTIVE',
},
});
if (!recoveryRecord) {
this.logger.warn(`No active recovery mnemonic found for account ${accountSequence}`);
return {
valid: false,
message: 'Account has no recovery mnemonic configured',
};
}
// 3. 使用 RecoveryMnemonicAdapter 验证哈希 (bcrypt 是异步的)
const result = await this.recoveryMnemonic.verifyMnemonic(mnemonic, recoveryRecord.mnemonicHash);
if (result.valid) {
this.logger.log(`Mnemonic verified successfully for account ${accountSequence}`);
} else {
this.logger.warn(`Mnemonic verification failed for account ${accountSequence}: ${result.message}`);
}
return result;
}
/**
*
*/
async saveRecoveryMnemonic(params: {
accountSequence: string;
publicKey: string;
encryptedMnemonic: string;
mnemonicHash: string;
}): Promise<void> {
this.logger.log(`Saving recovery mnemonic for account ${params.accountSequence}`);
await this.prisma.recoveryMnemonic.create({
data: {
accountSequence: params.accountSequence,
publicKey: params.publicKey,
encryptedMnemonic: params.encryptedMnemonic,
mnemonicHash: params.mnemonicHash,
status: 'ACTIVE',
isBackedUp: false,
},
});
this.logger.log(`Recovery mnemonic saved for account ${params.accountSequence}`);
}
/**
*
*/
async markAsBackedUp(accountSequence: string): Promise<void> {
await this.prisma.recoveryMnemonic.updateMany({
where: {
accountSequence,
status: 'ACTIVE',
},
data: {
isBackedUp: true,
},
});
this.logger.log(`Recovery mnemonic marked as backed up for account ${accountSequence}`);
}
/**
*
* ACTIVE REVOKED
*/
async revokeMnemonic(accountSequence: string, reason: string): Promise<{ success: boolean; message: string }> {
this.logger.log(`Revoking mnemonic for account ${accountSequence}, reason: ${reason}`);
// 查找 ACTIVE 状态的助记词
const activeRecord = await this.prisma.recoveryMnemonic.findFirst({
where: {
accountSequence,
status: 'ACTIVE',
},
});
if (!activeRecord) {
this.logger.warn(`No active mnemonic found for account ${accountSequence}`);
return {
success: false,
message: '该账户没有可挂失的助记词',
};
}
// 更新状态为 REVOKED
await this.prisma.recoveryMnemonic.update({
where: { id: activeRecord.id },
data: {
status: 'REVOKED',
revokedAt: new Date(),
revokedReason: reason,
},
});
this.logger.log(`Mnemonic revoked successfully for account ${accountSequence}`);
return {
success: true,
message: '助记词已挂失',
};
}
/**
*
*/
async isMnemonicRevoked(accountSequence: string): Promise<boolean> {
const revokedRecord = await this.prisma.recoveryMnemonic.findFirst({
where: {
accountSequence,
status: 'REVOKED',
},
});
return !!revokedRecord;
}
}

View File

@ -0,0 +1,26 @@
/**
* MPC Transfer Initializer Service
*
* MPC ERC20
* Domain Infrastructure
*/
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Erc20TransferService } from '@/domain/services/erc20-transfer.service';
import { MpcSigningClient } from '@/infrastructure/mpc';
@Injectable()
export class MpcTransferInitializerService implements OnModuleInit {
private readonly logger = new Logger(MpcTransferInitializerService.name);
constructor(
private readonly erc20TransferService: Erc20TransferService,
private readonly mpcSigningClient: MpcSigningClient,
) {}
onModuleInit() {
this.logger.log('[INIT] Injecting MPC Signing Client into ERC20 Transfer Service');
this.erc20TransferService.setMpcSigningClient(this.mpcSigningClient);
this.logger.log('[INIT] MPC Signing Client injection complete');
}
}

View File

@ -0,0 +1,148 @@
import { Injectable, Logger, Inject, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import {
OUTBOX_EVENT_REPOSITORY,
IOutboxEventRepository,
OutboxEventStatus,
} from '@/domain/repositories/outbox-event.repository.interface';
import {
DEPOSIT_TRANSACTION_REPOSITORY,
IDepositTransactionRepository,
} from '@/domain/repositories/deposit-transaction.repository.interface';
/**
* Outbox
*
* outbox_events Kafka
* Outbox Pattern
*/
@Injectable()
export class OutboxPublisherService implements OnModuleInit {
private readonly logger = new Logger(OutboxPublisherService.name);
// 发送超时时间(秒)- 超过此时间未收到 ACK 则重发
private readonly SENT_TIMEOUT_SECONDS = 300; // 5 分钟
// 清理已确认事件的天数
private readonly CLEANUP_DAYS = 7;
constructor(
private readonly eventPublisher: EventPublisherService,
@Inject(OUTBOX_EVENT_REPOSITORY)
private readonly outboxRepo: IOutboxEventRepository,
@Inject(DEPOSIT_TRANSACTION_REPOSITORY)
private readonly depositRepo: IDepositTransactionRepository,
) {}
async onModuleInit() {
this.logger.log('OutboxPublisherService initialized');
}
/**
* 5
*/
@Cron(CronExpression.EVERY_5_SECONDS)
async publishPendingEvents(): Promise<void> {
try {
const pendingEvents = await this.outboxRepo.findPendingEvents(50);
if (pendingEvents.length === 0) return;
this.logger.debug(`Found ${pendingEvents.length} pending events to publish`);
for (const event of pendingEvents) {
try {
// 发送到 Kafka
await this.eventPublisher.publishRaw({
eventId: `outbox-${event.id}`,
eventType: event.eventType,
occurredAt: event.createdAt,
payload: event.payload,
});
// 标记为已发送
await this.outboxRepo.markAsSent(event.id);
this.logger.log(
`Published event ${event.id}: ${event.eventType} for ${event.aggregateType}:${event.aggregateId}`,
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Failed to publish event ${event.id}: ${errorMessage}`);
// 记录失败,安排重试
await this.outboxRepo.recordFailure(event.id, errorMessage);
}
}
} catch (error) {
this.logger.error('Error in publishPendingEvents:', error);
}
}
/**
*
* SENT PENDING
*/
@Cron(CronExpression.EVERY_MINUTE)
async checkUnackedEvents(): Promise<void> {
try {
const unackedEvents = await this.outboxRepo.findUnackedEvents(
this.SENT_TIMEOUT_SECONDS,
50,
);
if (unackedEvents.length === 0) return;
this.logger.warn(`Found ${unackedEvents.length} unacked events, will retry`);
for (const event of unackedEvents) {
// 记录超时失败,触发重试逻辑
await this.outboxRepo.recordFailure(event.id, 'ACK timeout');
this.logger.warn(
`Event ${event.id} ACK timeout, scheduled for retry (attempt ${event.retryCount + 1})`,
);
}
} catch (error) {
this.logger.error('Error in checkUnackedEvents:', error);
}
}
/**
* 3
*/
@Cron('0 3 * * *')
async cleanupOldEvents(): Promise<void> {
try {
const count = await this.outboxRepo.cleanupAckedEvents(this.CLEANUP_DAYS);
if (count > 0) {
this.logger.log(`Cleaned up ${count} old ACKED events`);
}
} catch (error) {
this.logger.error('Error in cleanupOldEvents:', error);
}
}
/**
* ACK
* wallet-service
*/
async handleAck(aggregateType: string, aggregateId: string, eventType: string): Promise<void> {
try {
await this.outboxRepo.markAsAckedByAggregateId(aggregateType, aggregateId, eventType);
// 同时更新 deposit_transactions 表的 notified_at
if (aggregateType === 'DepositTransaction') {
const depositId = BigInt(aggregateId);
const deposit = await this.depositRepo.findById(depositId);
if (deposit) {
deposit.markAsNotified();
await this.depositRepo.save(deposit);
this.logger.log(`Deposit ${aggregateId} marked as notified`);
}
}
} catch (error) {
this.logger.error(`Error handling ACK for ${aggregateType}:${aggregateId}:`, error);
}
}
}

View File

@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3012', 10),
serviceName: process.env.SERVICE_NAME || 'blockchain-service',
}));

View File

@ -0,0 +1,65 @@
import { registerAs } from '@nestjs/config';
/**
*
*
* NETWORK_MODE :
* - NETWORK_MODE=mainnet (): 使
* - NETWORK_MODE=testnet: 使用测试网配置
*
* :
* - BSC Testnet: Chain ID 97, 水龙头: https://testnet.bnbchain.org/faucet-smart
* - KAVA Testnet: Chain ID 2221, 水龙头: https://faucet.kava.io
*/
export default registerAs('blockchain', () => {
const networkMode = process.env.NETWORK_MODE || 'mainnet';
const isTestnet = networkMode === 'testnet';
return {
// 网络模式
networkMode,
isTestnet,
// 通用配置
scanIntervalMs: parseInt(process.env.BLOCK_SCAN_INTERVAL_MS || '5000', 10),
confirmationsRequired: parseInt(process.env.BLOCK_CONFIRMATIONS_REQUIRED || (isTestnet ? '3' : '12'), 10),
scanBatchSize: parseInt(process.env.BLOCK_SCAN_BATCH_SIZE || '100', 10),
// KAVA 配置
kava: isTestnet
? {
// KAVA Testnet
rpcUrl: process.env.KAVA_RPC_URL || 'https://evm.testnet.kava.io',
chainId: parseInt(process.env.KAVA_CHAIN_ID || '2221', 10),
// 测试网 USDT 合约 (自定义部署的 TestUSDT)
usdtContract: process.env.KAVA_USDT_CONTRACT || '0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF',
confirmations: parseInt(process.env.KAVA_CONFIRMATIONS || '3', 10),
}
: {
// KAVA Mainnet
rpcUrl: process.env.KAVA_RPC_URL || 'https://evm.kava.io',
chainId: parseInt(process.env.KAVA_CHAIN_ID || '2222', 10),
// dUSDT (绿积分) 合约地址 - Durian USDT, 精度6位
usdtContract: process.env.KAVA_USDT_CONTRACT || '0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3',
confirmations: parseInt(process.env.KAVA_CONFIRMATIONS || '12', 10),
},
// BSC 配置
bsc: isTestnet
? {
// BSC Testnet (BNB Smart Chain Testnet)
rpcUrl: process.env.BSC_RPC_URL || 'https://data-seed-prebsc-1-s1.binance.org:8545',
chainId: parseInt(process.env.BSC_CHAIN_ID || '97', 10),
// BSC Testnet 官方测试 USDT 合约
usdtContract: process.env.BSC_USDT_CONTRACT || '0x337610d27c682E347C9cD60BD4b3b107C9d34dDd',
confirmations: parseInt(process.env.BSC_CONFIRMATIONS || '3', 10),
}
: {
// BSC Mainnet
rpcUrl: process.env.BSC_RPC_URL || 'https://bsc-dataseed.binance.org',
chainId: parseInt(process.env.BSC_CHAIN_ID || '56', 10),
usdtContract: process.env.BSC_USDT_CONTRACT || '0x55d398326f99059fF775485246999027B3197955',
confirmations: parseInt(process.env.BSC_CONFIRMATIONS || '15', 10),
},
};
});

View File

@ -0,0 +1,5 @@
import { registerAs } from '@nestjs/config';
export default registerAs('database', () => ({
url: process.env.DATABASE_URL,
}));

View File

@ -0,0 +1,5 @@
export { default as appConfig } from './app.config';
export { default as databaseConfig } from './database.config';
export { default as redisConfig } from './redis.config';
export { default as kafkaConfig } from './kafka.config';
export { default as blockchainConfig } from './blockchain.config';

View File

@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export default registerAs('kafka', () => ({
brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','),
clientId: process.env.KAFKA_CLIENT_ID || 'blockchain-service',
groupId: process.env.KAFKA_GROUP_ID || 'blockchain-service-group',
}));

View File

@ -0,0 +1,8 @@
import { registerAs } from '@nestjs/config';
export default registerAs('redis', () => ({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
db: parseInt(process.env.REDIS_DB || '11', 10),
password: process.env.REDIS_PASSWORD || undefined,
}));

View File

@ -0,0 +1,44 @@
import { DomainEvent } from '@/domain/events/domain-event.base';
/**
*
*
*
*/
export abstract class AggregateRoot<TId = bigint> {
protected readonly _domainEvents: DomainEvent[] = [];
/**
*
*/
abstract get id(): TId | undefined;
/**
*
*/
get domainEvents(): ReadonlyArray<DomainEvent> {
return [...this._domainEvents];
}
/**
*
* @param event
*/
protected addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
/**
*
*/
clearDomainEvents(): void {
this._domainEvents.length = 0;
}
/**
*
*/
hasDomainEvents(): boolean {
return this._domainEvents.length > 0;
}
}

View File

@ -0,0 +1,212 @@
import { AggregateRoot } from '../aggregate-root.base';
import { DepositDetectedEvent, DepositConfirmedEvent } from '@/domain/events';
import { ChainType, TxHash, EvmAddress, TokenAmount, BlockNumber } from '@/domain/value-objects';
import { DepositStatus } from '@/domain/enums';
export interface DepositTransactionProps {
id?: bigint;
chainType: ChainType;
txHash: TxHash;
fromAddress: EvmAddress;
toAddress: EvmAddress;
tokenContract: EvmAddress;
amount: TokenAmount;
blockNumber: BlockNumber;
blockTimestamp: Date;
logIndex: number;
confirmations: number;
status: DepositStatus;
addressId: bigint;
accountSequence: string; // 跨服务关联标识 (格式: D + YYMMDD + 5位序号)
userId: bigint; // 保留兼容
notifiedAt?: Date;
notifyAttempts: number;
lastNotifyError?: string;
createdAt?: Date;
updatedAt?: Date;
}
export class DepositTransaction extends AggregateRoot<bigint> {
private props: DepositTransactionProps;
private constructor(props: DepositTransactionProps) {
super();
this.props = props;
}
// Getters
get id(): bigint | undefined {
return this.props.id;
}
get chainType(): ChainType {
return this.props.chainType;
}
get txHash(): TxHash {
return this.props.txHash;
}
get fromAddress(): EvmAddress {
return this.props.fromAddress;
}
get toAddress(): EvmAddress {
return this.props.toAddress;
}
get tokenContract(): EvmAddress {
return this.props.tokenContract;
}
get amount(): TokenAmount {
return this.props.amount;
}
get blockNumber(): BlockNumber {
return this.props.blockNumber;
}
get blockTimestamp(): Date {
return this.props.blockTimestamp;
}
get logIndex(): number {
return this.props.logIndex;
}
get confirmations(): number {
return this.props.confirmations;
}
get status(): DepositStatus {
return this.props.status;
}
get addressId(): bigint {
return this.props.addressId;
}
get accountSequence(): string {
return this.props.accountSequence;
}
get userId(): bigint {
return this.props.userId;
}
get notifiedAt(): Date | undefined {
return this.props.notifiedAt;
}
get notifyAttempts(): number {
return this.props.notifyAttempts;
}
get lastNotifyError(): string | undefined {
return this.props.lastNotifyError;
}
get createdAt(): Date | undefined {
return this.props.createdAt;
}
get updatedAt(): Date | undefined {
return this.props.updatedAt;
}
get isConfirmed(): boolean {
return this.props.status === DepositStatus.CONFIRMED;
}
get isNotified(): boolean {
return this.props.status === DepositStatus.NOTIFIED;
}
/**
*
*/
static create(params: {
chainType: ChainType;
txHash: TxHash;
fromAddress: EvmAddress;
toAddress: EvmAddress;
tokenContract: EvmAddress;
amount: TokenAmount;
blockNumber: BlockNumber;
blockTimestamp: Date;
logIndex: number;
addressId: bigint;
accountSequence: string;
userId: bigint;
}): DepositTransaction {
const deposit = new DepositTransaction({
...params,
confirmations: 0,
status: DepositStatus.DETECTED,
notifyAttempts: 0,
});
deposit.addDomainEvent(
new DepositDetectedEvent({
depositId: '0', // Will be set after persistence
chainType: params.chainType.toString(),
txHash: params.txHash.toString(),
fromAddress: params.fromAddress.toString(),
toAddress: params.toAddress.toString(),
tokenContract: params.tokenContract.toString(),
amount: params.amount.raw.toString(),
amountFormatted: params.amount.toFixed(8),
blockNumber: params.blockNumber.toString(),
blockTimestamp: params.blockTimestamp.toISOString(),
accountSequence: params.accountSequence,
userId: params.userId.toString(),
}),
);
return deposit;
}
/**
*
*/
static reconstitute(props: DepositTransactionProps): DepositTransaction {
return new DepositTransaction(props);
}
/**
*
*/
updateConfirmations(currentBlockNumber: BlockNumber, requiredConfirmations: number): void {
const confirmations = Number(currentBlockNumber.diff(this.props.blockNumber));
this.props.confirmations = Math.max(0, confirmations);
// 检查是否达到确认要求(状态为 DETECTED 或 CONFIRMING 都可以确认)
if (
this.props.confirmations >= requiredConfirmations &&
(this.props.status === DepositStatus.DETECTED || this.props.status === DepositStatus.CONFIRMING)
) {
this.confirm();
} else if (this.props.status === DepositStatus.DETECTED) {
// 首次检测但确认数不够,标记为确认中
this.props.status = DepositStatus.CONFIRMING;
}
}
/**
*
*/
private confirm(): void {
this.props.status = DepositStatus.CONFIRMED;
this.addDomainEvent(
new DepositConfirmedEvent({
depositId: this.props.id?.toString() ?? '0',
chainType: this.props.chainType.toString(),
txHash: this.props.txHash.toString(),
toAddress: this.props.toAddress.toString(),
amount: this.props.amount.raw.toString(),
amountFormatted: this.props.amount.toFixed(8),
confirmations: this.props.confirmations,
accountSequence: this.props.accountSequence,
userId: this.props.userId.toString(),
}),
);
}
/**
*
*/
markAsNotified(): void {
this.props.status = DepositStatus.NOTIFIED;
this.props.notifiedAt = new Date();
}
/**
*
*/
recordNotifyFailure(error: string): void {
this.props.notifyAttempts += 1;
this.props.lastNotifyError = error;
}
}

View File

@ -0,0 +1 @@
export * from './deposit-transaction.aggregate';

View File

@ -0,0 +1,4 @@
export * from './aggregate-root.base';
export * from './deposit-transaction';
export * from './monitored-address';
export * from './transaction-request';

View File

@ -0,0 +1 @@
export * from './monitored-address.aggregate';

View File

@ -0,0 +1,84 @@
import { AggregateRoot } from '../aggregate-root.base';
import { ChainType, EvmAddress } from '@/domain/value-objects';
export interface MonitoredAddressProps {
id?: bigint;
chainType: ChainType;
address: EvmAddress;
accountSequence: string; // 跨服务关联标识 (格式: D + YYMMDD + 5位序号)
userId: bigint; // 保留兼容
isActive: boolean;
createdAt?: Date;
updatedAt?: Date;
}
export class MonitoredAddress extends AggregateRoot<bigint> {
private props: MonitoredAddressProps;
private constructor(props: MonitoredAddressProps) {
super();
this.props = props;
}
// Getters
get id(): bigint | undefined {
return this.props.id;
}
get chainType(): ChainType {
return this.props.chainType;
}
get address(): EvmAddress {
return this.props.address;
}
get accountSequence(): string {
return this.props.accountSequence;
}
get userId(): bigint {
return this.props.userId;
}
get isActive(): boolean {
return this.props.isActive;
}
get createdAt(): Date | undefined {
return this.props.createdAt;
}
get updatedAt(): Date | undefined {
return this.props.updatedAt;
}
/**
*
*/
static create(params: {
chainType: ChainType;
address: EvmAddress;
accountSequence: string;
userId: bigint;
}): MonitoredAddress {
return new MonitoredAddress({
...params,
isActive: true,
});
}
/**
*
*/
static reconstitute(props: MonitoredAddressProps): MonitoredAddress {
return new MonitoredAddress(props);
}
/**
*
*/
activate(): void {
this.props.isActive = true;
}
/**
*
*/
deactivate(): void {
this.props.isActive = false;
}
}

View File

@ -0,0 +1 @@
export * from './transaction-request.aggregate';

View File

@ -0,0 +1,185 @@
import { AggregateRoot } from '../aggregate-root.base';
import { TransactionBroadcastedEvent } from '@/domain/events';
import { ChainType, TxHash, EvmAddress, TokenAmount } from '@/domain/value-objects';
import { TransactionStatus } from '@/domain/enums';
import { Decimal } from '@prisma/client/runtime/library';
export interface TransactionRequestProps {
id?: bigint;
chainType: ChainType;
sourceService: string;
sourceOrderId: string;
fromAddress: EvmAddress;
toAddress: EvmAddress;
value: TokenAmount;
data?: string;
signedTx?: string;
txHash?: TxHash;
status: TransactionStatus;
gasLimit?: bigint;
gasPrice?: Decimal;
nonce?: number;
errorMessage?: string;
retryCount: number;
createdAt?: Date;
updatedAt?: Date;
}
export class TransactionRequest extends AggregateRoot<bigint> {
private props: TransactionRequestProps;
private constructor(props: TransactionRequestProps) {
super();
this.props = props;
}
// Getters
get id(): bigint | undefined {
return this.props.id;
}
get chainType(): ChainType {
return this.props.chainType;
}
get sourceService(): string {
return this.props.sourceService;
}
get sourceOrderId(): string {
return this.props.sourceOrderId;
}
get fromAddress(): EvmAddress {
return this.props.fromAddress;
}
get toAddress(): EvmAddress {
return this.props.toAddress;
}
get value(): TokenAmount {
return this.props.value;
}
get data(): string | undefined {
return this.props.data;
}
get signedTx(): string | undefined {
return this.props.signedTx;
}
get txHash(): TxHash | undefined {
return this.props.txHash;
}
get status(): TransactionStatus {
return this.props.status;
}
get gasLimit(): bigint | undefined {
return this.props.gasLimit;
}
get gasPrice(): Decimal | undefined {
return this.props.gasPrice;
}
get nonce(): number | undefined {
return this.props.nonce;
}
get errorMessage(): string | undefined {
return this.props.errorMessage;
}
get retryCount(): number {
return this.props.retryCount;
}
get createdAt(): Date | undefined {
return this.props.createdAt;
}
get updatedAt(): Date | undefined {
return this.props.updatedAt;
}
get isPending(): boolean {
return this.props.status === TransactionStatus.PENDING;
}
get isBroadcasted(): boolean {
return this.props.status === TransactionStatus.BROADCASTED;
}
get isFailed(): boolean {
return this.props.status === TransactionStatus.FAILED;
}
/**
*
*/
static create(params: {
chainType: ChainType;
sourceService: string;
sourceOrderId: string;
fromAddress: EvmAddress;
toAddress: EvmAddress;
value: TokenAmount;
data?: string;
}): TransactionRequest {
return new TransactionRequest({
...params,
status: TransactionStatus.PENDING,
retryCount: 0,
});
}
/**
*
*/
static reconstitute(props: TransactionRequestProps): TransactionRequest {
return new TransactionRequest(props);
}
/**
*
*/
setSignedTransaction(signedTx: string, gasLimit: bigint, gasPrice: Decimal, nonce: number): void {
this.props.signedTx = signedTx;
this.props.gasLimit = gasLimit;
this.props.gasPrice = gasPrice;
this.props.nonce = nonce;
this.props.status = TransactionStatus.SIGNED;
}
/**
* 广
*/
markAsBroadcasted(txHash: TxHash): void {
this.props.txHash = txHash;
this.props.status = TransactionStatus.BROADCASTED;
this.addDomainEvent(
new TransactionBroadcastedEvent({
requestId: this.props.id?.toString() ?? '0',
chainType: this.props.chainType.toString(),
txHash: txHash.toString(),
fromAddress: this.props.fromAddress.toString(),
toAddress: this.props.toAddress.toString(),
value: this.props.value.raw.toString(),
sourceService: this.props.sourceService,
sourceOrderId: this.props.sourceOrderId,
}),
);
}
/**
*
*/
markAsConfirmed(): void {
this.props.status = TransactionStatus.CONFIRMED;
}
/**
*
*/
markAsFailed(errorMessage: string): void {
this.props.status = TransactionStatus.FAILED;
this.props.errorMessage = errorMessage;
this.props.retryCount += 1;
}
/**
*
*/
retry(): void {
this.props.status = TransactionStatus.PENDING;
this.props.errorMessage = undefined;
this.props.signedTx = undefined;
this.props.txHash = undefined;
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ConfirmationPolicyService, ChainConfigService } from './services';
import { Erc20TransferService } from './services/erc20-transfer.service';
@Module({
providers: [ConfirmationPolicyService, ChainConfigService, Erc20TransferService],
exports: [ConfirmationPolicyService, ChainConfigService, Erc20TransferService],
})
export class DomainModule {}

View File

@ -0,0 +1,8 @@
/**
*
*/
export enum ChainTypeEnum {
KAVA = 'KAVA',
DST = 'DST',
BSC = 'BSC',
}

View File

@ -0,0 +1,13 @@
/**
*
*/
export enum DepositStatus {
/** 已检测到 */
DETECTED = 'DETECTED',
/** 确认中 */
CONFIRMING = 'CONFIRMING',
/** 已确认 */
CONFIRMED = 'CONFIRMED',
/** 已通知 */
NOTIFIED = 'NOTIFIED',
}

View File

@ -0,0 +1,3 @@
export * from './chain-type.enum';
export * from './deposit-status.enum';
export * from './transaction-status.enum';

View File

@ -0,0 +1,15 @@
/**
*
*/
export enum TransactionStatus {
/** 待处理 */
PENDING = 'PENDING',
/** 已签名 */
SIGNED = 'SIGNED',
/** 已广播 */
BROADCASTED = 'BROADCASTED',
/** 已确认 */
CONFIRMED = 'CONFIRMED',
/** 失败 */
FAILED = 'FAILED',
}

View File

@ -0,0 +1,30 @@
import { DomainEvent } from './domain-event.base';
export interface DepositConfirmedPayload {
depositId: string;
chainType: string;
txHash: string;
toAddress: string;
amount: string;
amountFormatted: string;
confirmations: number;
accountSequence: string; // 跨服务关联标识 (全局唯一业务ID)
userId: string; // 保留兼容
[key: string]: unknown;
}
/**
*
*
*/
export class DepositConfirmedEvent extends DomainEvent {
readonly eventType = 'blockchain.deposit.confirmed';
constructor(private readonly payload: DepositConfirmedPayload) {
super();
}
toPayload(): DepositConfirmedPayload {
return this.payload;
}
}

View File

@ -0,0 +1,33 @@
import { DomainEvent } from './domain-event.base';
export interface DepositDetectedPayload {
depositId: string;
chainType: string;
txHash: string;
fromAddress: string;
toAddress: string;
tokenContract: string;
amount: string;
amountFormatted: string;
blockNumber: string;
blockTimestamp: string;
accountSequence: string; // 跨服务关联标识 (全局唯一业务ID)
userId: string; // 保留兼容
[key: string]: unknown;
}
/**
*
*
*/
export class DepositDetectedEvent extends DomainEvent {
readonly eventType = 'blockchain.deposit.detected';
constructor(private readonly payload: DepositDetectedPayload) {
super();
}
toPayload(): DepositDetectedPayload {
return this.payload;
}
}

View File

@ -0,0 +1,17 @@
import { v4 as uuidv4 } from 'uuid';
/**
*
*/
export abstract class DomainEvent {
readonly eventId: string;
readonly occurredAt: Date;
abstract readonly eventType: string;
constructor() {
this.eventId = uuidv4();
this.occurredAt = new Date();
}
abstract toPayload(): Record<string, unknown>;
}

View File

@ -0,0 +1,5 @@
export * from './domain-event.base';
export * from './deposit-detected.event';
export * from './deposit-confirmed.event';
export * from './wallet-address-created.event';
export * from './transaction-broadcasted.event';

Some files were not shown because too many files have changed in this diff Show More