Initial commit: USDT transfer worker (RabbitMQ + Postgres + Vault)
This commit is contained in:
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.
|
||||
Reference in New Issue
Block a user