feat: crypto payment system with HD wallets, Freedom.st integration, and checkout UI

This commit is contained in:
2026-03-18 23:04:17 +04:00
parent bead49b870
commit c9130243e8
18 changed files with 943 additions and 35 deletions
+100
View File
@@ -0,0 +1,100 @@
/// 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
import bitcore from "bitcore-lib";
// @ts-ignore
import bitcoreCash from "bitcore-lib-cash";
// @ts-ignore
import bitcoreLtc from "bitcore-lib-ltc";
// @ts-ignore
import bitcoreDoge from "bitcore-lib-doge";
// @ts-ignore
import dashcore from "@dashevo/dashcore-lib";
interface CoinLib {
HDPublicKey: any;
}
const LIBS: Record<string, CoinLib> = {
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];
if (!val) throw new Error(`Missing env var: ${key}`);
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 {
const xpub = getXpub(coin === "xec" ? "xec" : coin);
if (coin === "btc") {
const hd = new bitcore.HDPublicKey(xpub);
return hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toString();
}
if (coin === "bch") {
const hd = new bitcoreCash.HDPublicKey(xpub);
return hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toCashAddress();
}
if (coin === "xec") {
const hd = new bitcoreCash.HDPublicKey(xpub);
const addr = hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toCashAddress();
return addr.replace(/^bitcoincash:/, "ecash:");
}
if (coin === "ltc") {
const hd = new bitcoreLtc.HDPublicKey(xpub);
return hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toString();
}
if (coin === "doge") {
const hd = new bitcoreDoge.HDPublicKey(xpub);
return hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toString();
}
if (coin === "dash") {
const hd = new dashcore.HDPublicKey(xpub);
return hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toString();
}
throw new Error(`Unsupported coin: ${coin}`);
}
export { deriveAddressSafe as derive };
+38
View File
@@ -0,0 +1,38 @@
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", {
max: 10,
idle_timeout: 30,
connect_timeout: 10,
});
export default sql;
export async function migrate() {
// Plan expiry on accounts (may already exist from API/web migrations)
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_expires_at TIMESTAMPTZ`;
await sql`
CREATE TABLE IF NOT EXISTS payments (
id BIGSERIAL PRIMARY KEY,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
plan TEXT NOT NULL,
months INTEGER,
amount_usd NUMERIC(10,2) NOT NULL,
coin TEXT NOT NULL,
amount_crypto TEXT NOT NULL,
address TEXT NOT NULL,
derivation_index INTEGER NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT now(),
paid_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
txid TEXT
)
`;
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)`;
console.log("Pay DB ready");
}
+28
View File
@@ -0,0 +1,28 @@
const API = process.env.FREEDOM_API ?? "https://api-v1.freedom.st";
export async function getChainInfo(): Promise<Record<string, any>> {
const res = await fetch(`${API}/rpc/info`);
return res.json();
}
export async function getExchangeRates(): Promise<Record<string, number>> {
const res = await fetch(`${API}/invoice/rates`);
return res.json();
}
export async function getAddressInfo(address: string): Promise<any> {
const res = await fetch(`${API}/address/${address}`);
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<string[]> {
const info = await getChainInfo();
return Object.entries(info)
.filter(([_, v]) => v && v.uptime != null)
.map(([k]) => k);
}
+58
View File
@@ -0,0 +1,58 @@
import { Elysia } from "elysia";
import { migrate } from "./db";
import { routes } from "./routes";
import { checkPayments, expireProPlans } from "./monitor";
await migrate();
const SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
"Referrer-Policy": "strict-origin-when-cross-origin",
};
const CORS_ORIGIN = process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com"];
const app = new Elysia()
.onAfterHandle(({ set }) => {
Object.assign(set.headers, SECURITY_HEADERS);
})
// CORS for web app
.onRequest(({ request, set }) => {
const origin = request.headers.get("origin") ?? "";
if (CORS_ORIGIN.includes(origin)) {
set.headers["access-control-allow-origin"] = origin;
set.headers["access-control-allow-credentials"] = "true";
set.headers["access-control-allow-methods"] = "GET, POST, OPTIONS";
set.headers["access-control-allow-headers"] = "Content-Type, Authorization";
}
})
.options("/*", ({ request }) => {
const origin = request.headers.get("origin") ?? "";
const allowed = CORS_ORIGIN.includes(origin) ? origin : CORS_ORIGIN[0];
return new Response(null, {
status: 204,
headers: {
"access-control-allow-origin": allowed,
"access-control-allow-credentials": "true",
"access-control-allow-methods": "GET, POST, OPTIONS",
"access-control-allow-headers": "Content-Type, Authorization",
},
});
})
.get("/", () => ({ name: "PingQL Pay", version: "1" }))
.use(routes)
.listen(3002);
console.log(`PingQL Pay running at http://localhost:${app.server?.port}`);
// Check pending payments every 30 seconds
setInterval(() => {
checkPayments().catch((err) => console.error("Payment check failed:", err));
}, 30_000);
// Expire pro plans every hour
setInterval(() => {
expireProPlans().catch((err) => console.error("Plan expiry check failed:", err));
}, 60 * 60_000);
+134
View File
@@ -0,0 +1,134 @@
/// Background job: poll pending payments and activate plans on confirmation.
import sql from "./db";
import { getAddressInfo } from "./freedom";
import { COINS } from "./plans";
/** Check all pending/confirming payments against the blockchain. */
export async function checkPayments() {
// Expire stale payments
await sql`
UPDATE payments SET status = 'expired'
WHERE status IN ('pending', 'confirming')
AND expires_at < now()
`;
const pending = await sql`
SELECT * FROM payments
WHERE status IN ('pending', 'confirming')
AND expires_at >= now()
`;
for (const payment of pending) {
try {
await checkPayment(payment);
} catch (e) {
console.error(`Error checking payment ${payment.id}:`, e);
}
}
}
async function checkPayment(payment: any) {
const info = await getAddressInfo(payment.address);
if (info.error) return;
const coin = COINS[payment.coin];
if (!coin) return;
const expectedSats = cryptoToSats(payment.coin, payment.amount_crypto);
// Sum confirmed received
const confirmedReceived = sumReceived(info, true);
// Sum all received (including unconfirmed)
const totalReceived = sumReceived(info, false);
// Allow 0.5% tolerance for rounding
const threshold = expectedSats * 0.995;
if (coin.confirmations === 0) {
// 0-conf coins (BCH, XEC): accept as soon as seen in mempool
if (totalReceived >= threshold) {
const txid = findTxid(info);
await activatePayment(payment, txid);
}
} else {
// 1-conf coins: need confirmed balance
if (confirmedReceived >= threshold) {
const txid = findTxid(info);
await activatePayment(payment, txid);
} else if (totalReceived >= threshold && payment.status === "pending") {
// Seen in mempool but not yet confirmed
await sql`UPDATE payments SET status = 'confirming' WHERE id = ${payment.id}`;
}
}
}
function sumReceived(info: any, confirmedOnly: boolean): number {
// Freedom.st /address response has balance fields
// The response includes received/balance in the coin's base unit (satoshis)
if (confirmedOnly) {
return Number(info.balance?.confirmed ?? info.confirmed ?? info.received ?? 0);
}
// Total = confirmed + unconfirmed
const confirmed = Number(info.balance?.confirmed ?? info.confirmed ?? info.received ?? 0);
const unconfirmed = Number(info.balance?.unconfirmed ?? info.unconfirmed ?? 0);
return confirmed + unconfirmed;
}
function findTxid(info: any): string | null {
// Try to get the first txid from transaction history
if (info.txs?.length) return info.txs[0].txid || info.txs[0].hash || null;
if (info.transactions?.length) return info.transactions[0].txid || info.transactions[0] || null;
return null;
}
/** Convert a decimal crypto amount string to satoshis/base units. */
function cryptoToSats(coin: string, amount: string): number {
// XEC uses 100 sats per coin, everything else uses 1e8
const multiplier = coin === "xec" ? 100 : 1e8;
return Math.round(parseFloat(amount) * multiplier);
}
async function activatePayment(payment: any, txid: string | null) {
await sql`
UPDATE payments
SET status = 'paid', paid_at = now(), txid = ${txid}
WHERE id = ${payment.id} AND status != 'paid'
`;
if (payment.plan === "lifetime") {
await sql`
UPDATE accounts SET plan = 'lifetime', plan_expires_at = NULL
WHERE id = ${payment.account_id}
`;
} else {
// Pro: extend from current expiry or now
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}
`;
}
console.log(`Payment ${payment.id} activated: ${payment.plan} for account ${payment.account_id}`);
}
/** Downgrade expired pro accounts back to free. */
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()
`;
if (result.count > 0) {
console.log(`Downgraded ${result.count} expired pro accounts to free`);
}
}
+19
View File
@@ -0,0 +1,19 @@
export const PLANS = {
pro: {
label: "Pro",
monthlyUsd: 14,
},
lifetime: {
label: "Lifetime",
priceUsd: 149,
},
} as const;
export const COINS: Record<string, { label: string; ticker: string; confirmations: number; uri: string }> = {
btc: { label: "Bitcoin", ticker: "BTC", confirmations: 1, uri: "bitcoin" },
ltc: { label: "Litecoin", ticker: "LTC", confirmations: 1, uri: "litecoin" },
doge: { label: "Dogecoin", ticker: "DOGE", confirmations: 1, uri: "dogecoin" },
dash: { label: "Dash", ticker: "DASH", confirmations: 1, uri: "dash" },
bch: { label: "Bitcoin Cash", ticker: "BCH", confirmations: 0, uri: "bitcoincash" },
xec: { label: "eCash", ticker: "XEC", confirmations: 0, uri: "ecash" },
};
+165
View File
@@ -0,0 +1,165 @@
import { Elysia, t } from "elysia";
import sql from "./db";
import { derive } from "./address";
import { getExchangeRates, getAvailableCoins, getQrUrl } from "./freedom";
import { PLANS, COINS } from "./plans";
// Resolve account from key (same logic as API/web apps)
async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null; plan: string } | null> {
const [account] = await sql`SELECT id, plan FROM accounts WHERE key = ${key}`;
if (account) return { accountId: account.id, keyId: null, plan: account.plan };
const [apiKey] = await sql`SELECT k.id, k.account_id, a.plan FROM api_keys k JOIN accounts a ON a.id = k.account_id WHERE k.key = ${key}`;
if (apiKey) return { accountId: apiKey.account_id, keyId: apiKey.id, plan: apiKey.plan };
return null;
}
function requireAuth(app: Elysia) {
return app
.derive(async ({ headers, cookie, set }) => {
const authHeader = headers["authorization"] ?? "";
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
const cookieKey = cookie?.pingql_key?.value;
const key = bearer || cookieKey;
if (!key) {
set.status = 401;
return { accountId: null as string | null, keyId: null as string | null, plan: "free" };
}
const resolved = await resolveKey(key);
if (resolved) return resolved;
set.status = 401;
return { accountId: null as string | null, keyId: null as string | null, plan: "free" };
})
.onBeforeHandle(({ accountId, set }) => {
if (!accountId) {
set.status = 401;
return { error: "Unauthorized" };
}
});
}
export const routes = new Elysia()
// Public: available coins and rates
.get("/coins", async () => {
const [available, rates] = await Promise.all([getAvailableCoins(), getExchangeRates()]);
const coins = Object.entries(COINS)
.filter(([k]) => available.includes(k))
.map(([k, v]) => ({ id: k, ...v, rate: rates[k] }));
return { coins, plans: PLANS };
})
.use(requireAuth)
// Create a checkout
.post("/checkout", async ({ accountId, keyId, body, set }) => {
if (keyId) { set.status = 403; return { error: "Sub-keys cannot create checkouts" }; }
const { plan, months, coin } = body;
// Validate plan
if (plan === "lifetime") {
const [acc] = await sql`SELECT plan FROM accounts WHERE id = ${accountId}`;
if (acc.plan === "lifetime") { set.status = 400; return { error: "Already on lifetime plan" }; }
}
// Validate coin
if (!COINS[coin]) { set.status = 400; return { error: `Unknown coin: ${coin}` }; }
const available = await getAvailableCoins();
if (!available.includes(coin)) { set.status = 400; return { error: `${coin} is temporarily unavailable` }; }
// Calculate amount
const amountUsd = plan === "lifetime" ? PLANS.lifetime.priceUsd : PLANS.pro.monthlyUsd * (months ?? 1);
const rates = await getExchangeRates();
const rate = rates[coin];
if (!rate) { set.status = 500; return { error: "Could not fetch exchange rate" }; }
// Crypto amount with 8 decimal precision
const amountCrypto = (amountUsd / rate).toFixed(8);
// Get next derivation index
const [{ next_index }] = await sql`
SELECT COALESCE(MAX(derivation_index), -1) + 1 as next_index FROM payments
`;
// Derive address
let address: string;
try {
address = derive(coin, next_index);
} catch (e: any) {
set.status = 500;
return { error: `Address derivation failed: ${e.message}` };
}
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
const [payment] = await sql`
INSERT INTO payments (account_id, plan, months, amount_usd, coin, amount_crypto, address, derivation_index, expires_at)
VALUES (${accountId}, ${plan}, ${plan === "lifetime" ? null : months ?? 1}, ${amountUsd}, ${coin}, ${amountCrypto}, ${address}, ${next_index}, ${expiresAt.toISOString()})
RETURNING *
`;
// Build payment URI for QR code
const coinInfo = COINS[coin];
const uri = `${coinInfo.uri}:${address}?amount=${amountCrypto}`;
return {
id: payment.id,
plan: payment.plan,
months: payment.months,
amount_usd: Number(payment.amount_usd),
coin: payment.coin,
amount_crypto: payment.amount_crypto,
address: payment.address,
status: payment.status,
expires_at: payment.expires_at,
qr_url: getQrUrl(uri),
coin_label: coinInfo.label,
coin_ticker: coinInfo.ticker,
};
}, {
body: t.Object({
plan: t.String({ description: "'pro' or 'lifetime'" }),
months: t.Optional(t.Number({ minimum: 1, maximum: 12 })),
coin: t.String({ description: "Coin ticker: btc, bch, ltc, doge, dash, xec" }),
}),
})
// Get checkout details
.get("/checkout/:id", async ({ accountId, params, set }) => {
const [payment] = await sql`
SELECT * FROM payments WHERE id = ${params.id} AND account_id = ${accountId}
`;
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}`;
return {
id: payment.id,
plan: payment.plan,
months: payment.months,
amount_usd: Number(payment.amount_usd),
coin: payment.coin,
amount_crypto: payment.amount_crypto,
address: payment.address,
status: payment.status,
created_at: payment.created_at,
expires_at: payment.expires_at,
paid_at: payment.paid_at,
txid: payment.txid,
qr_url: getQrUrl(uri),
coin_label: coinInfo?.label,
coin_ticker: coinInfo?.ticker,
};
})
// Lightweight status poll
.get("/checkout/:id/status", async ({ accountId, params, set }) => {
const [payment] = await sql`
SELECT status, txid FROM payments WHERE id = ${params.id} AND account_id = ${accountId}
`;
if (!payment) { set.status = 404; return { error: "Payment not found" }; }
return { status: payment.status, txid: payment.txid };
});