Initial commit: USDT transfer worker (RabbitMQ + Postgres + Vault)

This commit is contained in:
ZOMBIIIIIII
2026-04-30 20:00:21 +03:00
commit f250b99288
24 changed files with 3283 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.env
.git
.gitignore
*.log
README.md

17
.env.example Normal file
View 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
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log

18
Dockerfile Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View 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
View 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
View 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
View 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
View 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";

View 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 }>;
}

View 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
View 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
View 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
View 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
View 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>";
}
}

View 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
View 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);
}

View 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
View 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");
});
});

View 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
View 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"]
}