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:
parent
cec98e9d3e
commit
042a52550b
|
|
@ -0,0 +1,13 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.local
|
||||
*.md
|
||||
.vscode
|
||||
.idea
|
||||
coverage
|
||||
test
|
||||
*.log
|
||||
npm-debug.log
|
||||
|
|
@ -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
|
||||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"]
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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(', '));
|
||||
|
|
@ -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);
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
222
backend/services/mining-blockchain-service/contracts/eUSDT/package-lock.json
generated
Normal file
222
backend/services/mining-blockchain-service/contracts/eUSDT/package-lock.json
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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(', '));
|
||||
|
|
@ -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);
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
222
backend/services/mining-blockchain-service/contracts/fUSDT/package-lock.json
generated
Normal file
222
backend/services/mining-blockchain-service/contracts/fUSDT/package-lock.json
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
|
|
@ -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");
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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..."
|
||||
}'
|
||||
```
|
||||
|
|
@ -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
|
|
@ -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.
|
||||
`);
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './request';
|
||||
export * from './response';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}>;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './balance.dto';
|
||||
export * from './address.dto';
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './mpc-keygen-completed.handler';
|
||||
export * from './withdrawal-requested.handler';
|
||||
|
|
@ -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 事件中提取 publicKey、userId 和 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
// 单链失败不影响其他链的更新
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './hot-wallet-balance.scheduler';
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// 获取代币的实际 decimals(USDT 通常是 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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
}));
|
||||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('database', () => ({
|
||||
url: process.env.DATABASE_URL,
|
||||
}));
|
||||
|
|
@ -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';
|
||||
|
|
@ -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',
|
||||
}));
|
||||
|
|
@ -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,
|
||||
}));
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './deposit-transaction.aggregate';
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from './aggregate-root.base';
|
||||
export * from './deposit-transaction';
|
||||
export * from './monitored-address';
|
||||
export * from './transaction-request';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './monitored-address.aggregate';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './transaction-request.aggregate';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* 支持的区块链类型
|
||||
*/
|
||||
export enum ChainTypeEnum {
|
||||
KAVA = 'KAVA',
|
||||
DST = 'DST',
|
||||
BSC = 'BSC',
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* 充值交易状态
|
||||
*/
|
||||
export enum DepositStatus {
|
||||
/** 已检测到 */
|
||||
DETECTED = 'DETECTED',
|
||||
/** 确认中 */
|
||||
CONFIRMING = 'CONFIRMING',
|
||||
/** 已确认 */
|
||||
CONFIRMED = 'CONFIRMED',
|
||||
/** 已通知 */
|
||||
NOTIFIED = 'NOTIFIED',
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './chain-type.enum';
|
||||
export * from './deposit-status.enum';
|
||||
export * from './transaction-status.enum';
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* 交易请求状态
|
||||
*/
|
||||
export enum TransactionStatus {
|
||||
/** 待处理 */
|
||||
PENDING = 'PENDING',
|
||||
/** 已签名 */
|
||||
SIGNED = 'SIGNED',
|
||||
/** 已广播 */
|
||||
BROADCASTED = 'BROADCASTED',
|
||||
/** 已确认 */
|
||||
CONFIRMED = 'CONFIRMED',
|
||||
/** 失败 */
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue