update: ....

This commit is contained in:
2026-03-19 01:02:52 +04:00
parent 1e6739b42a
commit 955b26f942
6 changed files with 267 additions and 194 deletions
+2
View File
@@ -31,6 +31,8 @@ export async function migrate() {
)
`;
await sql`ALTER TABLE payments ADD COLUMN IF NOT EXISTS amount_received TEXT NOT NULL DEFAULT '0'`;
await sql`CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payments_account ON payments(account_id)`;
+104 -107
View File
@@ -1,41 +1,37 @@
/// Payment monitor: raw SSE stream for instant tx/block detection,
/// with bulk polling as fallback.
/// States: pending → underpaid → confirming → paid (or expired)
import sql from "./db";
import { getAddressInfo, getAddressInfoBulk } from "./freedom";
import { COINS } from "./plans";
const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st";
const THRESHOLD = 0.995; // 0.5% tolerance for network fees
// ── In-memory maps ──────────────────────────────────────────────────
let addressMap = new Map<string, any>();
let confirmingMap = new Map<number, { payment: any; txids: Set<string> }>();
let txidLookup = new Map<string, number>();
let txidLookup = new Map<string, number>(); // txid → payment.id
const seenTxids = new Set<string>();
async function refreshMaps() {
const active = await sql`
SELECT * FROM payments
WHERE status IN ('pending', 'confirming')
WHERE status IN ('pending', 'underpaid', 'confirming')
AND expires_at >= now()
`;
const newAddr = new Map<string, any>();
const newConfirming = new Map<number, { payment: any; txids: Set<string> }>();
const newTxidLookup = new Map<string, number>();
for (const p of active) {
newAddr.set(p.address, p);
if (p.status === "confirming" && p.txid) {
const existing = confirmingMap.get(p.id);
const txids = existing?.txids ?? new Set<string>();
txids.add(p.txid);
newConfirming.set(p.id, { payment: p, txids });
for (const t of txids) newTxidLookup.set(t, p.id);
if (p.txid) {
// txid column may contain comma-separated txids
for (const t of p.txid.split(",")) newTxidLookup.set(t, p.id);
}
}
addressMap = newAddr;
confirmingMap = newConfirming;
txidLookup = newTxidLookup;
for (const txid of seenTxids) {
@@ -43,11 +39,10 @@ async function refreshMaps() {
}
}
// ── Single raw SSE connection — no query, all chains ────────────────
// ── Single raw SSE connection ───────────────────────────────────────
function startSSE() {
const url = `${SOCK_API}/sse`;
connectSSE(url);
connectSSE(`${SOCK_API}/sse`);
}
async function connectSSE(url: string) {
@@ -94,6 +89,8 @@ async function connectSSE(url: string) {
}
}
// ── SSE tx handler ──────────────────────────────────────────────────
async function handleTxEvent(event: any) {
const outputs = event.data?.out ?? [];
const txHash = event.data?.tx?.hash ?? null;
@@ -111,82 +108,99 @@ async function handleTxEvent(event: any) {
const coin = COINS[payment.coin];
if (!coin) continue;
console.log(`SSE: tx ${txHash} for payment ${payment.id} (${payment.coin})`);
// Sum output value going to our address in this tx
const txValue = outputs
.filter((o: any) => o?.script?.address === addr)
.reduce((sum: number, o: any) => sum + Number(o.value ?? 0), 0);
if (coin.confirmations === 0) {
try {
const info = await getAddressInfo(payment.address);
if (!info || info.error) continue;
const received = Number(info.received ?? 0);
const threshold = parseFloat(payment.amount_crypto) * 0.995;
if (received >= threshold) {
await activatePayment(payment, txHash);
addressMap.delete(addr);
}
} catch {}
const prevReceived = parseFloat(payment.amount_received || "0");
const newReceived = prevReceived + txValue;
const expected = parseFloat(payment.amount_crypto);
const threshold = expected * THRESHOLD;
console.log(`SSE: tx ${txHash} for payment ${payment.id}: +${txValue} ${payment.coin} (total: ${newReceived}/${expected})`);
// Append txid
const txids = payment.txid ? payment.txid + "," + txHash : txHash;
if (coin.confirmations === 0 && newReceived >= threshold) {
// 0-conf, full amount: activate immediately
await sql`UPDATE payments SET amount_received = ${newReceived.toFixed(8)}, txid = ${txids}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`;
await applyPlan(payment);
addressMap.delete(addr);
console.log(`Payment ${payment.id} paid (0-conf)`);
} else if (coin.confirmations === 0 && newReceived > 0) {
// 0-conf, partial: underpaid
await sql`UPDATE payments SET amount_received = ${newReceived.toFixed(8)}, txid = ${txids}, status = 'underpaid' WHERE id = ${payment.id}`;
payment.amount_received = newReceived.toFixed(8);
payment.txid = txids;
payment.status = "underpaid";
console.log(`Payment ${payment.id} underpaid (0-conf): ${newReceived}/${expected}`);
} else if (newReceived >= threshold) {
// 1+ conf, full amount: confirming
await sql`UPDATE payments SET amount_received = ${newReceived.toFixed(8)}, txid = ${txids}, status = 'confirming' WHERE id = ${payment.id}`;
payment.amount_received = newReceived.toFixed(8);
payment.txid = txids;
payment.status = "confirming";
for (const t of txids.split(",")) txidLookup.set(t, payment.id);
console.log(`Payment ${payment.id} confirming: ${newReceived}/${expected}`);
} else {
if (payment.status === "pending") {
await sql`UPDATE payments SET status = 'confirming', txid = ${txHash} WHERE id = ${payment.id}`;
payment.status = "confirming";
payment.txid = txHash;
console.log(`Payment ${payment.id} now confirming`);
}
let entry = confirmingMap.get(payment.id);
if (!entry) {
entry = { payment, txids: new Set() };
confirmingMap.set(payment.id, entry);
}
entry.txids.add(txHash);
txidLookup.set(txHash, payment.id);
// 1+ conf, partial: underpaid
await sql`UPDATE payments SET amount_received = ${newReceived.toFixed(8)}, txid = ${txids}, status = 'underpaid' WHERE id = ${payment.id}`;
payment.amount_received = newReceived.toFixed(8);
payment.txid = txids;
payment.status = "underpaid";
for (const t of txids.split(",")) txidLookup.set(t, payment.id);
console.log(`Payment ${payment.id} underpaid: ${newReceived}/${expected}`);
}
return; // Only process first matching output set per tx
}
}
// ── SSE block handler ───────────────────────────────────────────────
async function handleBlockEvent(event: any) {
const blockTxs: string[] = event.data?.tx ?? [];
if (blockTxs.length === 0) return;
const toCheck = new Set<number>();
// Find payments with txids in this block
const paymentIds = new Set<number>();
for (const txid of blockTxs) {
const paymentId = txidLookup.get(txid);
if (paymentId != null) toCheck.add(paymentId);
const pid = txidLookup.get(txid);
if (pid != null) paymentIds.add(pid);
}
if (toCheck.size === 0) return;
if (paymentIds.size === 0) return;
const addressesToCheck: string[] = [];
const paymentsByAddress = new Map<string, { entry: { payment: any; txids: Set<string> }; paymentId: number }>();
for (const pid of paymentIds) {
// Re-fetch from DB for latest state
const [payment] = await sql`
SELECT * FROM payments WHERE id = ${pid} AND status IN ('underpaid', 'confirming')
`;
if (!payment) continue;
for (const paymentId of toCheck) {
const entry = confirmingMap.get(paymentId);
if (!entry) continue;
addressesToCheck.push(entry.payment.address);
paymentsByAddress.set(entry.payment.address, { entry, paymentId });
}
if (addressesToCheck.length === 0) return;
let bulk: Record<string, any> = {};
try { bulk = await getAddressInfoBulk(addressesToCheck); } catch {}
for (const [addr, { entry, paymentId }] of paymentsByAddress) {
let info = bulk[addr];
if (!info) {
try { info = await getAddressInfo(addr); } catch { continue; }
}
// Check confirmed amount via address API
let info: any;
try { info = await getAddressInfo(payment.address); } catch { continue; }
if (!info || info.error) continue;
const receivedConfirmed = Number(info.received_confirmed ?? 0);
const threshold = parseFloat(entry.payment.amount_crypto) * 0.995;
const expected = parseFloat(payment.amount_crypto);
const threshold = expected * THRESHOLD;
if (receivedConfirmed >= threshold) {
console.log(`SSE: block confirmed payment ${paymentId}`);
const txid = entry.payment.txid || [...entry.txids][0] || null;
await activatePayment(entry.payment, txid);
for (const t of entry.txids) txidLookup.delete(t);
confirmingMap.delete(paymentId);
addressMap.delete(addr);
await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`;
await applyPlan(payment);
addressMap.delete(payment.address);
// Clean up txid lookups
if (payment.txid) {
for (const t of payment.txid.split(",")) txidLookup.delete(t);
}
console.log(`Payment ${payment.id} paid (confirmed)`);
} else if (receivedConfirmed > 0) {
// Partially confirmed — update amount_received
await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)} WHERE id = ${payment.id}`;
}
}
}
@@ -196,7 +210,7 @@ async function handleBlockEvent(event: any) {
export async function checkPayments() {
await sql`
UPDATE payments SET status = 'expired'
WHERE status IN ('pending', 'confirming')
WHERE status IN ('pending', 'underpaid', 'confirming')
AND expires_at < now()
`;
@@ -204,7 +218,7 @@ export async function checkPayments() {
const allPayments = await sql`
SELECT * FROM payments
WHERE status IN ('pending', 'confirming')
WHERE status IN ('pending', 'underpaid', 'confirming')
AND expires_at >= now()
`;
@@ -231,23 +245,26 @@ export async function checkPayments() {
const received = Number(info.received ?? 0);
const receivedConfirmed = Number(info.received_confirmed ?? 0);
const expectedCrypto = parseFloat(payment.amount_crypto);
const threshold = expectedCrypto * 0.995;
const expected = parseFloat(payment.amount_crypto);
const threshold = expected * THRESHOLD;
const txid = payment.txid || findTxid(info);
if (payment.status === "pending") {
if (payment.status === "pending" || payment.status === "underpaid") {
if (coin.confirmations === 0 && received >= threshold) {
await activatePayment(payment, findTxid(info));
await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, txid = ${txid}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`;
await applyPlan(payment);
} else if (coin.confirmations > 0 && receivedConfirmed >= threshold) {
await activatePayment(payment, findTxid(info));
await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)}, txid = ${txid}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`;
await applyPlan(payment);
} else if (received >= threshold) {
const txid = findTxid(info);
await sql`UPDATE payments SET status = 'confirming', txid = ${txid} WHERE id = ${payment.id}`;
console.log(`Poll: payment ${payment.id} now confirming`);
await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, txid = ${txid}, status = 'confirming' WHERE id = ${payment.id}`;
} else if (received > 0) {
await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, txid = ${txid}, status = 'underpaid' WHERE id = ${payment.id}`;
}
} else if (payment.status === "confirming") {
if (receivedConfirmed >= threshold) {
const txid = payment.txid || findTxid(info);
await activatePayment(payment, txid);
await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`;
await applyPlan(payment);
}
}
} catch (e) {
@@ -258,46 +275,27 @@ export async function checkPayments() {
// ── Helpers ───────────────────────────────────────────────────────────
/** Add a payment to the address map immediately (called from routes on checkout creation). */
export function watchPayment(payment: any) {
addressMap.set(payment.address, payment);
}
function findTxid(info: any): string | null {
if (info.in?.length) return info.in[0].txid ?? null;
if (info.in?.length) return info.in.map((i: any) => i.txid).filter(Boolean).join(",") || null;
return null;
}
async function activatePayment(payment: any, txid: string | null) {
const [updated] = await sql`
UPDATE payments
SET status = 'paid', paid_at = now(), txid = ${txid}
WHERE id = ${payment.id} AND status != 'paid'
RETURNING id
`;
if (!updated) return;
async function applyPlan(payment: any) {
if (payment.plan === "lifetime") {
await sql`
UPDATE accounts SET plan = 'lifetime', plan_expires_at = NULL
WHERE id = ${payment.account_id}
`;
await sql`UPDATE accounts SET plan = 'lifetime', plan_expires_at = NULL WHERE id = ${payment.account_id}`;
} else {
const [account] = await sql`
SELECT plan, plan_expires_at FROM accounts WHERE id = ${payment.account_id}
`;
const [account] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${payment.account_id}`;
const now = new Date();
const currentExpiry = account.plan_expires_at ? new Date(account.plan_expires_at) : null;
const base = (account.plan === "pro" && currentExpiry && currentExpiry > now) ? currentExpiry : now;
const newExpiry = new Date(base);
newExpiry.setMonth(newExpiry.getMonth() + payment.months);
await sql`
UPDATE accounts SET plan = 'pro', plan_expires_at = ${newExpiry.toISOString()}
WHERE id = ${payment.account_id}
`;
await sql`UPDATE accounts SET plan = 'pro', plan_expires_at = ${newExpiry.toISOString()} WHERE id = ${payment.account_id}`;
}
console.log(`Payment ${payment.id} activated: ${payment.plan} for account ${payment.account_id}`);
}
@@ -317,5 +315,4 @@ function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms));
}
// Start the single SSE connection immediately on import
startSSE();
+11 -1
View File
@@ -115,6 +115,8 @@ export const routes = new Elysia()
amount_usd: Number(payment.amount_usd),
coin: payment.coin,
amount_crypto: payment.amount_crypto,
amount_received: "0",
amount_remaining: payment.amount_crypto,
address: payment.address,
status: payment.status,
expires_at: payment.expires_at,
@@ -138,7 +140,13 @@ export const routes = new Elysia()
if (!payment) { set.status = 404; return { error: "Payment not found" }; }
const coinInfo = COINS[payment.coin];
const uri = `${coinInfo.uri}:${payment.address}?amount=${payment.amount_crypto}`;
const amountCrypto = parseFloat(payment.amount_crypto);
const amountReceived = parseFloat(payment.amount_received || "0");
const amountRemaining = Math.max(0, amountCrypto - amountReceived);
// QR shows remaining amount (or full amount if nothing received yet)
const qrAmount = amountRemaining > 0 ? amountRemaining.toFixed(8) : payment.amount_crypto;
const uri = `${coinInfo.uri}:${payment.address}?amount=${qrAmount}`;
return {
id: payment.id,
@@ -147,6 +155,8 @@ export const routes = new Elysia()
amount_usd: Number(payment.amount_usd),
coin: payment.coin,
amount_crypto: payment.amount_crypto,
amount_received: payment.amount_received || "0",
amount_remaining: amountRemaining.toFixed(8),
address: payment.address,
status: payment.status,
created_at: payment.created_at,