Initial commit: USDT transfer worker (RabbitMQ + Postgres + Vault)
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
README.md
|
||||
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
PORT=3000
|
||||
ETH_RPC_URL=https://ethereum-rpc.publicnode.com
|
||||
HOT_WALLET_PRIVATE_KEY=0xYOUR_PRIVATE_KEY
|
||||
USDT_CONTRACT_ADDRESS=0xdAC17F958D2ee523a2206206994597C13D831ec7
|
||||
|
||||
VAULT_MOUNT_POINT=dev-secrets
|
||||
VAULT_ADDR=https://corp.vault.elcsa.ru
|
||||
VAULT_ROLE_ID=
|
||||
VAULT_SECRET_ID=
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
REQUIRED_CONFIRMATIONS=12
|
||||
MISMATCH_TIMEOUT_MS=300000
|
||||
POLL_INTERVAL_MS=15000
|
||||
MAX_TRANSFER_USDT=1000
|
||||
MIN_ETH_BALANCE_WEI=1
|
||||
WEBHOOK_TIMEOUT_MS=10000
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
COPY tests ./tests
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --from=build /app/dist ./dist
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/src/index.js"]
|
||||
139
README.md
Normal file
139
README.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# USDT Transfer Worker
|
||||
|
||||
Production worker that consumes `crypto.transfer.requested` from RabbitMQ, sends an exact-amount ERC20 USDT transfer on Ethereum mainnet from a hot wallet, verifies the recipient's balance increased, and publishes `crypto.transfer.completed` on success. Status of every order is written into the `payments` table in Postgres.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
RabbitMQ (crypto.transfer.requested)
|
||||
│
|
||||
▼
|
||||
AmqpConsumer ──▶ TransferOrchestrator
|
||||
│
|
||||
├─ Postgres (orders, wallets, payments)
|
||||
├─ Ethereum RPC (USDT balanceOf, transfer)
|
||||
└─ AmqpPublisher (crypto.transfer.completed)
|
||||
```
|
||||
|
||||
Postgres credentials and RabbitMQ credentials are pulled from HashiCorp Vault (KV v2, AppRole auth). The hot-wallet private key, ETH RPC URL and limits stay in `.env`.
|
||||
|
||||
## Message contract
|
||||
|
||||
Inbound — queue `crypto.transfer.requested`:
|
||||
|
||||
```json
|
||||
{
|
||||
"order_id": "01K...",
|
||||
"user_id": "01K...",
|
||||
"trace_id": "01K...",
|
||||
"message_id": "01K..."
|
||||
}
|
||||
```
|
||||
|
||||
All four IDs are ULIDs (26 chars, Crockford base32).
|
||||
|
||||
Outbound — queue `crypto.transfer.completed` (only on success):
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "01K...",
|
||||
"order_id": "01K...",
|
||||
"trace_id": "01K...",
|
||||
"message_id": "01K..."
|
||||
}
|
||||
```
|
||||
|
||||
`message_id` in the outbound payload is freshly generated; the other three are echoed from the request.
|
||||
|
||||
## Processing flow
|
||||
|
||||
1. Read `payments` row by `order_id`. If `usdt_delivered` already — skip. If terminal failure (`web3_hash_error` / `web3_balance_problem`) — also skip.
|
||||
2. `SELECT usdt_amount FROM orders WHERE id = $order_id`.
|
||||
3. `SELECT address FROM wallets WHERE user_id = $user_id AND chain = 'ETH' LIMIT 1`.
|
||||
4. Pre-checks: hot-wallet ETH and USDT balances, `MAX_TRANSFER_USDT` cap.
|
||||
5. Capture pre-balance of the recipient (`usdt.balanceOf`).
|
||||
6. Broadcast `usdt.transfer(recipient, amount)`. If RPC fails — `payments.status = web3_hash_error` and stop.
|
||||
7. Persist tx hash into `payments.web3_transaction_hash` immediately.
|
||||
8. Balance loop: 3 attempts × 60 s. On the first attempt where `post − pre ≥ amount`, mark `payments.status = usdt_delivered`, set `paid_at`, publish to `crypto.transfer.completed`.
|
||||
9. If all 3 attempts fail to observe the increase: `payments.status = web3_balance_problem` (the hash is still kept).
|
||||
|
||||
Failure paths only update Postgres — the spec asks for queue publishing on success only.
|
||||
|
||||
## Environment
|
||||
|
||||
Required `.env` (see `.env.example`):
|
||||
|
||||
```
|
||||
PORT=3000
|
||||
ETH_RPC_URL=https://ethereum-rpc.publicnode.com
|
||||
HOT_WALLET_PRIVATE_KEY=0x...
|
||||
USDT_CONTRACT_ADDRESS=0xdAC17F958D2ee523a2206206994597C13D831ec7
|
||||
|
||||
VAULT_MOUNT_POINT=dev-secrets
|
||||
VAULT_ADDR=https://corp.vault.elcsa.ru
|
||||
VAULT_ROLE_ID=...
|
||||
VAULT_SECRET_ID=...
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
REQUIRED_CONFIRMATIONS=12
|
||||
MISMATCH_TIMEOUT_MS=300000
|
||||
POLL_INTERVAL_MS=15000
|
||||
MAX_TRANSFER_USDT=1000
|
||||
MIN_ETH_BALANCE_WEI=1
|
||||
WEBHOOK_TIMEOUT_MS=10000
|
||||
```
|
||||
|
||||
`REQUIRED_CONFIRMATIONS`, `MISMATCH_TIMEOUT_MS`, `POLL_INTERVAL_MS`, `WEBHOOK_TIMEOUT_MS` are kept in the `.env` for compatibility with infra tooling but are **not consumed** by the worker — the verification cadence is fixed at 3 attempts × 60 s.
|
||||
|
||||
## Vault layout (`dev-secrets/` mount, KV v2)
|
||||
|
||||
`dev-secrets/database`:
|
||||
|
||||
| key | value |
|
||||
|----------|------------------------|
|
||||
| host | postgres host |
|
||||
| port | postgres port |
|
||||
| name | database name |
|
||||
| user | username |
|
||||
| password | password |
|
||||
|
||||
`dev-secrets/rabbitmq`:
|
||||
|
||||
| key | value |
|
||||
|----------|---------------|
|
||||
| host | rabbit host |
|
||||
| port | amqp port |
|
||||
| vhost | virtual host |
|
||||
| user | username |
|
||||
| password | password |
|
||||
|
||||
## HTTP
|
||||
|
||||
A single endpoint is exposed for liveness/readiness probes:
|
||||
|
||||
- `GET /health` — RPC block number + hot-wallet ETH/USDT balances. Returns 503 on RPC errors.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # fill VAULT_ROLE_ID / VAULT_SECRET_ID, etc.
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
With Docker:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
`docker-compose.yml` brings up Postgres and RabbitMQ alongside the app. Vault stays external — point `VAULT_ADDR` at your real instance.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Covers amount parsing and gas error humanization. Integration testing (Postgres + RabbitMQ + RPC) is performed in the deploy environment.
|
||||
52
docker-compose.yml
Normal file
52
docker-compose.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres_user
|
||||
POSTGRES_PASSWORD: postgres_password
|
||||
POSTGRES_DB: app
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres_user -d app"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3-management-alpine
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: rabbit
|
||||
RABBITMQ_DEFAULT_PASS: rabbit
|
||||
RABBITMQ_DEFAULT_VHOST: /
|
||||
ports:
|
||||
- "5672:5672"
|
||||
- "15672:15672"
|
||||
volumes:
|
||||
- rabbit_data:/var/lib/rabbitmq
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
rabbit_data:
|
||||
1972
package-lock.json
generated
Normal file
1972
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "usdt-transfer-confirmation-service",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/src/index.js",
|
||||
"test": "npm run build && node --test \"dist/tests/**/*.test.js\""
|
||||
},
|
||||
"dependencies": {
|
||||
"amqplib": "^0.10.4",
|
||||
"dotenv": "^16.4.7",
|
||||
"ethers": "^6.13.5",
|
||||
"express": "^4.21.2",
|
||||
"pg": "^8.13.1",
|
||||
"pino": "^9.5.0",
|
||||
"ulid": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/amqplib": "^0.10.5",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/pg": "^8.11.10",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
56
src/config.ts
Normal file
56
src/config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { parseUsdtAmount } from "./domain/amount.js";
|
||||
|
||||
export const MAINNET_USDT_CONTRACT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
|
||||
export interface AppConfig {
|
||||
port: number;
|
||||
ethRpcUrl: string;
|
||||
hotWalletPrivateKey: string;
|
||||
usdtContractAddress: string;
|
||||
maxTransferAmountUnits: bigint;
|
||||
minEthBalanceWei: bigint;
|
||||
balanceCheckAttempts: number;
|
||||
balanceCheckIntervalMs: number;
|
||||
vaultAddr: string;
|
||||
vaultMountPoint: string;
|
||||
vaultRoleId: string;
|
||||
vaultSecretId: string;
|
||||
logLevel: string;
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
return {
|
||||
port: readInteger(env.PORT, 3000),
|
||||
ethRpcUrl: readRequired(env.ETH_RPC_URL, "ETH_RPC_URL"),
|
||||
hotWalletPrivateKey: readRequired(env.HOT_WALLET_PRIVATE_KEY, "HOT_WALLET_PRIVATE_KEY"),
|
||||
usdtContractAddress: env.USDT_CONTRACT_ADDRESS ?? MAINNET_USDT_CONTRACT,
|
||||
maxTransferAmountUnits: parseUsdtAmount(env.MAX_TRANSFER_USDT ?? "1000"),
|
||||
minEthBalanceWei: BigInt(env.MIN_ETH_BALANCE_WEI ?? "1"),
|
||||
balanceCheckAttempts: 3,
|
||||
balanceCheckIntervalMs: 60_000,
|
||||
vaultAddr: readRequired(env.VAULT_ADDR, "VAULT_ADDR"),
|
||||
vaultMountPoint: readRequired(env.VAULT_MOUNT_POINT, "VAULT_MOUNT_POINT"),
|
||||
vaultRoleId: readRequired(env.VAULT_ROLE_ID, "VAULT_ROLE_ID"),
|
||||
vaultSecretId: readRequired(env.VAULT_SECRET_ID, "VAULT_SECRET_ID"),
|
||||
logLevel: env.LOG_LEVEL ?? "INFO"
|
||||
};
|
||||
}
|
||||
|
||||
function readRequired(value: string | undefined, name: string): string {
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readInteger(value: string | undefined, defaultValue: number): number {
|
||||
if (!value) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`Expected positive integer, got ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
102
src/db/PostgresClient.ts
Normal file
102
src/db/PostgresClient.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import pg from "pg";
|
||||
import type { DatabaseSecret } from "../secrets/VaultClient.js";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
export type PaymentStatus =
|
||||
| "pending"
|
||||
| "usdt_delivered"
|
||||
| "web3_hash_error"
|
||||
| "web3_balance_problem"
|
||||
| string;
|
||||
|
||||
export interface PaymentRecord {
|
||||
status: PaymentStatus;
|
||||
web3_transaction_hash: string | null;
|
||||
}
|
||||
|
||||
export class PostgresClient {
|
||||
private readonly pool: pg.Pool;
|
||||
|
||||
constructor(secret: DatabaseSecret) {
|
||||
this.pool = new Pool({
|
||||
host: secret.host,
|
||||
port: secret.port,
|
||||
database: secret.name,
|
||||
user: secret.user,
|
||||
password: secret.password,
|
||||
max: 5,
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 10_000
|
||||
});
|
||||
}
|
||||
|
||||
async ping(): Promise<void> {
|
||||
await this.pool.query("SELECT 1");
|
||||
}
|
||||
|
||||
async getOrderUsdtAmount(orderId: string): Promise<string | null> {
|
||||
const result = await this.pool.query<{ usdt_amount: string }>(
|
||||
"SELECT usdt_amount::text AS usdt_amount FROM orders WHERE id = $1",
|
||||
[orderId]
|
||||
);
|
||||
return result.rows[0]?.usdt_amount ?? null;
|
||||
}
|
||||
|
||||
async getUserEthWalletAddress(userId: string): Promise<string | null> {
|
||||
const result = await this.pool.query<{ address: string }>(
|
||||
"SELECT address FROM wallets WHERE user_id = $1 AND chain = 'ETH' LIMIT 1",
|
||||
[userId]
|
||||
);
|
||||
return result.rows[0]?.address ?? null;
|
||||
}
|
||||
|
||||
async getPaymentByOrderId(orderId: string): Promise<PaymentRecord | null> {
|
||||
const result = await this.pool.query<PaymentRecord>(
|
||||
"SELECT status, web3_transaction_hash FROM payments WHERE order_id = $1",
|
||||
[orderId]
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async setPaymentTxHash(orderId: string, txHash: string): Promise<void> {
|
||||
await this.pool.query(
|
||||
"UPDATE payments SET web3_transaction_hash = $2, updated_at = now() WHERE order_id = $1",
|
||||
[orderId, txHash]
|
||||
);
|
||||
}
|
||||
|
||||
async markPaymentDelivered(orderId: string, txHash: string): Promise<void> {
|
||||
await this.pool.query(
|
||||
`UPDATE payments
|
||||
SET status = 'usdt_delivered',
|
||||
web3_transaction_hash = $2,
|
||||
paid_at = now(),
|
||||
updated_at = now()
|
||||
WHERE order_id = $1`,
|
||||
[orderId, txHash]
|
||||
);
|
||||
}
|
||||
|
||||
async markPaymentHashError(orderId: string): Promise<void> {
|
||||
await this.pool.query(
|
||||
"UPDATE payments SET status = 'web3_hash_error', updated_at = now() WHERE order_id = $1",
|
||||
[orderId]
|
||||
);
|
||||
}
|
||||
|
||||
async markPaymentBalanceProblem(orderId: string, txHash: string): Promise<void> {
|
||||
await this.pool.query(
|
||||
`UPDATE payments
|
||||
SET status = 'web3_balance_problem',
|
||||
web3_transaction_hash = $2,
|
||||
updated_at = now()
|
||||
WHERE order_id = $1`,
|
||||
[orderId, txHash]
|
||||
);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
41
src/domain/amount.ts
Normal file
41
src/domain/amount.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export const USDT_DECIMALS = 6;
|
||||
export const USDT_SCALE = 1_000_000n;
|
||||
|
||||
export function parseUsdtAmount(input: string): bigint {
|
||||
const value = input.trim();
|
||||
|
||||
if (!/^\d+(\.\d+)?$/.test(value)) {
|
||||
throw new Error("Amount must be a valid decimal string");
|
||||
}
|
||||
|
||||
const [wholePart, fractionalPart = ""] = value.split(".");
|
||||
if (fractionalPart.length > USDT_DECIMALS) {
|
||||
throw new Error("Amount must have at most 6 decimals");
|
||||
}
|
||||
|
||||
const wholeUnits = BigInt(wholePart) * USDT_SCALE;
|
||||
const fractionalUnits = BigInt((fractionalPart || "0").padEnd(USDT_DECIMALS, "0"));
|
||||
const units = wholeUnits + fractionalUnits;
|
||||
|
||||
if (units <= 0n) {
|
||||
throw new Error("Amount must be greater than zero");
|
||||
}
|
||||
|
||||
return units;
|
||||
}
|
||||
|
||||
export function formatUsdtAmount(units: bigint): string {
|
||||
const whole = units / USDT_SCALE;
|
||||
const fractional = units % USDT_SCALE;
|
||||
|
||||
if (fractional === 0n) {
|
||||
return whole.toString();
|
||||
}
|
||||
|
||||
const trimmedFractional = fractional
|
||||
.toString()
|
||||
.padStart(USDT_DECIMALS, "0")
|
||||
.replace(/0+$/, "");
|
||||
|
||||
return `${whole}.${trimmedFractional}`;
|
||||
}
|
||||
3
src/domain/types.ts
Normal file
3
src/domain/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const PAYMENT_STATUS_USDT_DELIVERED = "usdt_delivered";
|
||||
export const PAYMENT_STATUS_HASH_ERROR = "web3_hash_error";
|
||||
export const PAYMENT_STATUS_BALANCE_PROBLEM = "web3_balance_problem";
|
||||
7
src/ethereum/EthereumGateway.ts
Normal file
7
src/ethereum/EthereumGateway.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface EthereumGateway {
|
||||
getHotWalletAddress(): string;
|
||||
getCurrentBlockNumber(): Promise<number>;
|
||||
getEthBalance(address?: string): Promise<bigint>;
|
||||
getUsdtBalance(address: string): Promise<bigint>;
|
||||
sendUsdtTransfer(to: string, amountUnits: bigint): Promise<{ hash: string }>;
|
||||
}
|
||||
89
src/ethereum/EthersEthereumGateway.ts
Normal file
89
src/ethereum/EthersEthereumGateway.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Contract, JsonRpcProvider, Wallet, getAddress } from "ethers";
|
||||
import type { EthereumGateway } from "./EthereumGateway.js";
|
||||
|
||||
const USDT_ABI = [
|
||||
"function balanceOf(address owner) view returns (uint256)",
|
||||
"function transfer(address to, uint256 value) returns (bool)"
|
||||
];
|
||||
|
||||
interface EthersEthereumGatewayOptions {
|
||||
rpcUrl: string;
|
||||
privateKey: string;
|
||||
usdtContractAddress: string;
|
||||
}
|
||||
|
||||
export class EthersEthereumGateway implements EthereumGateway {
|
||||
private readonly provider: JsonRpcProvider;
|
||||
private readonly wallet: Wallet;
|
||||
private readonly usdt: Contract;
|
||||
|
||||
constructor(options: EthersEthereumGatewayOptions) {
|
||||
this.provider = new JsonRpcProvider(options.rpcUrl);
|
||||
this.wallet = new Wallet(options.privateKey, this.provider);
|
||||
const usdtAddress = getAddress(options.usdtContractAddress);
|
||||
this.usdt = new Contract(usdtAddress, USDT_ABI, this.wallet);
|
||||
}
|
||||
|
||||
getHotWalletAddress(): string {
|
||||
return this.wallet.address;
|
||||
}
|
||||
|
||||
async getCurrentBlockNumber(): Promise<number> {
|
||||
return this.provider.getBlockNumber();
|
||||
}
|
||||
|
||||
async getEthBalance(address = this.wallet.address): Promise<bigint> {
|
||||
return this.provider.getBalance(address);
|
||||
}
|
||||
|
||||
async getUsdtBalance(address: string): Promise<bigint> {
|
||||
return BigInt(await this.usdt.balanceOf(getAddress(address)));
|
||||
}
|
||||
|
||||
async sendUsdtTransfer(to: string, amountUnits: bigint): Promise<{ hash: string }> {
|
||||
try {
|
||||
const tx = await this.usdt.transfer(getAddress(to), amountUnits);
|
||||
return { hash: tx.hash };
|
||||
} catch (error) {
|
||||
throw new Error(humanizeTransferError(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function humanizeTransferError(error: unknown): string {
|
||||
const fallback = error instanceof Error ? error.message : String(error);
|
||||
const providerMessage = extractProviderMessage(error);
|
||||
|
||||
if (providerMessage.includes("max fee per gas less than block base fee")) {
|
||||
const maxFeeMatch = providerMessage.match(/maxFeePerGas:\s*(\d+)/i);
|
||||
const baseFeeMatch = providerMessage.match(/baseFee:\s*(\d+)/i);
|
||||
const configured = maxFeeMatch ? formatGwei(BigInt(maxFeeMatch[1])) : "configured";
|
||||
const required = baseFeeMatch ? formatGwei(BigInt(baseFeeMatch[1])) : "current";
|
||||
|
||||
return `Transfer fee ${configured} gwei is below current base fee ${required} gwei.`;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function extractProviderMessage(error: unknown): string {
|
||||
if (error && typeof error === "object") {
|
||||
const candidate = error as {
|
||||
info?: {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
return candidate.info?.error?.message ?? "";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatGwei(valueWei: bigint): string {
|
||||
const whole = valueWei / 1_000_000_000n;
|
||||
const fraction = (valueWei % 1_000_000_000n).toString().padStart(9, "0").replace(/0+$/, "");
|
||||
return fraction ? `${whole}.${fraction}` : whole.toString();
|
||||
}
|
||||
49
src/http/app.ts
Normal file
49
src/http/app.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import express, { type NextFunction, type Request, type Response } from "express";
|
||||
import type { Logger } from "pino";
|
||||
import { formatUsdtAmount } from "../domain/amount.js";
|
||||
import type { EthereumGateway } from "../ethereum/EthereumGateway.js";
|
||||
|
||||
interface AppOptions {
|
||||
ethereum: EthereumGateway;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export function createApp(options: AppOptions) {
|
||||
const app = express();
|
||||
const { ethereum, logger } = options;
|
||||
|
||||
app.get("/health", async (_req, res) => {
|
||||
try {
|
||||
const hotWalletAddress = ethereum.getHotWalletAddress();
|
||||
const [blockNumber, ethBalanceWei, usdtBalanceUnits] = await Promise.all([
|
||||
ethereum.getCurrentBlockNumber(),
|
||||
ethereum.getEthBalance(hotWalletAddress),
|
||||
ethereum.getUsdtBalance(hotWalletAddress)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
status: "ok",
|
||||
blockNumber,
|
||||
hotWalletAddress,
|
||||
hotWalletEthBalanceWei: ethBalanceWei.toString(),
|
||||
hotWalletUsdtBalanceUnits: usdtBalanceUnits.toString(),
|
||||
hotWalletUsdtBalance: formatUsdtAmount(usdtBalanceUnits)
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "/health degraded");
|
||||
res.status(503).json({
|
||||
status: "degraded",
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
logger.error({ err: error }, "unhandled http error");
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
91
src/index.ts
Normal file
91
src/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import "dotenv/config";
|
||||
import type { Server } from "node:http";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { PostgresClient } from "./db/PostgresClient.js";
|
||||
import { EthersEthereumGateway } from "./ethereum/EthersEthereumGateway.js";
|
||||
import { createApp } from "./http/app.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { AmqpClient } from "./queue/AmqpClient.js";
|
||||
import { VaultClient } from "./secrets/VaultClient.js";
|
||||
import { TransferOrchestrator } from "./services/TransferOrchestrator.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const logger = createLogger(config.logLevel);
|
||||
logger.info({ event: "bootstrap.start" }, "starting usdt-transfer service");
|
||||
|
||||
const vault = new VaultClient({
|
||||
addr: config.vaultAddr,
|
||||
mountPoint: config.vaultMountPoint,
|
||||
roleId: config.vaultRoleId,
|
||||
secretId: config.vaultSecretId
|
||||
});
|
||||
const secrets = await vault.bootstrap();
|
||||
logger.info({ event: "bootstrap.vault_loaded" }, "vault secrets loaded");
|
||||
|
||||
const db = new PostgresClient(secrets.database);
|
||||
await db.ping();
|
||||
logger.info({ event: "bootstrap.pg_ready" }, "postgres pool ready");
|
||||
|
||||
const ethereum = new EthersEthereumGateway({
|
||||
rpcUrl: config.ethRpcUrl,
|
||||
privateKey: config.hotWalletPrivateKey,
|
||||
usdtContractAddress: config.usdtContractAddress
|
||||
});
|
||||
|
||||
let triggerShutdown: (reason: string) => void = () => {};
|
||||
|
||||
const amqp = new AmqpClient({
|
||||
secret: secrets.rabbitmq,
|
||||
logger,
|
||||
onConnectionLost: () => triggerShutdown("amqp_connection_lost")
|
||||
});
|
||||
await amqp.connect();
|
||||
|
||||
const orchestrator = new TransferOrchestrator(db, ethereum, amqp, {
|
||||
maxTransferAmountUnits: config.maxTransferAmountUnits,
|
||||
minEthBalanceWei: config.minEthBalanceWei,
|
||||
balanceCheckAttempts: config.balanceCheckAttempts,
|
||||
balanceCheckIntervalMs: config.balanceCheckIntervalMs
|
||||
});
|
||||
|
||||
await amqp.startConsumer((message, log) => orchestrator.handle(message, log));
|
||||
|
||||
const app = createApp({ ethereum, logger });
|
||||
const server: Server = await new Promise((resolve) => {
|
||||
const s = app.listen(config.port, () => resolve(s));
|
||||
});
|
||||
logger.info({ event: "bootstrap.http_listening", port: config.port }, "http listening");
|
||||
|
||||
let shuttingDown = false;
|
||||
triggerShutdown = (reason: string) => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
logger.warn({ event: "shutdown.start", reason }, "shutting down");
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await amqp.close();
|
||||
await db.close();
|
||||
logger.info({ event: "shutdown.done" }, "shutdown complete");
|
||||
process.exit(reason === "amqp_connection_lost" ? 1 : 0);
|
||||
} catch (error) {
|
||||
logger.error({ event: "shutdown.error", err: error }, "shutdown error");
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => triggerShutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => triggerShutdown("SIGTERM"));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
// Logger may not be ready yet; fall back to stderr.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("fatal bootstrap error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
20
src/logger.ts
Normal file
20
src/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { pino, type Logger } from "pino";
|
||||
|
||||
const VALID_LEVELS = new Set(["trace", "debug", "info", "warn", "error", "fatal", "silent"]);
|
||||
|
||||
function resolveLevel(input: string | undefined): string {
|
||||
if (!input) {
|
||||
return "info";
|
||||
}
|
||||
|
||||
const normalized = input.toLowerCase();
|
||||
return VALID_LEVELS.has(normalized) ? normalized : "info";
|
||||
}
|
||||
|
||||
export function createLogger(level: string | undefined): Logger {
|
||||
return pino({
|
||||
level: resolveLevel(level),
|
||||
base: { service: "usdt-transfer" },
|
||||
timestamp: pino.stdTimeFunctions.isoTime
|
||||
});
|
||||
}
|
||||
147
src/queue/AmqpClient.ts
Normal file
147
src/queue/AmqpClient.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import amqplib, { type ChannelModel, type Channel, type ConsumeMessage } from "amqplib";
|
||||
import type { Logger } from "pino";
|
||||
import type { RabbitMqSecret } from "../secrets/VaultClient.js";
|
||||
import type { CryptoTransferCompleted, CryptoTransferRequest } from "./messageSchema.js";
|
||||
import { parseCryptoTransferRequest } from "./messageSchema.js";
|
||||
|
||||
export const REQUEST_QUEUE = "crypto.transfer.requested";
|
||||
export const COMPLETED_QUEUE = "crypto.transfer.completed";
|
||||
|
||||
export type TransferRequestHandler = (
|
||||
message: CryptoTransferRequest,
|
||||
log: Logger
|
||||
) => Promise<void>;
|
||||
|
||||
export interface AmqpClientOptions {
|
||||
secret: RabbitMqSecret;
|
||||
logger: Logger;
|
||||
onConnectionLost: () => void;
|
||||
}
|
||||
|
||||
export class AmqpClient {
|
||||
private readonly logger: Logger;
|
||||
private readonly secret: RabbitMqSecret;
|
||||
private readonly onConnectionLost: () => void;
|
||||
private connection?: ChannelModel;
|
||||
private channel?: Channel;
|
||||
|
||||
constructor(options: AmqpClientOptions) {
|
||||
this.secret = options.secret;
|
||||
this.logger = options.logger.child({ component: "amqp" });
|
||||
this.onConnectionLost = options.onConnectionLost;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.connection = await amqplib.connect({
|
||||
protocol: "amqp",
|
||||
hostname: this.secret.host,
|
||||
port: this.secret.port,
|
||||
username: this.secret.user,
|
||||
password: this.secret.password,
|
||||
vhost: this.secret.vhost
|
||||
});
|
||||
|
||||
this.connection.on("error", (error) => {
|
||||
this.logger.error({ err: error }, "amqp connection error");
|
||||
});
|
||||
this.connection.on("close", () => {
|
||||
this.logger.error("amqp connection closed");
|
||||
this.onConnectionLost();
|
||||
});
|
||||
|
||||
this.channel = await this.connection.createChannel();
|
||||
await this.channel.prefetch(1);
|
||||
await this.channel.assertQueue(REQUEST_QUEUE, { durable: true });
|
||||
await this.channel.assertQueue(COMPLETED_QUEUE, { durable: true });
|
||||
|
||||
this.logger.info(
|
||||
{ host: this.secret.host, port: this.secret.port, vhost: this.secret.vhost },
|
||||
"amqp connected"
|
||||
);
|
||||
}
|
||||
|
||||
async startConsumer(handler: TransferRequestHandler): Promise<void> {
|
||||
if (!this.channel) {
|
||||
throw new Error("AmqpClient not connected");
|
||||
}
|
||||
|
||||
await this.channel.consume(REQUEST_QUEUE, (msg) => {
|
||||
if (!msg) {
|
||||
return;
|
||||
}
|
||||
void this.dispatch(msg, handler);
|
||||
});
|
||||
|
||||
this.logger.info({ queue: REQUEST_QUEUE }, "amqp consumer started");
|
||||
}
|
||||
|
||||
publishCompleted(payload: CryptoTransferCompleted): boolean {
|
||||
if (!this.channel) {
|
||||
throw new Error("AmqpClient not connected");
|
||||
}
|
||||
|
||||
return this.channel.sendToQueue(
|
||||
COMPLETED_QUEUE,
|
||||
Buffer.from(JSON.stringify(payload)),
|
||||
{ contentType: "application/json", persistent: true }
|
||||
);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
await this.channel?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await this.connection?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatch(msg: ConsumeMessage, handler: TransferRequestHandler): Promise<void> {
|
||||
if (!this.channel) {
|
||||
return;
|
||||
}
|
||||
const channel = this.channel;
|
||||
|
||||
let parsed: CryptoTransferRequest;
|
||||
try {
|
||||
const body = JSON.parse(msg.content.toString("utf8")) as unknown;
|
||||
parsed = parseCryptoTransferRequest(body);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ err: error, raw: safeRaw(msg) },
|
||||
"rejected malformed message"
|
||||
);
|
||||
channel.ack(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const log = this.logger.child({
|
||||
trace_id: parsed.trace_id,
|
||||
order_id: parsed.order_id,
|
||||
user_id: parsed.user_id,
|
||||
message_id: parsed.message_id
|
||||
});
|
||||
|
||||
log.info({ event: "transfer.requested.received" }, "received from queue");
|
||||
|
||||
try {
|
||||
await handler(parsed, log);
|
||||
channel.ack(msg);
|
||||
} catch (error) {
|
||||
log.error({ err: error }, "infrastructure error during message handling, requeue=false");
|
||||
channel.nack(msg, false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeRaw(msg: ConsumeMessage): string {
|
||||
try {
|
||||
return msg.content.toString("utf8").slice(0, 512);
|
||||
} catch {
|
||||
return "<unreadable>";
|
||||
}
|
||||
}
|
||||
37
src/queue/messageSchema.ts
Normal file
37
src/queue/messageSchema.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface CryptoTransferRequest {
|
||||
order_id: string;
|
||||
user_id: string;
|
||||
trace_id: string;
|
||||
message_id: string;
|
||||
}
|
||||
|
||||
export interface CryptoTransferCompleted {
|
||||
user_id: string;
|
||||
order_id: string;
|
||||
trace_id: string;
|
||||
message_id: string;
|
||||
}
|
||||
|
||||
const ULID_REGEX = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
||||
|
||||
export function parseCryptoTransferRequest(raw: unknown): CryptoTransferRequest {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw new Error("message body must be a JSON object");
|
||||
}
|
||||
|
||||
const candidate = raw as Record<string, unknown>;
|
||||
return {
|
||||
order_id: requireUlid(candidate, "order_id"),
|
||||
user_id: requireUlid(candidate, "user_id"),
|
||||
trace_id: requireUlid(candidate, "trace_id"),
|
||||
message_id: requireUlid(candidate, "message_id")
|
||||
};
|
||||
}
|
||||
|
||||
function requireUlid(source: Record<string, unknown>, key: string): string {
|
||||
const value = source[key];
|
||||
if (typeof value !== "string" || !ULID_REGEX.test(value)) {
|
||||
throw new Error(`field '${key}' must be a ULID string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
144
src/secrets/VaultClient.ts
Normal file
144
src/secrets/VaultClient.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
export interface VaultClientOptions {
|
||||
addr: string;
|
||||
mountPoint: string;
|
||||
roleId: string;
|
||||
secretId: string;
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface DatabaseSecret {
|
||||
host: string;
|
||||
port: number;
|
||||
name: string;
|
||||
user: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RabbitMqSecret {
|
||||
host: string;
|
||||
port: number;
|
||||
vhost: string;
|
||||
user: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface BootstrappedSecrets {
|
||||
database: DatabaseSecret;
|
||||
rabbitmq: RabbitMqSecret;
|
||||
}
|
||||
|
||||
export class VaultClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly mountPoint: string;
|
||||
private readonly roleId: string;
|
||||
private readonly secretId: string;
|
||||
private readonly timeoutMs: number;
|
||||
private token?: string;
|
||||
|
||||
constructor(options: VaultClientOptions) {
|
||||
this.baseUrl = options.addr.replace(/\/+$/, "");
|
||||
this.mountPoint = options.mountPoint;
|
||||
this.roleId = options.roleId;
|
||||
this.secretId = options.secretId;
|
||||
this.timeoutMs = options.requestTimeoutMs ?? 10_000;
|
||||
}
|
||||
|
||||
async bootstrap(): Promise<BootstrappedSecrets> {
|
||||
await this.login();
|
||||
const [database, rabbitmq] = await Promise.all([
|
||||
this.readSecret("database"),
|
||||
this.readSecret("rabbitmq")
|
||||
]);
|
||||
|
||||
return {
|
||||
database: parseDatabaseSecret(database),
|
||||
rabbitmq: parseRabbitMqSecret(rabbitmq)
|
||||
};
|
||||
}
|
||||
|
||||
private async login(): Promise<void> {
|
||||
const response = await this.fetch(`${this.baseUrl}/v1/auth/approle/login`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ role_id: this.roleId, secret_id: this.secretId })
|
||||
});
|
||||
|
||||
const json = (await response.json()) as { auth?: { client_token?: string } };
|
||||
const token = json.auth?.client_token;
|
||||
if (!token) {
|
||||
throw new Error("Vault AppRole login did not return a client_token");
|
||||
}
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
private async readSecret(path: string): Promise<Record<string, unknown>> {
|
||||
if (!this.token) {
|
||||
throw new Error("Vault token is missing; call bootstrap() first");
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}/v1/${this.mountPoint}/data/${path}`;
|
||||
const response = await this.fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "X-Vault-Token": this.token }
|
||||
});
|
||||
|
||||
const json = (await response.json()) as { data?: { data?: Record<string, unknown> } };
|
||||
const data = json.data?.data;
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error(`Vault secret ${this.mountPoint}/${path} is empty or malformed`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private async fetch(url: string, init: RequestInit): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { ...init, signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`Vault request to ${url} failed with HTTP ${response.status}: ${text}`);
|
||||
}
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseDatabaseSecret(raw: Record<string, unknown>): DatabaseSecret {
|
||||
return {
|
||||
host: requireString(raw, "host", "database"),
|
||||
port: requirePort(raw, "port", "database"),
|
||||
name: requireString(raw, "name", "database"),
|
||||
user: requireString(raw, "user", "database"),
|
||||
password: requireString(raw, "password", "database")
|
||||
};
|
||||
}
|
||||
|
||||
function parseRabbitMqSecret(raw: Record<string, unknown>): RabbitMqSecret {
|
||||
return {
|
||||
host: requireString(raw, "host", "rabbitmq"),
|
||||
port: requirePort(raw, "port", "rabbitmq"),
|
||||
vhost: requireString(raw, "vhost", "rabbitmq"),
|
||||
user: requireString(raw, "user", "rabbitmq"),
|
||||
password: requireString(raw, "password", "rabbitmq")
|
||||
};
|
||||
}
|
||||
|
||||
function requireString(raw: Record<string, unknown>, key: string, secretName: string): string {
|
||||
const value = raw[key];
|
||||
if (typeof value !== "string" || !value) {
|
||||
throw new Error(`Vault secret '${secretName}' is missing string field '${key}'`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePort(raw: Record<string, unknown>, key: string, secretName: string): number {
|
||||
const value = raw[key];
|
||||
const numeric = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0 || numeric > 65535) {
|
||||
throw new Error(`Vault secret '${secretName}' has invalid port '${String(value)}'`);
|
||||
}
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
201
src/services/TransferOrchestrator.ts
Normal file
201
src/services/TransferOrchestrator.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { isAddress } from "ethers";
|
||||
import type { Logger } from "pino";
|
||||
import { ulid } from "ulid";
|
||||
import { parseUsdtAmount } from "../domain/amount.js";
|
||||
import type { PostgresClient } from "../db/PostgresClient.js";
|
||||
import type { EthereumGateway } from "../ethereum/EthereumGateway.js";
|
||||
import type { AmqpClient } from "../queue/AmqpClient.js";
|
||||
import type { CryptoTransferRequest } from "../queue/messageSchema.js";
|
||||
|
||||
export interface TransferOrchestratorOptions {
|
||||
maxTransferAmountUnits: bigint;
|
||||
minEthBalanceWei: bigint;
|
||||
balanceCheckAttempts: number;
|
||||
balanceCheckIntervalMs: number;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export class TransferOrchestrator {
|
||||
private readonly sleep: (ms: number) => Promise<void>;
|
||||
|
||||
constructor(
|
||||
private readonly db: PostgresClient,
|
||||
private readonly ethereum: EthereumGateway,
|
||||
private readonly amqp: AmqpClient,
|
||||
private readonly options: TransferOrchestratorOptions
|
||||
) {
|
||||
this.sleep = options.sleep ?? defaultSleep;
|
||||
}
|
||||
|
||||
async handle(message: CryptoTransferRequest, log: Logger): Promise<void> {
|
||||
const existing = await this.db.getPaymentByOrderId(message.order_id);
|
||||
if (!existing) {
|
||||
log.error({ event: "payment.missing" }, "no payment row for order_id, ack");
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.status === "usdt_delivered") {
|
||||
log.info({ event: "payment.already_delivered" }, "skip — already delivered");
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.status === "web3_hash_error" || existing.status === "web3_balance_problem") {
|
||||
log.warn(
|
||||
{ event: "payment.terminal_state", status: existing.status },
|
||||
"skip — payment already in terminal failure state"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const usdtAmountString = await this.db.getOrderUsdtAmount(message.order_id);
|
||||
if (!usdtAmountString) {
|
||||
log.error({ event: "order.not_found" }, "order missing for order_id");
|
||||
await this.db.markPaymentHashError(message.order_id);
|
||||
return;
|
||||
}
|
||||
|
||||
let amountUnits: bigint;
|
||||
try {
|
||||
amountUnits = parseUsdtAmount(usdtAmountString);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
{ event: "order.invalid_amount", err: error, usdt_amount: usdtAmountString },
|
||||
"could not parse usdt_amount"
|
||||
);
|
||||
await this.db.markPaymentHashError(message.order_id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (amountUnits > this.options.maxTransferAmountUnits) {
|
||||
log.error(
|
||||
{ event: "transfer.amount_exceeds_limit", amount_units: amountUnits.toString() },
|
||||
"usdt_amount exceeds MAX_TRANSFER_USDT"
|
||||
);
|
||||
await this.db.markPaymentHashError(message.order_id);
|
||||
return;
|
||||
}
|
||||
|
||||
const recipient = await this.db.getUserEthWalletAddress(message.user_id);
|
||||
if (!recipient || !isAddress(recipient)) {
|
||||
log.error({ event: "wallet.not_found_or_invalid", recipient }, "no valid ETH wallet for user");
|
||||
await this.db.markPaymentHashError(message.order_id);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.assertHotWalletReady(amountUnits);
|
||||
} catch (error) {
|
||||
log.error({ event: "hot_wallet.not_ready", err: error }, "hot wallet pre-check failed");
|
||||
await this.db.markPaymentHashError(message.order_id);
|
||||
return;
|
||||
}
|
||||
|
||||
let preBalance: bigint;
|
||||
try {
|
||||
preBalance = await this.ethereum.getUsdtBalance(recipient);
|
||||
} catch (error) {
|
||||
log.error({ event: "rpc.pre_balance_failed", err: error }, "failed to read recipient pre-balance");
|
||||
await this.db.markPaymentHashError(message.order_id);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ event: "transfer.pre_balance", pre_balance_units: preBalance.toString(), amount_units: amountUnits.toString() },
|
||||
"captured pre-balance"
|
||||
);
|
||||
|
||||
let txHash: string;
|
||||
try {
|
||||
const tx = await this.ethereum.sendUsdtTransfer(recipient, amountUnits);
|
||||
txHash = tx.hash;
|
||||
} catch (error) {
|
||||
log.error({ event: "transfer.broadcast_failed", err: error }, "broadcast failed");
|
||||
await this.db.markPaymentHashError(message.order_id);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info({ event: "transfer.broadcasted", tx_hash: txHash }, "broadcast OK");
|
||||
await this.db.setPaymentTxHash(message.order_id, txHash);
|
||||
|
||||
const delivered = await this.pollForBalance(recipient, preBalance, amountUnits, log);
|
||||
if (!delivered) {
|
||||
log.error({ event: "transfer.balance_problem", tx_hash: txHash }, "balance did not increase after 3 checks");
|
||||
await this.db.markPaymentBalanceProblem(message.order_id, txHash);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db.markPaymentDelivered(message.order_id, txHash);
|
||||
log.info({ event: "transfer.delivered", tx_hash: txHash }, "marked usdt_delivered");
|
||||
|
||||
const completedMessageId = ulid();
|
||||
this.amqp.publishCompleted({
|
||||
user_id: message.user_id,
|
||||
order_id: message.order_id,
|
||||
trace_id: message.trace_id,
|
||||
message_id: completedMessageId
|
||||
});
|
||||
log.info(
|
||||
{ event: "transfer.completed.published", message_id: completedMessageId },
|
||||
"published crypto.transfer.completed"
|
||||
);
|
||||
}
|
||||
|
||||
private async assertHotWalletReady(amountUnits: bigint): Promise<void> {
|
||||
const hotWalletAddress = this.ethereum.getHotWalletAddress();
|
||||
const [ethBalanceWei, usdtBalanceUnits] = await Promise.all([
|
||||
this.ethereum.getEthBalance(hotWalletAddress),
|
||||
this.ethereum.getUsdtBalance(hotWalletAddress)
|
||||
]);
|
||||
|
||||
if (ethBalanceWei < this.options.minEthBalanceWei) {
|
||||
throw new Error("Hot wallet ETH balance is below gas threshold");
|
||||
}
|
||||
if (usdtBalanceUnits < amountUnits) {
|
||||
throw new Error("Hot wallet USDT balance is insufficient");
|
||||
}
|
||||
}
|
||||
|
||||
private async pollForBalance(
|
||||
recipient: string,
|
||||
preBalance: bigint,
|
||||
amountUnits: bigint,
|
||||
log: Logger
|
||||
): Promise<boolean> {
|
||||
for (let attempt = 1; attempt <= this.options.balanceCheckAttempts; attempt += 1) {
|
||||
await this.sleep(this.options.balanceCheckIntervalMs);
|
||||
|
||||
let postBalance: bigint;
|
||||
try {
|
||||
postBalance = await this.ethereum.getUsdtBalance(recipient);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
{ event: "balance_check.rpc_error", attempt, err: error },
|
||||
"balance check RPC error"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const delta = postBalance - preBalance;
|
||||
log.info(
|
||||
{
|
||||
event: "balance_check.result",
|
||||
attempt,
|
||||
attempts_total: this.options.balanceCheckAttempts,
|
||||
post_balance_units: postBalance.toString(),
|
||||
delta_units: delta.toString()
|
||||
},
|
||||
"balance check"
|
||||
);
|
||||
|
||||
if (delta >= amountUnits) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
24
tests/amount.test.ts
Normal file
24
tests/amount.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { formatUsdtAmount, parseUsdtAmount } from "../src/domain/amount.js";
|
||||
|
||||
void describe("USDT amount parsing", () => {
|
||||
void it("parses whole and fractional USDT with 6 decimals", () => {
|
||||
assert.equal(parseUsdtAmount("1"), 1_000_000n);
|
||||
assert.equal(parseUsdtAmount("1.234567"), 1_234_567n);
|
||||
assert.equal(parseUsdtAmount("0.000001"), 1n);
|
||||
});
|
||||
|
||||
void it("rejects zero, negative, malformed, and over-precise values", () => {
|
||||
assert.throws(() => parseUsdtAmount("0"), /greater than zero/);
|
||||
assert.throws(() => parseUsdtAmount("-1"), /valid decimal/);
|
||||
assert.throws(() => parseUsdtAmount("1.0000001"), /at most 6 decimals/);
|
||||
assert.throws(() => parseUsdtAmount("1e3"), /valid decimal/);
|
||||
});
|
||||
|
||||
void it("formats raw token units back to a trimmed USDT string", () => {
|
||||
assert.equal(formatUsdtAmount(1_234_567n), "1.234567");
|
||||
assert.equal(formatUsdtAmount(1_000_000n), "1");
|
||||
assert.equal(formatUsdtAmount(10n), "0.00001");
|
||||
});
|
||||
});
|
||||
19
tests/gasErrorMessage.test.ts
Normal file
19
tests/gasErrorMessage.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { humanizeTransferError } from "../src/ethereum/EthersEthereumGateway.js";
|
||||
|
||||
void describe("humanizeTransferError", () => {
|
||||
void it("translates low gas price into a readable error", () => {
|
||||
const message = humanizeTransferError({
|
||||
message: "missing revert data",
|
||||
info: {
|
||||
error: {
|
||||
message:
|
||||
"failed with 3080732 gas: max fee per gas less than block base fee: address 0xabc, maxFeePerGas: 960000000, baseFee: 1575278655"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert.match(message, /Transfer fee 0.96 gwei is below current base fee 1.575278655 gwei/);
|
||||
});
|
||||
});
|
||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user