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

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