first commit

Made-with: Cursor
This commit is contained in:
Michaël
2026-02-26 18:33:00 -03:00
commit 3734365463
76 changed files with 14133 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
import { config } from "../config.js";
import { getDb } from "../db/index.js";
import { getWalletBalanceSats } from "./lnbits.js";
import { fetchAndScorePubkey } from "./nostr.js";
export type DenialCode =
| "faucet_disabled"
| "emergency_stop"
| "insufficient_balance"
| "daily_budget_exceeded"
| "cooldown_pubkey"
| "cooldown_ip"
| "account_too_new"
| "low_activity"
| "invalid_nip98"
| "invalid_lightning_address"
| "quote_expired"
| "payout_failed";
export interface EligibilityResult {
eligible: boolean;
denialCode?: DenialCode;
denialMessage?: string;
nextEligibleAt?: number;
}
const SECONDS_PER_DAY = 86400;
export async function checkEligibility(pubkey: string, ipHash: string): Promise<EligibilityResult> {
if (config.emergencyStop) {
return {
eligible: false,
denialCode: "emergency_stop",
denialMessage: "The faucet is temporarily in maintenance. Please try again later.",
};
}
if (!config.faucetEnabled) {
return {
eligible: false,
denialCode: "faucet_disabled",
denialMessage: "The faucet is currently disabled.",
};
}
let balanceSats: number;
try {
balanceSats = await getWalletBalanceSats();
} catch {
return {
eligible: false,
denialCode: "insufficient_balance",
denialMessage: "Unable to check faucet balance. Please try again later.",
};
}
if (balanceSats < config.faucetMinSats) {
return {
eligible: false,
denialCode: "insufficient_balance",
denialMessage: balanceSats === 0
? "The faucet pool is empty. Donations welcome!"
: `The faucet pool is too low to pay out (${balanceSats} sats). Donations welcome!`,
};
}
const db = getDb();
const lastPubkeyClaim = await db.getLastSuccessfulClaimByPubkey(pubkey);
const cooldownEnd = lastPubkeyClaim
? lastPubkeyClaim.claimed_at + config.cooldownDays * SECONDS_PER_DAY
: 0;
const now = Math.floor(Date.now() / 1000);
if (cooldownEnd > now) {
return {
eligible: false,
denialCode: "cooldown_pubkey",
denialMessage: "You have already claimed recently.",
nextEligibleAt: cooldownEnd,
};
}
const ipSince = now - config.ipCooldownDays * SECONDS_PER_DAY;
const ipClaimCount = await db.getClaimCountForIpSince(ipHash, ipSince);
if (ipClaimCount >= config.maxClaimsPerIpPerPeriod) {
const lastIpClaim = await db.getLastClaimByIpHash(ipHash);
const ipNextAt = lastIpClaim ? lastIpClaim.claimed_at + config.ipCooldownDays * SECONDS_PER_DAY : 0;
return {
eligible: false,
denialCode: "cooldown_ip",
denialMessage: "This IP has reached the claim limit for this period.",
nextEligibleAt: ipNextAt,
};
}
const profile = await fetchAndScorePubkey(pubkey);
const minAgeSec = config.minAccountAgeDays * SECONDS_PER_DAY;
const cutoff = now - minAgeSec;
if (profile.nostrFirstSeenAt === null || profile.nostrFirstSeenAt > cutoff) {
return {
eligible: false,
denialCode: "account_too_new",
denialMessage: `Your Nostr account must be at least ${config.minAccountAgeDays} days old.`,
};
}
if (profile.activityScore < config.minActivityScore) {
return {
eligible: false,
denialCode: "low_activity",
denialMessage: `Your account does not meet the minimum activity score (${config.minActivityScore}). Be more active on Nostr and try again.`,
};
}
const since24h = now - 86400;
const claims24h = await db.getClaimsCountSince(since24h);
if (claims24h >= config.maxClaimsPerDay) {
return {
eligible: false,
denialCode: "daily_budget_exceeded",
denialMessage: "Daily claim limit reached. Try again tomorrow.",
};
}
return { eligible: true };
}

View File

@@ -0,0 +1,190 @@
import { config } from "../config.js";
const base = config.lnbitsBaseUrl;
const adminKey = config.lnbitsAdminKey;
const walletId = config.lnbitsWalletId;
export async function getWalletBalanceSats(): Promise<number> {
const res = await fetch(`${base}/api/v1/wallet`, {
headers: { "X-Api-Key": adminKey },
});
if (!res.ok) {
const text = await res.text();
throw new Error(`LNbits wallet fetch failed: ${res.status} ${text}`);
}
const data = (await res.json()) as { balance?: number };
return Math.floor((data.balance ?? 0) / 1000);
}
/**
* Pay to a Lightning address via LNURL.
* 1. Resolve Lightning address to LNURL (GET https://domain/.well-known/lnurlp/user)
* 2. Call callback with amount in millisats
*/
export async function payToLightningAddress(
lightningAddress: string,
sats: number
): Promise<{ paymentHash: string }> {
const [user, domain] = lightningAddress.split("@");
if (!user || !domain) {
console.error("[lnbits] Invalid Lightning address format:", lightningAddress);
throw new Error("Invalid Lightning address format");
}
const lnurlpUrl = `https://${domain}/.well-known/lnurlp/${user}`;
const lnurlRes = await fetch(lnurlpUrl);
if (!lnurlRes.ok) {
const text = await lnurlRes.text();
console.error("[lnbits] LNURLp resolution failed:", {
lightningAddress,
lnurlpUrl,
status: lnurlRes.status,
statusText: lnurlRes.statusText,
body: text.slice(0, 500),
});
throw new Error(`Could not resolve Lightning address: ${lnurlRes.status} ${text.slice(0, 200)}`);
}
const lnurlData = (await lnurlRes.json()) as { callback?: string; minSendable?: number; maxSendable?: number };
const callback = lnurlData.callback;
if (!callback) {
console.error("[lnbits] No callback in LNURLp response:", { lightningAddress, lnurlpUrl, lnurlData });
throw new Error("No callback in LNURLp");
}
const millisats = sats * 1000;
const separator = callback.includes("?") ? "&" : "?";
const payReqUrl = `${callback}${separator}amount=${millisats}`;
const payRes = await fetch(payReqUrl);
const payBody = await payRes.text();
if (!payRes.ok) {
let parsed: unknown;
try {
parsed = JSON.parse(payBody);
} catch {
parsed = payBody;
}
console.error("[lnbits] LNURL pay request failed:", {
lightningAddress,
sats,
millisats,
callbackHost: new URL(callback).host,
status: payRes.status,
statusText: payRes.statusText,
body: parsed,
});
const detail = typeof parsed === "object" && parsed !== null && "reason" in parsed
? (parsed as { reason?: string }).reason
: payBody.slice(0, 300);
throw new Error(`LNURL pay request failed: ${payRes.status} ${detail}`);
}
const payData = JSON.parse(payBody) as { pr?: string; reason?: string };
const pr = payData.pr;
if (!pr) {
console.error("[lnbits] No invoice (pr) in pay response:", { lightningAddress, payData });
throw new Error(`No invoice in pay response: ${payData.reason ?? JSON.stringify(payData).slice(0, 200)}`);
}
const payResult = await fetch(`${base}/api/v1/payments`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Api-Key": adminKey },
body: JSON.stringify({ out: true, bolt11: pr }),
});
if (!payResult.ok) {
const errText = await payResult.text();
console.error("[lnbits] LNbits bolt11 payment failed:", {
lightningAddress,
sats,
status: payResult.status,
body: errText.slice(0, 500),
});
throw new Error(`LNbits pay failed: ${payResult.status} ${errText}`);
}
const result = (await payResult.json()) as { payment_hash?: string };
return { paymentHash: result.payment_hash ?? "" };
}
/** LNbits payment list item (GET /api/v1/payments). Amount in millisatoshis; positive = incoming, negative = outgoing. */
/** Per LNbits OpenAPI: time, created_at, updated_at are "string" format "date-time" (ISO 8601). */
export interface LnbitsPaymentItem {
payment_hash?: string;
amount?: number;
pending?: boolean;
time?: number | string;
created_at?: number | string;
updated_at?: number | string;
timestamp?: number;
date?: number;
[key: string]: unknown;
}
const MIN_VALID_UNIX = 1e9;
function parsePaymentTime(raw: unknown): number {
if (raw == null) return 0;
if (typeof raw === "number") {
const ts = raw > 1e12 ? Math.floor(raw / 1000) : raw;
return ts >= MIN_VALID_UNIX ? ts : 0;
}
if (typeof raw === "string") {
const ms = Date.parse(raw);
if (Number.isNaN(ms)) return 0;
return Math.floor(ms / 1000);
}
return 0;
}
function normalizePaymentTime(p: LnbitsPaymentItem): number {
const ts =
parsePaymentTime(p.time) ||
parsePaymentTime(p.created_at) ||
parsePaymentTime(p.updated_at) ||
parsePaymentTime(p.timestamp) ||
parsePaymentTime(p.date);
if (ts >= MIN_VALID_UNIX) return ts;
return Math.floor(Date.now() / 1000);
}
/**
* Fetch recent payments from LNbits and return paid incoming ones (amount > 0, not pending).
* LNbits returns amount in millisatoshis; we convert to sats for storage.
*/
export async function getIncomingPaymentsFromLnbits(limit = 100): Promise<
{ payment_hash: string; amount_sats: number; paid_at: number }[]
> {
const res = await fetch(
`${base}/api/v1/payments?limit=${limit}&sortby=time&direction=desc`,
{ headers: { "X-Api-Key": adminKey } }
);
if (!res.ok) {
const text = await res.text();
throw new Error(`LNbits payments list failed: ${res.status} ${text}`);
}
const data = (await res.json()) as LnbitsPaymentItem[] | { detail?: string; payments?: LnbitsPaymentItem[] };
let items: LnbitsPaymentItem[];
if (Array.isArray(data)) {
items = data;
} else if (data && typeof data === "object" && Array.isArray((data as { payments?: LnbitsPaymentItem[] }).payments)) {
items = (data as { payments: LnbitsPaymentItem[] }).payments;
} else {
const detail = (data as { detail?: string })?.detail;
throw new Error(detail ?? "LNbits payments list invalid response");
}
const incoming: { payment_hash: string; amount_sats: number; paid_at: number }[] = [];
for (const p of items) {
const hash = p.payment_hash;
const amountMsats = Number(p.amount ?? 0);
const pending = Boolean(p.pending);
const paidAt = normalizePaymentTime(p);
if (!hash || typeof hash !== "string") continue;
if (pending) continue;
if (amountMsats <= 0) continue;
const amountSats = Math.floor(amountMsats / 1000);
if (amountSats <= 0) continue;
incoming.push({
payment_hash: hash,
amount_sats: amountSats,
paid_at: paidAt,
});
}
return incoming;
}

View File

@@ -0,0 +1,150 @@
import { SimplePool } from "nostr-tools";
import { config } from "../config.js";
import { getDb } from "../db/index.js";
const pool = new SimplePool();
export interface NostrProfile {
nostrFirstSeenAt: number | null;
notesCount: number;
followingCount: number;
followersCount: number;
activityScore: number;
}
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, rej) => setTimeout(() => rej(new Error("timeout")), ms)),
]);
}
/**
* Fetch events from relays in parallel (kinds 0, 1, 3), compute metrics, optionally cache.
* When forceRefreshProfile is true, always fetch from relays (skip cache) so kind 0 is parsed and lightning_address/name updated.
*/
export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile = false): Promise<NostrProfile> {
const db = getDb();
const cached = await db.getUser(pubkey);
const nowSec = Math.floor(Date.now() / 1000);
const cacheHours = config.metadataCacheHours;
const cacheValidUntil = (cached?.last_metadata_fetch_at ?? 0) + cacheHours * 3600;
if (!forceRefreshProfile && cached && cacheValidUntil > nowSec) {
return {
nostrFirstSeenAt: cached.nostr_first_seen_at,
notesCount: cached.notes_count,
followingCount: cached.following_count,
followersCount: cached.followers_count,
activityScore: cached.activity_score,
};
}
let events: { kind: number; created_at: number; content?: string; tags: string[][] }[] = [];
try {
const result = await withTimeout(
pool.querySync(config.nostrRelays, { kinds: [0, 1, 3], authors: [pubkey], limit: config.maxEventsFetch }),
config.relayTimeoutMs
);
events = Array.isArray(result) ? result : [];
} catch (_) {
// Timeout or relay error: use cache if any; otherwise upsert minimal user so /refresh-profile returns a row
if (cached) {
return {
nostrFirstSeenAt: cached.nostr_first_seen_at,
notesCount: cached.notes_count,
followingCount: cached.following_count,
followersCount: cached.followers_count,
activityScore: cached.activity_score,
};
}
const lastMetadataFetchAt = Math.floor(Date.now() / 1000);
await db.upsertUser({
pubkey,
nostr_first_seen_at: null,
notes_count: 0,
followers_count: 0,
following_count: 0,
activity_score: 0,
last_metadata_fetch_at: lastMetadataFetchAt,
lightning_address: null,
name: null,
});
return {
nostrFirstSeenAt: null,
notesCount: 0,
followingCount: 0,
followersCount: 0,
activityScore: 0,
};
}
const kind0 = events.filter((e) => e.kind === 0);
const kind1 = events.filter((e) => e.kind === 1);
const kind3 = events.filter((e) => e.kind === 3);
const earliestCreatedAt = events.length
? Math.min(...events.map((e) => e.created_at))
: null;
const lookbackSince = nowSec - config.activityLookbackDays * 86400;
const notesInLookback = kind1.filter((e) => e.created_at >= lookbackSince).length;
let followingCount = 0;
if (kind3.length > 0) {
const contacts = kind3[0].tags?.filter((t) => t[0] === "p").length ?? 0;
followingCount = contacts;
}
const hasMetadata = kind0.length > 0;
let score = 0;
if (hasMetadata) score += 10;
if (notesInLookback >= config.minNotesCount) score += 20;
if (followingCount >= config.minFollowingCount) score += 10;
if (0 >= config.minFollowersCount) score += 10; // followers not fetched for MVP; treat as 0
let lightning_address: string | null = null;
let name: string | null = null;
const lightningAddressRe = /^[^@]+@[^@]+$/;
if (kind0.length > 0 && kind0[0].content) {
try {
const meta = JSON.parse(kind0[0].content) as Record<string, unknown>;
// NIP-19 / common: lud16 is the Lightning address (user@domain). Fallbacks for other clients.
for (const key of ["lud16", "lightning", "ln_address", "nip05"] as const) {
const v = meta[key];
if (typeof v === "string") {
const s = v.trim();
if (lightningAddressRe.test(s)) {
lightning_address = s;
break;
}
}
}
if (typeof meta.name === "string" && meta.name.trim()) name = meta.name.trim();
else if (typeof meta.display_name === "string" && meta.display_name.trim()) name = meta.display_name.trim();
} catch (_) {}
}
const nostrFirstSeenAt = earliestCreatedAt;
const lastMetadataFetchAt = Math.floor(Date.now() / 1000);
await db.upsertUser({
pubkey,
nostr_first_seen_at: nostrFirstSeenAt,
notes_count: notesInLookback,
followers_count: 0,
following_count: followingCount,
activity_score: score,
last_metadata_fetch_at: lastMetadataFetchAt,
lightning_address,
name,
});
return {
nostrFirstSeenAt,
notesCount: notesInLookback,
followingCount,
followersCount: 0,
activityScore: score,
};
}

View File

@@ -0,0 +1,69 @@
import { randomInt } from "crypto";
import { v4 as uuidv4 } from "uuid";
import { config } from "../config.js";
import { getDb } from "../db/index.js";
import { getWalletBalanceSats } from "./lnbits.js";
const QUOTE_TTL_SECONDS = 60;
interface PayoutBucket {
sats: number;
weight: number;
}
function getPayoutBuckets(): PayoutBucket[] {
return [
{ sats: config.payoutSmallSats, weight: config.payoutWeightSmall },
{ sats: config.payoutMediumSats, weight: config.payoutWeightMedium },
{ sats: config.payoutLargeSats, weight: config.payoutWeightLarge },
{ sats: config.payoutJackpotSats, weight: config.payoutWeightJackpot },
];
}
/**
* Weighted random selection. Returns sats amount.
*/
export function selectWeightedPayout(): number {
const buckets = getPayoutBuckets();
const totalWeight = buckets.reduce((s, b) => s + b.weight, 0);
let r = randomInt(0, totalWeight);
for (const b of buckets) {
if (r < b.weight) return b.sats;
r -= b.weight;
}
return config.payoutSmallSats;
}
/**
* Compute payout for this claim: weighted selection, capped by daily budget remaining.
*/
export function computePayoutForClaim(todayPaidSats: number): number {
const remaining = Math.max(0, config.dailyBudgetSats - todayPaidSats);
if (remaining < config.faucetMinSats) return 0;
const selected = selectWeightedPayout();
return Math.min(selected, remaining, config.faucetMaxSats);
}
export interface CreateQuoteResult {
quoteId: string;
payoutSats: number;
expiresAt: number;
}
export async function createQuote(pubkey: string, lightningAddress: string): Promise<CreateQuoteResult | null> {
const db = getDb();
const now = Math.floor(Date.now() / 1000);
const dayStart = now - (now % 86400);
const todayPaid = await db.getPaidSatsSince(dayStart);
let payout = computePayoutForClaim(todayPaid);
if (payout <= 0) return null;
const walletBalance = await getWalletBalanceSats();
payout = Math.min(payout, Math.max(0, walletBalance));
if (payout < config.faucetMinSats) return null;
const quoteId = uuidv4();
const expiresAt = now + QUOTE_TTL_SECONDS;
await db.createQuote(quoteId, pubkey, payout, lightningAddress, expiresAt);
return { quoteId, payoutSats: payout, expiresAt };
}

View File

@@ -0,0 +1,44 @@
import { getDb } from "../db/index.js";
import { getIncomingPaymentsFromLnbits } from "./lnbits.js";
const SYNC_INTERVAL_MS = 2 * 60 * 1000;
const MIN_VALID_UNIX = 1e9;
export async function syncLnbitsDeposits(): Promise<void> {
const db = getDb();
try {
const payments = await getIncomingPaymentsFromLnbits(100);
let added = 0;
let updated = 0;
for (const p of payments) {
const exists = await db.hasDepositWithPaymentHash(p.payment_hash);
if (!exists) {
await db.insertDeposit(
p.amount_sats,
"lightning",
p.payment_hash,
p.paid_at
);
added++;
} else if (p.paid_at >= MIN_VALID_UNIX) {
const didUpdate = await db.updateDepositCreatedAtIfMissing(p.payment_hash, p.paid_at);
if (didUpdate) updated++;
}
}
if (added > 0) {
console.log(`[sync] LNbits deposits: ${added} new incoming payment(s) synced`);
}
if (updated > 0) {
console.log(`[sync] LNbits deposits: ${updated} date(s) backfilled`);
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[sync] LNbits deposits failed:", msg);
}
}
export function startLnbitsDepositSync(): void {
syncLnbitsDeposits();
setInterval(syncLnbitsDeposits, SYNC_INTERVAL_MS);
}