first commit
Made-with: Cursor
This commit is contained in:
126
backend/src/services/eligibility.ts
Normal file
126
backend/src/services/eligibility.ts
Normal 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 };
|
||||
}
|
||||
190
backend/src/services/lnbits.ts
Normal file
190
backend/src/services/lnbits.ts
Normal 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;
|
||||
}
|
||||
150
backend/src/services/nostr.ts
Normal file
150
backend/src/services/nostr.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
69
backend/src/services/quote.ts
Normal file
69
backend/src/services/quote.ts
Normal 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 };
|
||||
}
|
||||
44
backend/src/services/syncLnbitsDeposits.ts
Normal file
44
backend/src/services/syncLnbitsDeposits.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user