diff --git a/apps/pay/src/address.ts b/apps/pay/src/address.ts index 3ba77a8..5513b35 100644 --- a/apps/pay/src/address.ts +++ b/apps/pay/src/address.ts @@ -1,8 +1,7 @@ /// HD address derivation using bitcore-lib family. -/// Each coin uses its own xpub from env vars. /// Derives child addresses at m/0/{index} (external receive chain). -// @ts-ignore — bitcore libs don't have perfect types +// @ts-ignore import bitcore from "bitcore-lib"; import bs58check from "bs58check"; // @ts-ignore @@ -14,18 +13,6 @@ import bitcoreDoge from "bitcore-lib-doge"; // @ts-ignore import dashcore from "@dashevo/dashcore-lib"; -interface CoinLib { - HDPublicKey: any; -} - -const LIBS: Record = { - btc: bitcore, - bch: bitcoreCash, - ltc: bitcoreLtc, - doge: bitcoreDoge, - dash: dashcore, -}; - function getXpub(coin: string): string { const key = `XPUB_${coin.toUpperCase()}`; const val = process.env[key]; @@ -33,40 +20,7 @@ function getXpub(coin: string): string { return val; } -/** - * Derive a receive address for the given coin at the given index. - * Uses the standard BIP44 external chain: m/0/{index} - */ -export function deriveAddress(coin: string, index: number): string { - if (coin === "xec") { - // XEC uses the same address format as BCH but with ecash: prefix - // Derive using bitcore-lib-cash, then convert prefix - const xpub = getXpub("xec"); - const hdPub = new bitcoreCash.HDPublicKey(xpub); - const child = hdPub.deriveChild(0).deriveChild(index); - const addr = new bitcoreCash.Address(child.publicKey, bitcoreCash.Networks.mainnet); - // bitcore-lib-cash gives "bitcoincash:q..." — replace prefix with "ecash:" - const cashAddr = addr.toCashAddress(); - return cashAddr.replace(/^bitcoincash:/, "ecash:"); - } - - const lib = LIBS[coin]; - if (!lib) throw new Error(`Unsupported coin: ${coin}`); - - const xpub = getXpub(coin); - const hdPub = new lib.HDPublicKey(xpub); - const child = hdPub.deriveChild(0).deriveChild(index); - const addr = new lib.HDPublicKey.prototype.constructor.Address - ? new (lib as any).Address(child.publicKey) - : child.publicKey.toAddress(); - - return addr.toString(); -} - -/** - * Simpler approach — derive using each lib's built-in methods. - */ -export function deriveAddressSafe(coin: string, index: number): string { +export function derive(coin: string, index: number): string { const xpub = getXpub(coin === "xec" ? "xec" : coin); if (coin === "btc") { @@ -83,15 +37,12 @@ export function deriveAddressSafe(coin: string, index: number): string { return addr.replace(/^bitcoincash:/, "ecash:"); } if (coin === "ltc") { - // All zpub/Ltub/Mtub/xpub variants share the same key data — only version bytes differ. - // Remap to the standard xpub version (0x0488b21e) so bitcore-lib-ltc can parse it, - // then derive a native segwit P2WPKH (bech32 ltc1q...) address. + // Remap zpub/Ltub/Mtub to standard xpub version bytes for bitcore-lib-ltc const decoded = Buffer.from(bs58check.decode(xpub)); decoded[0] = 0x04; decoded[1] = 0x88; decoded[2] = 0xb2; decoded[3] = 0x1e; const normalized = bs58check.encode(decoded); const hd = new bitcoreLtc.HDPublicKey(normalized); const pubkey = hd.deriveChild(0).deriveChild(index).publicKey; - // Derive as P2WPKH (native segwit, bech32 ltc1q...) return new bitcoreLtc.Address(pubkey, bitcoreLtc.Networks.mainnet, bitcoreLtc.Address.PayToWitnessPublicKeyHash).toString(); } if (coin === "doge") { @@ -105,5 +56,3 @@ export function deriveAddressSafe(coin: string, index: number): string { throw new Error(`Unsupported coin: ${coin}`); } - -export { deriveAddressSafe as derive }; diff --git a/apps/pay/src/freedom.ts b/apps/pay/src/freedom.ts index dfe984f..80ac72f 100644 --- a/apps/pay/src/freedom.ts +++ b/apps/pay/src/freedom.ts @@ -15,8 +15,6 @@ export async function getAddressInfo(address: string): Promise { return res.json(); } -/** Bulk address lookup — POST /address with { terms: [...] } - * Normalizes response to { address: info } map regardless of API format. */ export async function getAddressInfoBulk(addresses: string[]): Promise> { if (addresses.length === 0) return {}; const res = await fetch(`${API}/address`, { @@ -24,39 +22,13 @@ export async function getAddressInfoBulk(addresses: string[]): Promise = {}; - for (const item of data) { - if (item?.address) map[item.address] = item; - } - return map; - } - - // Check if it's keyed by address — verify first value has address-like fields - const firstKey = Object.keys(data)[0]; - if (firstKey && data[firstKey]?.address) return data; - - // If keyed by index (0, 1, 2...), map back to addresses - if (firstKey === "0" || firstKey === "1") { - const map: Record = {}; - for (let i = 0; i < addresses.length; i++) { - if (data[i]) map[addresses[i]] = data[i]; - } - return map; - } - - return data; + return res.json(); } export function getQrUrl(text: string): string { return `${API}/invoice/qr/${encodeURIComponent(text)}`; } -/** Returns coin keys where the chain is online (no null uptime field). */ export async function getAvailableCoins(): Promise { const info = await getChainInfo(); return Object.entries(info) diff --git a/apps/pay/src/monitor.ts b/apps/pay/src/monitor.ts index 5e96310..b954c3e 100644 --- a/apps/pay/src/monitor.ts +++ b/apps/pay/src/monitor.ts @@ -1,6 +1,5 @@ -/// Payment monitor: raw SSE stream for instant tx/block detection, -/// with bulk polling as fallback. -/// States: pending → underpaid → confirming → paid (or expired) +/// Payment monitor: raw SSE + polling fallback. +/// States: pending → underpaid → confirming → paid | expired import sql from "./db"; import { getAddressInfo, getAddressInfoBulk } from "./freedom"; import { COINS } from "./plans"; @@ -8,7 +7,7 @@ import { COINS } from "./plans"; const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st"; const THRESHOLD = 0.995; -// ── In-memory maps ────────────────────────────────────────────────── +// ── In-memory lookups for SSE matching ────────────────────────────── let addressMap = new Map(); // address → payment let txidToPayment = new Map(); // txid → payment.id const seenTxids = new Set(); @@ -19,15 +18,11 @@ async function refreshMaps() { WHERE status IN ('pending', 'underpaid', 'confirming') AND expires_at >= now() `; - const newAddr = new Map(); const newTxid = new Map(); - for (const p of active) { - newAddr.set(p.address, p); - } + for (const p of active) newAddr.set(p.address, p); - // 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})`; @@ -36,13 +31,104 @@ async function refreshMaps() { addressMap = newAddr; txidToPayment = newTxid; + for (const t of seenTxids) { if (!newTxid.has(t)) seenTxids.delete(t); } +} - for (const txid of seenTxids) { - if (!newTxid.has(txid)) seenTxids.delete(txid); +// ── Core logic: one place for all state transitions ───────────────── + +async function recordTx(paymentId: number, txid: string, amount: number, confirmed: boolean) { + const [ins] = await sql` + INSERT INTO payment_txs (payment_id, txid, amount, confirmed) + VALUES (${paymentId}, ${txid}, ${amount.toFixed(8)}, ${confirmed}) + ON CONFLICT (payment_id, txid) DO UPDATE SET confirmed = EXCLUDED.confirmed OR payment_txs.confirmed + RETURNING (xmax = 0) as is_new + `; + // Extend expiry to 24h on new tx + if (ins?.is_new) { + const exp = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + await sql`UPDATE payments SET expires_at = ${exp} WHERE id = ${paymentId} AND expires_at < ${exp}`; } } -// ── Single raw SSE connection ─────────────────────────────────────── +async function evaluatePayment(paymentId: number) { + const [payment] = await sql` + SELECT * FROM payments WHERE id = ${paymentId} AND status IN ('pending', 'underpaid', 'confirming') + `; + if (!payment) return; + + const coin = COINS[payment.coin]; + if (!coin) return; + + const [{ total, unconfirmed }] = await sql` + SELECT COALESCE(SUM(amount::numeric), 0) as total, + COUNT(*) FILTER (WHERE NOT confirmed)::int as unconfirmed + FROM payment_txs WHERE payment_id = ${paymentId} + `; + + const received = Number(total); + const expected = parseFloat(payment.amount_crypto); + const threshold = expected * THRESHOLD; + + let newStatus = payment.status; + if (received >= threshold && (coin.confirmations === 0 || Number(unconfirmed) === 0)) { + newStatus = "paid"; + } else if (received >= threshold) { + newStatus = "confirming"; + } else if (received > 0) { + newStatus = "underpaid"; + } + + if (newStatus === payment.status && received <= parseFloat(payment.amount_received || "0")) return; + + if (newStatus === "paid") { + await sql`UPDATE payments SET status = 'paid', amount_received = ${received.toFixed(8)}, paid_at = now() WHERE id = ${paymentId} AND status != 'paid'`; + await applyPlan(payment); + addressMap.delete(payment.address); + console.log(`Payment ${paymentId} paid`); + } else { + await sql`UPDATE payments SET status = ${newStatus}, amount_received = ${received.toFixed(8)} WHERE id = ${paymentId}`; + } +} + +// ── SSE ───────────────────────────────────────────────────────────── + +async function handleTxEvent(event: any) { + const txHash = event.data?.tx?.hash; + if (!txHash || seenTxids.has(txHash)) return; + seenTxids.add(txHash); + + const outputs = event.data?.out ?? []; + for (const out of outputs) { + const addr = out?.script?.address; + const payment = addr && addressMap.get(addr); + if (!payment) continue; + + const txValue = outputs + .filter((o: any) => o?.script?.address === addr) + .reduce((s: number, o: any) => s + Number(o.value ?? 0), 0); + if (txValue <= 0) continue; + + console.log(`SSE: tx ${txHash} for payment ${payment.id}: +${txValue} ${payment.coin}`); + await recordTx(payment.id, txHash, txValue, false); + txidToPayment.set(txHash, payment.id); + await evaluatePayment(payment.id); + return; + } +} + +async function handleBlockEvent(event: any) { + const blockTxs: string[] = event.data?.tx ?? []; + const matched = blockTxs.filter(t => txidToPayment.has(t)); + if (matched.length === 0) return; + + await sql`UPDATE payment_txs SET confirmed = true WHERE txid = ANY(${matched})`; + + const pids = new Set(matched.map(t => txidToPayment.get(t)!)); + for (const pid of pids) { + console.log(`SSE: block confirmed tx for payment ${pid}`); + await evaluatePayment(pid); + } +} function startSSE() { connectSSE(`${SOCK_API}/sse`); @@ -52,35 +138,26 @@ async function connectSSE(url: string) { while (true) { try { const res = await fetch(url); - if (!res.ok || !res.body) { - console.error(`SSE: HTTP ${res.status}`); - await sleep(5000); - continue; - } + if (!res.ok || !res.body) { await sleep(5000); continue; } + console.log("SSE: connected"); - console.log("SSE: connected (raw, all chains)"); const reader = res.body.getReader(); const decoder = new TextDecoder(); - let buffer = ""; + let buf = ""; while (true) { const { done, value } = await reader.read(); if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split("\n"); + buf = lines.pop() ?? ""; for (const line of lines) { if (!line.startsWith("data:")) continue; try { - const json = line.startsWith("data: ") ? line.slice(6) : line.slice(5); - const event = JSON.parse(json); - if (event.type === "block") { - handleBlockEvent(event).catch(() => {}); - } else if (event.type === "tx") { - handleTxEvent(event).catch(() => {}); - } + const event = JSON.parse(line.slice(line.indexOf("{"))); + if (event.type === "block") handleBlockEvent(event).catch(() => {}); + else if (event.type === "tx") handleTxEvent(event).catch(() => {}); } catch {} } } @@ -92,226 +169,42 @@ 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; - if (!txHash) return; - if (seenTxids.has(txHash)) return; - seenTxids.add(txHash); - - for (const out of outputs) { - const addr = out?.script?.address; - if (!addr) continue; - - const payment = addressMap.get(addr); - if (!payment) continue; - - const coin = COINS[payment.coin]; - if (!coin) continue; - - // 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); - - if (txValue <= 0) continue; - - console.log(`SSE: tx ${txHash} for payment ${payment.id}: +${txValue} ${payment.coin}`); - - // Insert into payment_txs (ignore duplicate) - const [inserted] = await sql` - INSERT INTO payment_txs (payment_id, txid, amount) - VALUES (${payment.id}, ${txHash}, ${txValue.toFixed(8)}) - ON CONFLICT (payment_id, txid) DO NOTHING - RETURNING id - `; - txidToPayment.set(txHash, payment.id); - - // Extend expiry to 24h on first tx detection - if (inserted) { - const newExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); - await sql`UPDATE payments SET expires_at = ${newExpiry.toISOString()} WHERE id = ${payment.id} AND expires_at < ${newExpiry.toISOString()}`; - } - - // 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; - - 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 (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"; - console.log(`Payment ${payment.id} confirming: ${totalReceived}/${expected}`); - } else { - 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"; - console.log(`Payment ${payment.id} underpaid: ${totalReceived}/${expected}`); - } - - return; - } -} - -// ── SSE block handler ─────────────────────────────────────────────── - -async function handleBlockEvent(event: any) { - const blockTxs: string[] = event.data?.tx ?? []; - if (blockTxs.length === 0) return; - - // Find payment txs in this block - const paymentIds = new Set(); - for (const txid of blockTxs) { - 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) { - const [payment] = await sql` - SELECT * FROM payments WHERE id = ${pid} AND status IN ('underpaid', 'confirming') - `; - if (!payment) 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 - `; - - 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); - - 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); - console.log(`Payment ${pid} paid (all txs confirmed)`); - } - } -} - -// ── Bulk polling fallback ─────────────────────────────────────────── +// ── Polling fallback ──────────────────────────────────────────────── export async function checkPayments() { await sql` UPDATE payments SET status = 'expired' - WHERE status IN ('pending', 'underpaid', 'confirming') - AND expires_at < now() + WHERE status IN ('pending', 'underpaid', 'confirming') AND expires_at < now() `; - await refreshMaps(); - const allPayments = await sql` - SELECT * FROM payments - WHERE status IN ('pending', 'underpaid', 'confirming') - AND expires_at >= now() + const payments = await sql` + SELECT * FROM payments WHERE status IN ('pending', 'underpaid', 'confirming') AND expires_at >= now() `; + if (payments.length === 0) return; - if (allPayments.length === 0) return; - - const addresses = allPayments.map((p: any) => p.address); let bulk: Record = {}; - try { - bulk = await getAddressInfoBulk(addresses); - } catch (e) { - console.error("Bulk address lookup failed, falling back to individual:", e); - } + try { bulk = await getAddressInfoBulk(payments.map((p: any) => p.address)); } catch {} - for (const payment of allPayments) { + for (const payment of payments) { try { let info = bulk[payment.address]; - if (!info) { - try { info = await getAddressInfo(payment.address); } catch { continue; } - } + if (!info) try { info = await getAddressInfo(payment.address); } catch { continue; } if (!info || info.error) continue; - const coin = COINS[payment.coin]; - if (!coin) continue; - - const received = Number(info.received ?? 0); - const receivedConfirmed = Number(info.received_confirmed ?? 0); - const expected = parseFloat(payment.amount_crypto); - const threshold = expected * THRESHOLD; - - // Sync txs from address info into payment_txs - let newTxDetected = false; - if (info.in?.length) { - for (const tx of info.in) { - if (!tx.txid) continue; - const [ins] = 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 - RETURNING id - `; - if (ins) newTxDetected = true; - } - } - // Extend expiry to 24h on first tx detection - if (newTxDetected) { - const newExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); - await sql`UPDATE payments SET expires_at = ${newExpiry.toISOString()} WHERE id = ${payment.id} AND expires_at < ${newExpiry.toISOString()}`; - } - - if (payment.status === "pending" || payment.status === "underpaid") { - if (coin.confirmations === 0 && received >= threshold) { - 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) { - // 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)}, status = 'confirming' WHERE id = ${payment.id}`; - } else if (received > 0) { - await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, status = 'underpaid' WHERE id = ${payment.id}`; - } - } else if (payment.status === "confirming") { - 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); - } + // Sync txs from address API + for (const tx of info.in ?? []) { + if (!tx.txid) continue; + await recordTx(payment.id, tx.txid, Number(tx.amount ?? 0), tx.block != null); } + await evaluatePayment(payment.id); } catch (e) { console.error(`Error checking payment ${payment.id}:`, e); } } } -// ── Helpers ─────────────────────────────────────────────────────────── +// ── Helpers ────────────────────────────────────────────────────────── export function watchPayment(payment: any) { addressMap.set(payment.address, payment); @@ -321,10 +214,10 @@ 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}`; } else { - const [account] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${payment.account_id}`; + const [acc] = 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 expiry = acc.plan_expires_at ? new Date(acc.plan_expires_at) : null; + const base = (acc.plan === "pro" && expiry && expiry > now) ? expiry : 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}`; @@ -335,17 +228,11 @@ async function applyPlan(payment: any) { export async function expireProPlans() { const result = await sql` UPDATE accounts SET plan = 'free', plan_expires_at = NULL - WHERE plan = 'pro' - AND plan_expires_at IS NOT NULL - AND plan_expires_at < now() + WHERE plan = 'pro' AND plan_expires_at IS NOT NULL AND plan_expires_at < now() `; - if (result.count > 0) { - console.log(`Downgraded ${result.count} expired pro accounts to free`); - } + if (result.count > 0) console.log(`Downgraded ${result.count} expired pro accounts`); } -function sleep(ms: number) { - return new Promise(r => setTimeout(r, ms)); -} +function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); } startSSE(); diff --git a/apps/web/src/views/checkout.ejs b/apps/web/src/views/checkout.ejs index 83b9b78..4980ef8 100644 --- a/apps/web/src/views/checkout.ejs +++ b/apps/web/src/views/checkout.ejs @@ -404,6 +404,14 @@ const serverReceived = parseFloat(data.amount_received || '0'); if (serverReceived > localReceived) localReceived = serverReceived; + // Update expiry countdown if server extended it + if (data.expires_at) { + const newExpiry = new Date(data.expires_at).getTime(); + clearInterval(countdownInterval); + updateCountdown(newExpiry); + countdownInterval = setInterval(() => updateCountdown(newExpiry), 1000); + } + updateAmountDisplay({ ...data, amount_received: localReceived.toFixed(8) }); applyStatus(data.status, data); } catch {} diff --git a/apps/web/src/views/landing.ejs b/apps/web/src/views/landing.ejs index fcc4a27..0a6fb7d 100644 --- a/apps/web/src/views/landing.ejs +++ b/apps/web/src/views/landing.ejs @@ -503,9 +503,6 @@ No credit card - - Get Started - @@ -531,9 +528,6 @@ Priority support - - Upgrade to Pro - @@ -562,11 +556,15 @@ Limited availability - - Get Lifetime - + +