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