diff --git a/apps/pay/src/db.ts b/apps/pay/src/db.ts index 5a1b425..879fcd9 100644 --- a/apps/pay/src/db.ts +++ b/apps/pay/src/db.ts @@ -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` + 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_account ON payments(account_id)`; diff --git a/apps/pay/src/monitor.ts b/apps/pay/src/monitor.ts index 81d0d73..695573a 100644 --- a/apps/pay/src/monitor.ts +++ b/apps/pay/src/monitor.ts @@ -6,11 +6,11 @@ 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 +const THRESHOLD = 0.995; // ── In-memory maps ────────────────────────────────────────────────── -let addressMap = new Map(); -let txidLookup = new Map(); // txid → payment.id +let addressMap = new Map(); // address → payment +let txidToPayment = new Map(); // txid → payment.id const seenTxids = new Set(); async function refreshMaps() { @@ -21,21 +21,24 @@ async function refreshMaps() { `; const newAddr = new Map(); - const newTxidLookup = new Map(); + const newTxid = new Map(); for (const p of active) { 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; - txidLookup = newTxidLookup; + txidToPayment = newTxid; 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]; if (!coin) continue; - // Sum output value going to our address in this tx + // Sum outputs going to our address const txValue = outputs .filter((o: any) => o?.script?.address === addr) .reduce((sum: number, o: any) => sum + Number(o.value ?? 0), 0); - const prevReceived = parseFloat(payment.amount_received || "0"); - const newReceived = prevReceived + txValue; + if (txValue <= 0) continue; + + 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 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'`; + 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'`; 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; + } else if (totalReceived >= threshold) { + await sql`UPDATE payments SET amount_received = ${totalReceived.toFixed(8)}, status = 'confirming' WHERE id = ${payment.id}`; + payment.amount_received = totalReceived.toFixed(8); payment.status = "confirming"; - for (const t of txids.split(",")) txidLookup.set(t, payment.id); - console.log(`Payment ${payment.id} confirming: ${newReceived}/${expected}`); + console.log(`Payment ${payment.id} confirming: ${totalReceived}/${expected}`); } else { - // 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; + await sql`UPDATE payments SET amount_received = ${totalReceived.toFixed(8)}, status = 'underpaid' WHERE id = ${payment.id}`; + payment.amount_received = totalReceived.toFixed(8); payment.status = "underpaid"; - for (const t of txids.split(",")) txidLookup.set(t, payment.id); - console.log(`Payment ${payment.id} underpaid: ${newReceived}/${expected}`); + console.log(`Payment ${payment.id} underpaid: ${totalReceived}/${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 ?? []; if (blockTxs.length === 0) return; - // Find payments with txids in this block + // Find payment txs in this block const paymentIds = new Set(); for (const txid of blockTxs) { - const pid = txidLookup.get(txid); + const pid = txidToPayment.get(txid); if (pid != null) paymentIds.add(pid); } 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) { - // 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; - // Check confirmed amount via address API - let info: any; - try { info = await getAddressInfo(payment.address); } catch { continue; } - if (!info || info.error) continue; + // Check if all txs for this payment are confirmed + const [{ unconfirmed }] = await sql` + SELECT COUNT(*)::int as unconfirmed FROM payment_txs + 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 threshold = expected * THRESHOLD; - if (receivedConfirmed >= threshold) { - await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`; + if (totalReceived >= expected * THRESHOLD) { + await sql`UPDATE payments SET amount_received = ${totalReceived.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${pid} 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}`; + console.log(`Payment ${pid} paid (all txs confirmed)`); } } } @@ -247,22 +250,42 @@ export async function checkPayments() { const receivedConfirmed = Number(info.received_confirmed ?? 0); const expected = parseFloat(payment.amount_crypto); 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 (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); } 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'`; - await applyPlan(payment); + // 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); + } } 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) { - 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") { - 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 applyPlan(payment); } @@ -279,11 +302,6 @@ export function watchPayment(payment: any) { 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) { if (payment.plan === "lifetime") { await sql`UPDATE accounts SET plan = 'lifetime', plan_expires_at = NULL WHERE id = ${payment.account_id}`; diff --git a/apps/pay/src/routes.ts b/apps/pay/src/routes.ts index 10e407f..952ccf3 100644 --- a/apps/pay/src/routes.ts +++ b/apps/pay/src/routes.ts @@ -120,6 +120,7 @@ export const routes = new Elysia() address: payment.address, status: payment.status, expires_at: payment.expires_at, + txs: [], qr_url: getQrUrl(uri), coin_label: coinInfo.label, coin_ticker: coinInfo.ticker, @@ -144,10 +145,16 @@ export const routes = new Elysia() 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 qrAmount = amountRemaining > 0 && amountRemaining < amountCrypto + ? amountRemaining.toFixed(8) : payment.amount_crypto; 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 { id: payment.id, plan: payment.plan, @@ -162,7 +169,7 @@ export const routes = new Elysia() created_at: payment.created_at, expires_at: payment.expires_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), coin_label: coinInfo?.label, coin_ticker: coinInfo?.ticker, diff --git a/apps/web/src/views/checkout.ejs b/apps/web/src/views/checkout.ejs index ed846e1..f5877bc 100644 --- a/apps/web/src/views/checkout.ejs +++ b/apps/web/src/views/checkout.ejs @@ -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) { if (status === 'underpaid') { document.getElementById('pay-status').innerHTML = ` Underpaid — please send the remaining amount `; - if (data?.txid) { - for (const t of data.txid.split(',')) { - if (!watchedTxids.includes(t)) watchedTxids.push(t); - } - } + syncTxids(data); } else if (status === 'confirming') { document.getElementById('pay-status').innerHTML = ` Transaction detected, waiting for confirmation... `; - if (data?.txid) { - for (const t of data.txid.split(',')) { - if (!watchedTxids.includes(t)) watchedTxids.push(t); - } - } + syncTxids(data); } else if (status === 'paid') { clearInterval(pollInterval); clearInterval(countdownInterval);