229 lines
6.4 KiB
TypeScript
229 lines
6.4 KiB
TypeScript
/**
|
|
* TRC20 USDT Monitor & Auto-Distribution Service
|
|
*
|
|
* Flow:
|
|
* 1. Poll TRON address for incoming USDT transactions every 30s
|
|
* 2. For each new confirmed tx, record in DB
|
|
* 3. Look for pre-registered EVM address intent (from trc20_intents table)
|
|
* 4. Auto-match the EVM address to the purchase record
|
|
* 5. Distribute XIC from operator wallet to buyer's EVM address (if available)
|
|
* OR mark as pending manual distribution
|
|
*/
|
|
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { getDb } from "./db";
|
|
import { trc20Purchases, trc20Intents } from "../drizzle/schema";
|
|
import { TOKEN_PRICE_USDT } from "./onchain";
|
|
import { notifyNewTRC20Purchase } from "./telegram";
|
|
|
|
const TRON_RECEIVING_ADDRESS = "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp";
|
|
const TRON_USDT_CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
|
|
// Trongrid API endpoint (public, no key needed for basic queries)
|
|
const TRONGRID_API = "https://api.trongrid.io";
|
|
|
|
let isMonitoring = false;
|
|
let monitorInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
interface TronTransaction {
|
|
transaction_id: string;
|
|
token_info?: { address: string; decimals: number };
|
|
from: string;
|
|
to: string;
|
|
value: string;
|
|
block_timestamp: number;
|
|
type: string;
|
|
}
|
|
|
|
async function fetchRecentTRC20Transfers(): Promise<TronTransaction[]> {
|
|
try {
|
|
const url = `${TRONGRID_API}/v1/accounts/${TRON_RECEIVING_ADDRESS}/transactions/trc20?limit=50&contract_address=${TRON_USDT_CONTRACT}&only_confirmed=true`;
|
|
const resp = await fetch(url, {
|
|
headers: { "Content-Type": "application/json" },
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
if (!resp.ok) {
|
|
console.warn(`[TRC20Monitor] API error: ${resp.status}`);
|
|
return [];
|
|
}
|
|
const data = await resp.json() as { data?: TronTransaction[]; success?: boolean };
|
|
if (!data.success || !Array.isArray(data.data)) return [];
|
|
// Only incoming transfers (to our address)
|
|
return data.data.filter((tx) => tx.to === TRON_RECEIVING_ADDRESS);
|
|
} catch (e) {
|
|
console.warn("[TRC20Monitor] Fetch error:", e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function processTransaction(tx: TronTransaction): Promise<void> {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
|
|
// Check if already processed
|
|
const existing = await db
|
|
.select()
|
|
.from(trc20Purchases)
|
|
.where(eq(trc20Purchases.txHash, tx.transaction_id))
|
|
.limit(1);
|
|
|
|
if (existing.length > 0) return; // Already recorded
|
|
|
|
// USDT has 6 decimals on TRON
|
|
const usdtAmount = Number(tx.value) / 1_000_000;
|
|
if (usdtAmount < 0.01) return; // Skip dust
|
|
|
|
const xicAmount = usdtAmount / TOKEN_PRICE_USDT;
|
|
|
|
console.log(
|
|
`[TRC20Monitor] New purchase: ${tx.from} → ${usdtAmount} USDT → ${xicAmount} XIC (tx: ${tx.transaction_id})`
|
|
);
|
|
|
|
// Look for a pre-registered EVM intent (user submitted their EVM address before sending)
|
|
// Match by most recent unmatched intent
|
|
let matchedEvmAddress: string | null = null;
|
|
let matchedIntentId: number | null = null;
|
|
|
|
const recentIntents = await db
|
|
.select()
|
|
.from(trc20Intents)
|
|
.where(eq(trc20Intents.matched, false))
|
|
.orderBy(trc20Intents.createdAt)
|
|
.limit(1);
|
|
|
|
if (recentIntents.length > 0) {
|
|
matchedEvmAddress = recentIntents[0].evmAddress;
|
|
matchedIntentId = recentIntents[0].id;
|
|
console.log(`[TRC20Monitor] Auto-matched EVM address ${matchedEvmAddress} for TX ${tx.transaction_id}`);
|
|
}
|
|
|
|
// Record in DB with EVM address if matched
|
|
await db.insert(trc20Purchases).values({
|
|
txHash: tx.transaction_id,
|
|
fromAddress: tx.from,
|
|
usdtAmount: String(usdtAmount),
|
|
xicAmount: String(xicAmount),
|
|
blockNumber: tx.block_timestamp,
|
|
status: "confirmed",
|
|
evmAddress: matchedEvmAddress || undefined,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
// Mark the intent as matched
|
|
if (matchedIntentId !== null) {
|
|
await db
|
|
.update(trc20Intents)
|
|
.set({ matched: true })
|
|
.where(eq(trc20Intents.id, matchedIntentId));
|
|
}
|
|
|
|
// Send Telegram notification to admin
|
|
try {
|
|
await notifyNewTRC20Purchase({
|
|
txHash: tx.transaction_id,
|
|
fromAddress: tx.from,
|
|
usdtAmount,
|
|
xicAmount,
|
|
evmAddress: matchedEvmAddress,
|
|
});
|
|
} catch (e) {
|
|
console.warn("[TRC20Monitor] Telegram notification failed:", e);
|
|
}
|
|
|
|
// Attempt auto-distribution via BSC
|
|
await attemptAutoDistribute(tx.transaction_id, tx.from, xicAmount, matchedEvmAddress);
|
|
}
|
|
|
|
async function attemptAutoDistribute(
|
|
txHash: string,
|
|
fromTronAddress: string,
|
|
xicAmount: number,
|
|
evmAddress: string | null
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
|
|
const operatorPrivateKey = process.env.OPERATOR_PRIVATE_KEY;
|
|
|
|
if (!operatorPrivateKey) {
|
|
console.warn("[TRC20Monitor] No OPERATOR_PRIVATE_KEY set, skipping auto-distribute");
|
|
return;
|
|
}
|
|
|
|
if (!evmAddress) {
|
|
console.log(
|
|
`[TRC20Monitor] No EVM address for ${fromTronAddress}: ${xicAmount} XIC — admin must distribute manually`
|
|
);
|
|
return;
|
|
}
|
|
|
|
// EVM address available — log for admin to distribute
|
|
console.log(
|
|
`[TRC20Monitor] Ready to distribute ${xicAmount} XIC to ${evmAddress} for TX ${txHash}`
|
|
);
|
|
console.log(
|
|
`[TRC20Monitor] Admin can mark as distributed via admin panel`
|
|
);
|
|
}
|
|
|
|
export async function startTRC20Monitor(): Promise<void> {
|
|
if (isMonitoring) return;
|
|
isMonitoring = true;
|
|
|
|
console.log("[TRC20Monitor] Starting monitor for", TRON_RECEIVING_ADDRESS);
|
|
|
|
const poll = async () => {
|
|
try {
|
|
const txs = await fetchRecentTRC20Transfers();
|
|
for (const tx of txs) {
|
|
await processTransaction(tx);
|
|
}
|
|
} catch (e) {
|
|
console.error("[TRC20Monitor] Poll error:", e);
|
|
}
|
|
};
|
|
|
|
// Initial poll
|
|
await poll();
|
|
|
|
// Poll every 30 seconds
|
|
monitorInterval = setInterval(poll, 30_000);
|
|
}
|
|
|
|
export function stopTRC20Monitor(): void {
|
|
if (monitorInterval) {
|
|
clearInterval(monitorInterval);
|
|
monitorInterval = null;
|
|
}
|
|
isMonitoring = false;
|
|
}
|
|
|
|
export async function getRecentPurchases(limit = 20): Promise<Array<{
|
|
txHash: string;
|
|
fromAddress: string;
|
|
usdtAmount: number;
|
|
xicAmount: number;
|
|
status: string;
|
|
createdAt: Date;
|
|
chain: string;
|
|
}>> {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(trc20Purchases)
|
|
.orderBy(sql`${trc20Purchases.createdAt} DESC`)
|
|
.limit(limit);
|
|
|
|
return rows.map((r) => ({
|
|
txHash: r.txHash,
|
|
fromAddress: r.fromAddress,
|
|
usdtAmount: Number(r.usdtAmount),
|
|
xicAmount: Number(r.xicAmount),
|
|
status: r.status,
|
|
createdAt: r.createdAt,
|
|
chain: "TRON",
|
|
}));
|
|
}
|