140 lines
4.3 KiB
Markdown
140 lines
4.3 KiB
Markdown
# 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.
|