update: .......

This commit is contained in:
nate 2026-03-19 01:07:49 +04:00
parent 955b26f942
commit e62b60e0fd
4 changed files with 124 additions and 86 deletions

View File

@ -33,6 +33,20 @@ export async function migrate() {
await sql`ALTER TABLE payments ADD COLUMN IF NOT EXISTS amount_received TEXT NOT NULL DEFAULT '0'`; await sql`ALTER TABLE payments ADD COLUMN IF NOT EXISTS amount_received TEXT NOT NULL DEFAULT '0'`;
await sql`
CREATE TABLE IF NOT EXISTS payment_txs (
id BIGSERIAL PRIMARY KEY,
payment_id BIGINT NOT NULL REFERENCES payments(id) ON DELETE CASCADE,
txid TEXT NOT NULL,
amount TEXT NOT NULL,
confirmed BOOLEAN NOT NULL DEFAULT false,
detected_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(payment_id, txid)
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_payment_txs_payment ON payment_txs(payment_id)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payment_txs_txid ON payment_txs(txid)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status)`; 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)`; await sql`CREATE INDEX IF NOT EXISTS idx_payments_account ON payments(account_id)`;

View File

@ -6,11 +6,11 @@ import { getAddressInfo, getAddressInfoBulk } from "./freedom";
import { COINS } from "./plans"; import { COINS } from "./plans";
const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st"; const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st";
const THRESHOLD = 0.995; // 0.5% tolerance for network fees const THRESHOLD = 0.995;
// ── In-memory maps ────────────────────────────────────────────────── // ── In-memory maps ──────────────────────────────────────────────────
let addressMap = new Map<string, any>(); let addressMap = new Map<string, any>(); // address → payment
let txidLookup = new Map<string, number>(); // txid → payment.id let txidToPayment = new Map<string, number>(); // txid → payment.id
const seenTxids = new Set<string>(); const seenTxids = new Set<string>();
async function refreshMaps() { async function refreshMaps() {
@ -21,21 +21,24 @@ async function refreshMaps() {
`; `;
const newAddr = new Map<string, any>(); const newAddr = new Map<string, any>();
const newTxidLookup = new Map<string, number>(); const newTxid = new Map<string, number>();
for (const p of active) { for (const p of active) {
newAddr.set(p.address, p); newAddr.set(p.address, p);
if (p.txid) {
// txid column may contain comma-separated txids
for (const t of p.txid.split(",")) newTxidLookup.set(t, p.id);
} }
// Load all txids for active payments
if (active.length > 0) {
const ids = active.map((p: any) => p.id);
const txs = await sql`SELECT payment_id, txid FROM payment_txs WHERE payment_id = ANY(${ids})`;
for (const tx of txs) newTxid.set(tx.txid, tx.payment_id);
} }
addressMap = newAddr; addressMap = newAddr;
txidLookup = newTxidLookup; txidToPayment = newTxid;
for (const txid of seenTxids) { for (const txid of seenTxids) {
if (!newTxidLookup.has(txid)) seenTxids.delete(txid); if (!newTxid.has(txid)) seenTxids.delete(txid);
} }
} }
@ -108,53 +111,49 @@ async function handleTxEvent(event: any) {
const coin = COINS[payment.coin]; const coin = COINS[payment.coin];
if (!coin) continue; if (!coin) continue;
// Sum output value going to our address in this tx // Sum outputs going to our address
const txValue = outputs const txValue = outputs
.filter((o: any) => o?.script?.address === addr) .filter((o: any) => o?.script?.address === addr)
.reduce((sum: number, o: any) => sum + Number(o.value ?? 0), 0); .reduce((sum: number, o: any) => sum + Number(o.value ?? 0), 0);
const prevReceived = parseFloat(payment.amount_received || "0"); if (txValue <= 0) continue;
const newReceived = prevReceived + txValue;
console.log(`SSE: tx ${txHash} for payment ${payment.id}: +${txValue} ${payment.coin}`);
// Insert into payment_txs (ignore duplicate)
await sql`
INSERT INTO payment_txs (payment_id, txid, amount)
VALUES (${payment.id}, ${txHash}, ${txValue.toFixed(8)})
ON CONFLICT (payment_id, txid) DO NOTHING
`;
txidToPayment.set(txHash, payment.id);
// Recalculate total received
const [{ total }] = await sql`
SELECT COALESCE(SUM(amount::numeric), 0) as total FROM payment_txs WHERE payment_id = ${payment.id}
`;
const totalReceived = Number(total);
const expected = parseFloat(payment.amount_crypto); const expected = parseFloat(payment.amount_crypto);
const threshold = expected * THRESHOLD; const threshold = expected * THRESHOLD;
console.log(`SSE: tx ${txHash} for payment ${payment.id}: +${txValue} ${payment.coin} (total: ${newReceived}/${expected})`); if (coin.confirmations === 0 && totalReceived >= threshold) {
await sql`UPDATE payments SET amount_received = ${totalReceived.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`;
// 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); await applyPlan(payment);
addressMap.delete(addr); addressMap.delete(addr);
console.log(`Payment ${payment.id} paid (0-conf)`); console.log(`Payment ${payment.id} paid (0-conf)`);
} else if (coin.confirmations === 0 && newReceived > 0) { } else if (totalReceived >= threshold) {
// 0-conf, partial: underpaid await sql`UPDATE payments SET amount_received = ${totalReceived.toFixed(8)}, status = 'confirming' WHERE id = ${payment.id}`;
await sql`UPDATE payments SET amount_received = ${newReceived.toFixed(8)}, txid = ${txids}, status = 'underpaid' WHERE id = ${payment.id}`; payment.amount_received = totalReceived.toFixed(8);
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"; payment.status = "confirming";
for (const t of txids.split(",")) txidLookup.set(t, payment.id); console.log(`Payment ${payment.id} confirming: ${totalReceived}/${expected}`);
console.log(`Payment ${payment.id} confirming: ${newReceived}/${expected}`);
} else { } else {
// 1+ conf, partial: underpaid await sql`UPDATE payments SET amount_received = ${totalReceived.toFixed(8)}, status = 'underpaid' WHERE id = ${payment.id}`;
await sql`UPDATE payments SET amount_received = ${newReceived.toFixed(8)}, txid = ${txids}, status = 'underpaid' WHERE id = ${payment.id}`; payment.amount_received = totalReceived.toFixed(8);
payment.amount_received = newReceived.toFixed(8);
payment.txid = txids;
payment.status = "underpaid"; payment.status = "underpaid";
for (const t of txids.split(",")) txidLookup.set(t, payment.id); console.log(`Payment ${payment.id} underpaid: ${totalReceived}/${expected}`);
console.log(`Payment ${payment.id} underpaid: ${newReceived}/${expected}`);
} }
return; // Only process first matching output set per tx return;
} }
} }
@ -164,43 +163,47 @@ async function handleBlockEvent(event: any) {
const blockTxs: string[] = event.data?.tx ?? []; const blockTxs: string[] = event.data?.tx ?? [];
if (blockTxs.length === 0) return; if (blockTxs.length === 0) return;
// Find payments with txids in this block // Find payment txs in this block
const paymentIds = new Set<number>(); const paymentIds = new Set<number>();
for (const txid of blockTxs) { for (const txid of blockTxs) {
const pid = txidLookup.get(txid); const pid = txidToPayment.get(txid);
if (pid != null) paymentIds.add(pid); if (pid != null) paymentIds.add(pid);
} }
if (paymentIds.size === 0) return; if (paymentIds.size === 0) return;
// Mark matched txs as confirmed
const matchedTxids = blockTxs.filter(t => txidToPayment.has(t));
if (matchedTxids.length > 0) {
await sql`UPDATE payment_txs SET confirmed = true WHERE txid = ANY(${matchedTxids})`;
}
for (const pid of paymentIds) { for (const pid of paymentIds) {
// Re-fetch from DB for latest state
const [payment] = await sql` const [payment] = await sql`
SELECT * FROM payments WHERE id = ${pid} AND status IN ('underpaid', 'confirming') SELECT * FROM payments WHERE id = ${pid} AND status IN ('underpaid', 'confirming')
`; `;
if (!payment) continue; if (!payment) continue;
// Check confirmed amount via address API // Check if all txs for this payment are confirmed
let info: any; const [{ unconfirmed }] = await sql`
try { info = await getAddressInfo(payment.address); } catch { continue; } SELECT COUNT(*)::int as unconfirmed FROM payment_txs
if (!info || info.error) continue; WHERE payment_id = ${pid} AND confirmed = false
`;
const receivedConfirmed = Number(info.received_confirmed ?? 0); if (Number(unconfirmed) > 0) continue;
// All confirmed — check total meets threshold
const [{ total }] = await sql`
SELECT COALESCE(SUM(amount::numeric), 0) as total FROM payment_txs WHERE payment_id = ${pid}
`;
const totalReceived = Number(total);
const expected = parseFloat(payment.amount_crypto); const expected = parseFloat(payment.amount_crypto);
const threshold = expected * THRESHOLD;
if (receivedConfirmed >= threshold) { if (totalReceived >= expected * THRESHOLD) {
await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`; await sql`UPDATE payments SET amount_received = ${totalReceived.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${pid} AND status != 'paid'`;
await applyPlan(payment); await applyPlan(payment);
addressMap.delete(payment.address); addressMap.delete(payment.address);
// Clean up txid lookups console.log(`Payment ${pid} paid (all txs confirmed)`);
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}`;
} }
} }
} }
@ -247,22 +250,42 @@ export async function checkPayments() {
const receivedConfirmed = Number(info.received_confirmed ?? 0); const receivedConfirmed = Number(info.received_confirmed ?? 0);
const expected = parseFloat(payment.amount_crypto); const expected = parseFloat(payment.amount_crypto);
const threshold = expected * THRESHOLD; const threshold = expected * THRESHOLD;
const txid = payment.txid || findTxid(info);
// Sync txs from address info into payment_txs
if (info.in?.length) {
for (const tx of info.in) {
if (!tx.txid) continue;
await sql`
INSERT INTO payment_txs (payment_id, txid, amount, confirmed)
VALUES (${payment.id}, ${tx.txid}, ${String(tx.amount ?? 0)}, ${tx.block != null})
ON CONFLICT (payment_id, txid) DO UPDATE SET confirmed = EXCLUDED.confirmed
`;
}
}
if (payment.status === "pending" || payment.status === "underpaid") { if (payment.status === "pending" || payment.status === "underpaid") {
if (coin.confirmations === 0 && received >= threshold) { if (coin.confirmations === 0 && received >= threshold) {
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 sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`;
await applyPlan(payment); await applyPlan(payment);
} else if (coin.confirmations > 0 && receivedConfirmed >= threshold) { } else if (coin.confirmations > 0 && receivedConfirmed >= threshold) {
await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)}, txid = ${txid}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`; // Check all txs confirmed
const [{ unconfirmed }] = await sql`
SELECT COUNT(*)::int as unconfirmed FROM payment_txs WHERE payment_id = ${payment.id} AND confirmed = false
`;
if (Number(unconfirmed) === 0) {
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); await applyPlan(payment);
}
} else if (received >= threshold) { } else if (received >= threshold) {
await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, txid = ${txid}, status = 'confirming' WHERE id = ${payment.id}`; await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, status = 'confirming' WHERE id = ${payment.id}`;
} else if (received > 0) { } else if (received > 0) {
await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, txid = ${txid}, status = 'underpaid' WHERE id = ${payment.id}`; await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, status = 'underpaid' WHERE id = ${payment.id}`;
} }
} else if (payment.status === "confirming") { } else if (payment.status === "confirming") {
if (receivedConfirmed >= threshold) { const [{ unconfirmed }] = await sql`
SELECT COUNT(*)::int as unconfirmed FROM payment_txs WHERE payment_id = ${payment.id} AND confirmed = false
`;
if (receivedConfirmed >= threshold && Number(unconfirmed) === 0) {
await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`; 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); await applyPlan(payment);
} }
@ -279,11 +302,6 @@ export function watchPayment(payment: any) {
addressMap.set(payment.address, payment); addressMap.set(payment.address, payment);
} }
function findTxid(info: any): string | null {
if (info.in?.length) return info.in.map((i: any) => i.txid).filter(Boolean).join(",") || null;
return null;
}
async function applyPlan(payment: any) { async function applyPlan(payment: any) {
if (payment.plan === "lifetime") { 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}`;

View File

@ -120,6 +120,7 @@ export const routes = new Elysia()
address: payment.address, address: payment.address,
status: payment.status, status: payment.status,
expires_at: payment.expires_at, expires_at: payment.expires_at,
txs: [],
qr_url: getQrUrl(uri), qr_url: getQrUrl(uri),
coin_label: coinInfo.label, coin_label: coinInfo.label,
coin_ticker: coinInfo.ticker, coin_ticker: coinInfo.ticker,
@ -144,10 +145,16 @@ export const routes = new Elysia()
const amountReceived = parseFloat(payment.amount_received || "0"); const amountReceived = parseFloat(payment.amount_received || "0");
const amountRemaining = Math.max(0, amountCrypto - amountReceived); const amountRemaining = Math.max(0, amountCrypto - amountReceived);
// QR shows remaining amount (or full amount if nothing received yet) const qrAmount = amountRemaining > 0 && amountRemaining < amountCrypto
const qrAmount = amountRemaining > 0 ? amountRemaining.toFixed(8) : payment.amount_crypto; ? amountRemaining.toFixed(8) : payment.amount_crypto;
const uri = `${coinInfo.uri}:${payment.address}?amount=${qrAmount}`; const uri = `${coinInfo.uri}:${payment.address}?amount=${qrAmount}`;
const txs = await sql`
SELECT txid, amount, confirmed, detected_at
FROM payment_txs WHERE payment_id = ${payment.id}
ORDER BY detected_at ASC
`;
return { return {
id: payment.id, id: payment.id,
plan: payment.plan, plan: payment.plan,
@ -162,7 +169,7 @@ export const routes = new Elysia()
created_at: payment.created_at, created_at: payment.created_at,
expires_at: payment.expires_at, expires_at: payment.expires_at,
paid_at: payment.paid_at, paid_at: payment.paid_at,
txid: payment.txid, txs: txs.map((t: any) => ({ txid: t.txid, amount: t.amount, confirmed: t.confirmed, detected_at: t.detected_at })),
qr_url: getQrUrl(uri), qr_url: getQrUrl(uri),
coin_label: coinInfo?.label, coin_label: coinInfo?.label,
coin_ticker: coinInfo?.ticker, coin_ticker: coinInfo?.ticker,

View File

@ -281,27 +281,26 @@
} }
} }
function syncTxids(data) {
if (!data?.txs) return;
for (const tx of data.txs) {
if (tx.txid && !watchedTxids.includes(tx.txid)) watchedTxids.push(tx.txid);
}
}
function applyStatus(status, data) { function applyStatus(status, data) {
if (status === 'underpaid') { if (status === 'underpaid') {
document.getElementById('pay-status').innerHTML = ` document.getElementById('pay-status').innerHTML = `
<span class="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span> <span class="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span>
<span class="text-yellow-400">Underpaid — please send the remaining amount</span> <span class="text-yellow-400">Underpaid — please send the remaining amount</span>
`; `;
if (data?.txid) { syncTxids(data);
for (const t of data.txid.split(',')) {
if (!watchedTxids.includes(t)) watchedTxids.push(t);
}
}
} else if (status === 'confirming') { } else if (status === 'confirming') {
document.getElementById('pay-status').innerHTML = ` document.getElementById('pay-status').innerHTML = `
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span> <span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
<span class="text-blue-400">Transaction detected, waiting for confirmation...</span> <span class="text-blue-400">Transaction detected, waiting for confirmation...</span>
`; `;
if (data?.txid) { syncTxids(data);
for (const t of data.txid.split(',')) {
if (!watchedTxids.includes(t)) watchedTxids.push(t);
}
}
} else if (status === 'paid') { } else if (status === 'paid') {
clearInterval(pollInterval); clearInterval(pollInterval);
clearInterval(countdownInterval); clearInterval(countdownInterval);