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

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# Dependencies
node_modules/
# Build / output
dist/
build/
*.tsbuildinfo
# Environment and secrets
.env
.env.local
.env.*.local
# Logs
logs/
*.log
npm-debug.log*
# OS
.DS_Store
Thumbs.db
# IDE / editor
.idea/
.vscode/
*.swp
*.swo
# SQLite / local DB
*.db
*.sqlite
*.sqlite3
backend/data/
# Vite
frontend/dist/
# Misc
*.local
.cache/

62
backend/.env.example Normal file
View File

@@ -0,0 +1,62 @@
# Server
PORT=3001
TRUST_PROXY=false
# Comma-separated origins for CORS (default allows 5173 and 5174)
# ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174
# Database: omit for SQLite (default); set for Postgres
# DATABASE_URL=postgresql://user:pass@localhost:5432/faucet
# SQLITE_PATH=./data/faucet.db
# Security (required)
HMAC_IP_SECRET=your-secret-key-min-32-chars
JWT_SECRET=your-jwt-secret-min-32-chars
JWT_EXPIRES_IN_SECONDS=604800
NIP98_MAX_SKEW_SECONDS=300
NONCE_TTL_SECONDS=600
# Faucet economics
FAUCET_ENABLED=true
EMERGENCY_STOP=false
FAUCET_MIN_SATS=10
FAUCET_MAX_SATS=100
PAYOUT_WEIGHT_SMALL=50
PAYOUT_WEIGHT_MEDIUM=30
PAYOUT_WEIGHT_LARGE=15
PAYOUT_WEIGHT_JACKPOT=5
PAYOUT_SMALL_SATS=10
PAYOUT_MEDIUM_SATS=25
PAYOUT_LARGE_SATS=50
PAYOUT_JACKPOT_SATS=100
DAILY_BUDGET_SATS=10000
MAX_CLAIMS_PER_DAY=100
MIN_WALLET_BALANCE_SATS=1000
# Eligibility
MIN_ACCOUNT_AGE_DAYS=14
MIN_ACTIVITY_SCORE=30
MIN_NOTES_COUNT=5
MIN_FOLLOWING_COUNT=10
MIN_FOLLOWERS_COUNT=0
ACTIVITY_LOOKBACK_DAYS=90
# Cooldowns
COOLDOWN_DAYS=7
IP_COOLDOWN_DAYS=7
MAX_CLAIMS_PER_IP_PER_PERIOD=1
# Nostr
NOSTR_RELAYS=wss://relay.damus.io,wss://relay.nostr.band
RELAY_TIMEOUT_MS=5000
MAX_EVENTS_FETCH=500
METADATA_CACHE_HOURS=24
# LNbits
LNBITS_BASE_URL=https://azzamo.online
LNBITS_ADMIN_KEY=your-admin-key
LNBITS_WALLET_ID=your-wallet-id
DEPOSIT_LIGHTNING_ADDRESS=faucet@yourdomain.com
DEPOSIT_LNURLP=https://yourdomain.com/.well-known/lnurlp/faucet
# Cashu redeem (optional; default: https://cashu-redeem.azzamo.net)
# CASHU_REDEEM_API_URL=https://cashu-redeem.azzamo.net

2303
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
backend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "lnfaucet-backend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"migrate": "tsx src/db/migrate.ts"
},
"dependencies": {
"better-sqlite3": "^11.6.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"express-rate-limit": "^7.4.1",
"nostr-tools": "^2.4.4",
"pg": "^8.13.1",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^22.9.0",
"@types/pg": "^8.11.10",
"@types/uuid": "^10.0.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
}

32
backend/src/auth/jwt.ts Normal file
View File

@@ -0,0 +1,32 @@
import { createHmac } from "crypto";
import { config } from "../config.js";
const HEADER = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
const SEP = ".";
export function signJwt(pubkey: string): string {
const exp = Math.floor(Date.now() / 1000) + config.jwtExpiresInSeconds;
const payload = Buffer.from(JSON.stringify({ pubkey, exp })).toString("base64url");
const message = `${HEADER}${SEP}${payload}`;
const sig = createHmac("sha256", config.jwtSecret).update(message).digest("base64url");
return `${message}${SEP}${sig}`;
}
export function verifyJwt(token: string): { pubkey: string } | null {
try {
const parts = token.split(SEP);
if (parts.length !== 3) return null;
const [headerB64, payloadB64, sigB64] = parts;
const message = `${headerB64}${SEP}${payloadB64}`;
const expected = createHmac("sha256", config.jwtSecret).update(message).digest("base64url");
if (sigB64 !== expected) return null;
const payload = JSON.parse(
Buffer.from(payloadB64, "base64url").toString("utf-8")
) as { pubkey?: string; exp?: number };
if (!payload.pubkey || typeof payload.pubkey !== "string") return null;
if (!payload.exp || payload.exp < Math.floor(Date.now() / 1000)) return null;
return { pubkey: payload.pubkey };
} catch {
return null;
}
}

87
backend/src/config.ts Normal file
View File

@@ -0,0 +1,87 @@
import "dotenv/config";
function env(key: string, defaultValue?: string): string {
const v = process.env[key];
if (v !== undefined) return v;
if (defaultValue !== undefined) return defaultValue;
throw new Error(`Missing required env: ${key}`);
}
function envInt(key: string, defaultValue: number): number {
const v = process.env[key];
if (v === undefined) return defaultValue;
const n = parseInt(v, 10);
if (Number.isNaN(n)) throw new Error(`Invalid integer env: ${key}`);
return n;
}
function envBool(key: string, defaultValue: boolean): boolean {
const v = process.env[key];
if (v === undefined) return defaultValue;
return v === "true" || v === "1";
}
export const config = {
port: envInt("PORT", 3001),
trustProxy: envBool("TRUST_PROXY", false),
allowedOrigins: (process.env.ALLOWED_ORIGINS ?? process.env.FRONTEND_URL ?? "http://localhost:5173,http://localhost:5174").split(",").map((s) => s.trim()),
// Database: omit DATABASE_URL for SQLite; set for Postgres
databaseUrl: process.env.DATABASE_URL as string | undefined,
sqlitePath: process.env.SQLITE_PATH ?? "./data/faucet.db",
// Security
hmacIpSecret: env("HMAC_IP_SECRET"),
jwtSecret: env("JWT_SECRET", process.env.HMAC_IP_SECRET ?? ""),
jwtExpiresInSeconds: envInt("JWT_EXPIRES_IN_SECONDS", 86400 * 7), // 7 days
nip98MaxSkewSeconds: envInt("NIP98_MAX_SKEW_SECONDS", 300),
nonceTtlSeconds: envInt("NONCE_TTL_SECONDS", 600),
// Faucet economics
faucetEnabled: envBool("FAUCET_ENABLED", true),
emergencyStop: envBool("EMERGENCY_STOP", false),
faucetMinSats: envInt("FAUCET_MIN_SATS", 1),
faucetMaxSats: envInt("FAUCET_MAX_SATS", 5),
payoutWeightSmall: envInt("PAYOUT_WEIGHT_SMALL", 50),
payoutWeightMedium: envInt("PAYOUT_WEIGHT_MEDIUM", 30),
payoutWeightLarge: envInt("PAYOUT_WEIGHT_LARGE", 15),
payoutWeightJackpot: envInt("PAYOUT_WEIGHT_JACKPOT", 5),
payoutSmallSats: envInt("PAYOUT_SMALL_SATS", 10),
payoutMediumSats: envInt("PAYOUT_MEDIUM_SATS", 25),
payoutLargeSats: envInt("PAYOUT_LARGE_SATS", 50),
payoutJackpotSats: envInt("PAYOUT_JACKPOT_SATS", 100),
dailyBudgetSats: envInt("DAILY_BUDGET_SATS", 10000),
maxClaimsPerDay: envInt("MAX_CLAIMS_PER_DAY", 100),
minWalletBalanceSats: envInt("MIN_WALLET_BALANCE_SATS", 1000),
// Eligibility
minAccountAgeDays: envInt("MIN_ACCOUNT_AGE_DAYS", 14),
minActivityScore: envInt("MIN_ACTIVITY_SCORE", 30),
minNotesCount: envInt("MIN_NOTES_COUNT", 5),
minFollowingCount: envInt("MIN_FOLLOWING_COUNT", 10),
minFollowersCount: envInt("MIN_FOLLOWERS_COUNT", 0),
activityLookbackDays: envInt("ACTIVITY_LOOKBACK_DAYS", 90),
// Cooldowns
cooldownDays: envInt("COOLDOWN_DAYS", 7),
ipCooldownDays: envInt("IP_COOLDOWN_DAYS", 7),
maxClaimsPerIpPerPeriod: envInt("MAX_CLAIMS_PER_IP_PER_PERIOD", 1),
// Nostr (defaults include relays common for remote signers / NIP-05)
nostrRelays: (process.env.NOSTR_RELAYS ?? "wss://relay.damus.io,wss://relay.nostr.band,wss://relay.getalby.com,wss://nos.lol").split(",").map((s) => s.trim()),
relayTimeoutMs: envInt("RELAY_TIMEOUT_MS", 5000),
maxEventsFetch: envInt("MAX_EVENTS_FETCH", 500),
metadataCacheHours: envInt("METADATA_CACHE_HOURS", 24),
// LNbits
lnbitsBaseUrl: env("LNBITS_BASE_URL").replace(/\/$/, ""),
lnbitsAdminKey: env("LNBITS_ADMIN_KEY"),
lnbitsWalletId: env("LNBITS_WALLET_ID"),
depositLightningAddress: process.env.DEPOSIT_LIGHTNING_ADDRESS ?? "",
depositLnurlp: process.env.DEPOSIT_LNURLP ?? "",
cashuRedeemApiUrl: (process.env.CASHU_REDEEM_API_URL ?? "https://cashu-redeem.azzamo.net").replace(/\/$/, ""),
};
export function usePostgres(): boolean {
return Boolean(config.databaseUrl);
}

24
backend/src/db/index.ts Normal file
View File

@@ -0,0 +1,24 @@
import { config, usePostgres } from "../config.js";
import { createPgDb } from "./pg.js";
import { createSqliteDb } from "./sqlite.js";
import type { Db } from "./types.js";
import { mkdirSync, existsSync } from "fs";
import { dirname } from "path";
let dbInstance: Db | null = null;
export function getDb(): Db {
if (!dbInstance) {
if (usePostgres() && config.databaseUrl) {
dbInstance = createPgDb(config.databaseUrl);
} else {
const dir = dirname(config.sqlitePath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
dbInstance = createSqliteDb(config.sqlitePath);
}
}
return dbInstance;
}
export type { Db } from "./types.js";
export type { UserRow, ClaimRow, QuoteRow, IpLimitRow } from "./types.js";

14
backend/src/db/migrate.ts Normal file
View File

@@ -0,0 +1,14 @@
import "dotenv/config";
import { getDb } from "./index.js";
async function main() {
const db = getDb();
await db.runMigrations();
console.log("Migrations complete.");
process.exit(0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

347
backend/src/db/pg.ts Normal file
View File

@@ -0,0 +1,347 @@
import pg from "pg";
import { readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import type { ClaimRow, Db, DepositSource, IpLimitRow, QuoteRow, UserRow } from "./types.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
export function createPgDb(connectionString: string): Db {
const pool = new pg.Pool({ connectionString });
function toUserRow(r: pg.QueryResultRow): UserRow {
return {
pubkey: r.pubkey,
nostr_first_seen_at: r.nostr_first_seen_at != null ? Number(r.nostr_first_seen_at) : null,
notes_count: Number(r.notes_count),
followers_count: Number(r.followers_count),
following_count: Number(r.following_count),
activity_score: Number(r.activity_score),
last_metadata_fetch_at: r.last_metadata_fetch_at != null ? Number(r.last_metadata_fetch_at) : null,
lightning_address: r.lightning_address ?? null,
name: r.name ?? null,
created_at: Number(r.created_at),
updated_at: Number(r.updated_at),
};
}
function toClaimRow(r: pg.QueryResultRow): ClaimRow {
return {
id: r.id,
pubkey: r.pubkey,
claimed_at: Number(r.claimed_at),
payout_sats: r.payout_sats,
ip_hash: r.ip_hash,
payout_destination_hash: r.payout_destination_hash,
status: r.status,
lnbits_payment_hash: r.lnbits_payment_hash,
error_message: r.error_message,
};
}
function toQuoteRow(r: pg.QueryResultRow): QuoteRow {
return {
quote_id: r.quote_id,
pubkey: r.pubkey,
payout_sats: r.payout_sats,
lightning_address: r.lightning_address ?? null,
created_at: Number(r.created_at),
expires_at: Number(r.expires_at),
status: r.status,
};
}
return {
async runMigrations() {
const schema = readFileSync(join(__dirname, "schema.pg.sql"), "utf-8");
await pool.query(schema);
try {
await pool.query("ALTER TABLE users ADD COLUMN lightning_address TEXT");
} catch (_) {}
try {
await pool.query("ALTER TABLE users ADD COLUMN name TEXT");
} catch (_) {}
try {
await pool.query(
`CREATE TABLE IF NOT EXISTS deposits (
id SERIAL PRIMARY KEY,
created_at BIGINT NOT NULL,
amount_sats INTEGER NOT NULL,
source TEXT NOT NULL CHECK(source IN ('lightning','cashu')),
lnbits_payment_hash TEXT
)`
);
await pool.query("CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at)");
} catch (_) {}
try {
await pool.query("ALTER TABLE deposits ADD COLUMN lnbits_payment_hash TEXT");
} catch (_) {}
try {
await pool.query("CREATE INDEX IF NOT EXISTS idx_deposits_lnbits_payment_hash ON deposits(lnbits_payment_hash)");
} catch (_) {}
try {
await pool.query(
"UPDATE deposits SET amount_sats = amount_sats / 1000 WHERE source = 'lightning' AND lnbits_payment_hash IS NOT NULL AND amount_sats >= 1000"
);
} catch (_) {}
},
async getUser(pubkey: string): Promise<UserRow | null> {
const res = await pool.query("SELECT * FROM users WHERE pubkey = $1", [pubkey]);
return res.rows.length ? toUserRow(res.rows[0]) : null;
},
async upsertUser(row: Omit<UserRow, "created_at" | "updated_at">): Promise<void> {
const now = Math.floor(Date.now() / 1000);
await pool.query(
`INSERT INTO users (pubkey, nostr_first_seen_at, notes_count, followers_count, following_count, activity_score, last_metadata_fetch_at, lightning_address, name, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)
ON CONFLICT(pubkey) DO UPDATE SET
nostr_first_seen_at = EXCLUDED.nostr_first_seen_at,
notes_count = EXCLUDED.notes_count,
followers_count = EXCLUDED.followers_count,
following_count = EXCLUDED.following_count,
activity_score = EXCLUDED.activity_score,
last_metadata_fetch_at = EXCLUDED.last_metadata_fetch_at,
lightning_address = EXCLUDED.lightning_address,
name = EXCLUDED.name,
updated_at = EXCLUDED.updated_at`,
[
row.pubkey,
row.nostr_first_seen_at ?? null,
row.notes_count ?? 0,
row.followers_count ?? 0,
row.following_count ?? 0,
row.activity_score ?? 0,
row.last_metadata_fetch_at ?? null,
row.lightning_address ?? null,
row.name ?? null,
now,
]
);
},
async updateUserNostrCache(
pubkey: string,
data: {
nostr_first_seen_at: number | null;
notes_count: number;
followers_count: number;
following_count: number;
activity_score: number;
last_metadata_fetch_at: number;
}
): Promise<void> {
const now = Math.floor(Date.now() / 1000);
await pool.query(
`UPDATE users SET
nostr_first_seen_at = $1, notes_count = $2, followers_count = $3, following_count = $4,
activity_score = $5, last_metadata_fetch_at = $6, updated_at = $7
WHERE pubkey = $8`,
[
data.nostr_first_seen_at,
data.notes_count,
data.followers_count,
data.following_count,
data.activity_score,
data.last_metadata_fetch_at,
now,
pubkey,
]
);
},
async getLastSuccessfulClaimByPubkey(pubkey: string): Promise<ClaimRow | null> {
const res = await pool.query(
"SELECT * FROM claims WHERE pubkey = $1 AND status = 'paid' ORDER BY claimed_at DESC LIMIT 1",
[pubkey]
);
return res.rows.length ? toClaimRow(res.rows[0]) : null;
},
async getLastClaimByIpHash(ipHash: string): Promise<ClaimRow | null> {
const res = await pool.query(
"SELECT * FROM claims WHERE ip_hash = $1 AND status = 'paid' ORDER BY claimed_at DESC LIMIT 1",
[ipHash]
);
return res.rows.length ? toClaimRow(res.rows[0]) : null;
},
async getClaimCountForIpSince(ipHash: string, sinceTs: number): Promise<number> {
const res = await pool.query(
"SELECT COUNT(*) as c FROM claims WHERE ip_hash = $1 AND status = 'paid' AND claimed_at >= $2",
[ipHash, sinceTs]
);
return parseInt(res.rows[0]?.c ?? "0", 10);
},
async createClaim(row: Omit<ClaimRow, "id">): Promise<number> {
const res = await pool.query(
`INSERT INTO claims (pubkey, claimed_at, payout_sats, ip_hash, payout_destination_hash, status, lnbits_payment_hash, error_message)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
[
row.pubkey,
row.claimed_at,
row.payout_sats,
row.ip_hash,
row.payout_destination_hash ?? null,
row.status,
row.lnbits_payment_hash ?? null,
row.error_message ?? null,
]
);
return res.rows[0].id;
},
async updateClaimStatus(
id: number,
status: ClaimRow["status"],
lnbitsPaymentHash?: string,
errorMessage?: string
): Promise<void> {
await pool.query(
"UPDATE claims SET status = $1, lnbits_payment_hash = $2, error_message = $3 WHERE id = $4",
[status, lnbitsPaymentHash ?? null, errorMessage ?? null, id]
);
},
async getIpLimit(ipHash: string): Promise<IpLimitRow | null> {
const res = await pool.query("SELECT * FROM ip_limits WHERE ip_hash = $1", [ipHash]);
if (!res.rows.length) return null;
const r = res.rows[0];
return {
ip_hash: r.ip_hash,
last_claimed_at: Number(r.last_claimed_at),
claim_count_period: Number(r.claim_count_period),
};
},
async upsertIpLimit(ipHash: string, lastClaimedAt: number, claimCountPeriod: number): Promise<void> {
await pool.query(
`INSERT INTO ip_limits (ip_hash, last_claimed_at, claim_count_period) VALUES ($1, $2, $3)
ON CONFLICT(ip_hash) DO UPDATE SET last_claimed_at = $2, claim_count_period = $3`,
[ipHash, lastClaimedAt, claimCountPeriod]
);
},
async createQuote(quoteId: string, pubkey: string, payoutSats: number, lightningAddress: string, expiresAt: number): Promise<void> {
const now = Math.floor(Date.now() / 1000);
await pool.query(
"INSERT INTO quotes (quote_id, pubkey, payout_sats, lightning_address, created_at, expires_at, status) VALUES ($1, $2, $3, $4, $5, $6, 'active')",
[quoteId, pubkey, payoutSats, lightningAddress, now, expiresAt]
);
},
async getQuote(quoteId: string): Promise<QuoteRow | null> {
const res = await pool.query("SELECT * FROM quotes WHERE quote_id = $1", [quoteId]);
return res.rows.length ? toQuoteRow(res.rows[0]) : null;
},
async consumeQuote(quoteId: string): Promise<void> {
await pool.query("UPDATE quotes SET status = 'consumed' WHERE quote_id = $1", [quoteId]);
},
async setNonce(nonce: string, expiresAt: number): Promise<void> {
await pool.query(
"INSERT INTO nonces (nonce, expires_at) VALUES ($1, $2) ON CONFLICT (nonce) DO UPDATE SET expires_at = $2",
[nonce, expiresAt]
);
},
async hasNonce(nonce: string): Promise<boolean> {
const now = Math.floor(Date.now() / 1000);
const res = await pool.query("SELECT 1 FROM nonces WHERE nonce = $1 AND expires_at > $2", [nonce, now]);
return res.rows.length > 0;
},
async deleteExpiredNonces(): Promise<void> {
const now = Math.floor(Date.now() / 1000);
await pool.query("DELETE FROM nonces WHERE expires_at <= $1", [now]);
},
async getTotalPaidSats(): Promise<number> {
const res = await pool.query(
"SELECT COALESCE(SUM(payout_sats), 0)::bigint as total FROM claims WHERE status = 'paid'"
);
return parseInt(res.rows[0]?.total ?? "0", 10);
},
async getTotalClaimsCount(): Promise<number> {
const res = await pool.query("SELECT COUNT(*) as c FROM claims WHERE status = 'paid'");
return parseInt(res.rows[0]?.c ?? "0", 10);
},
async getClaimsCountSince(sinceTs: number): Promise<number> {
const res = await pool.query(
"SELECT COUNT(*) as c FROM claims WHERE status = 'paid' AND claimed_at >= $1",
[sinceTs]
);
return parseInt(res.rows[0]?.c ?? "0", 10);
},
async getPaidSatsSince(sinceTs: number): Promise<number> {
const res = await pool.query(
"SELECT COALESCE(SUM(payout_sats), 0)::bigint as total FROM claims WHERE status = 'paid' AND claimed_at >= $1",
[sinceTs]
);
return parseInt(res.rows[0]?.total ?? "0", 10);
},
async getRecentPayouts(
limit: number
): Promise<{ pubkey_prefix: string; payout_sats: number; claimed_at: number }[]> {
const res = await pool.query(
"SELECT pubkey, payout_sats, claimed_at FROM claims WHERE status = 'paid' ORDER BY claimed_at DESC LIMIT $1",
[limit]
);
return res.rows.map((r) => ({
pubkey_prefix: r.pubkey.slice(0, 8) + "…",
payout_sats: r.payout_sats,
claimed_at: Number(r.claimed_at),
}));
},
async insertDeposit(
amountSats: number,
source: DepositSource,
lnbitsPaymentHash?: string | null,
createdAt?: number
): Promise<void> {
const now = createdAt ?? Math.floor(Date.now() / 1000);
await pool.query(
"INSERT INTO deposits (created_at, amount_sats, source, lnbits_payment_hash) VALUES ($1, $2, $3, $4)",
[now, amountSats, source, lnbitsPaymentHash ?? null]
);
},
async hasDepositWithPaymentHash(paymentHash: string): Promise<boolean> {
const res = await pool.query(
"SELECT 1 FROM deposits WHERE lnbits_payment_hash = $1 LIMIT 1",
[paymentHash]
);
return res.rows.length > 0;
},
async updateDepositCreatedAtIfMissing(paymentHash: string, createdAt: number): Promise<boolean> {
const res = await pool.query(
"UPDATE deposits SET created_at = $1 WHERE lnbits_payment_hash = $2 AND (created_at IS NULL OR created_at < 1000000000) RETURNING id",
[createdAt, paymentHash]
);
return res.rowCount !== null && res.rowCount > 0;
},
async getRecentDeposits(
limit: number
): Promise<{ amount_sats: number; source: DepositSource; created_at: number }[]> {
const res = await pool.query(
"SELECT amount_sats, source, created_at FROM deposits ORDER BY created_at DESC LIMIT $1",
[limit]
);
return res.rows.map((r) => ({
amount_sats: Number(r.amount_sats),
source: r.source as DepositSource,
created_at: Number(r.created_at),
}));
},
};
}

View File

@@ -0,0 +1,50 @@
-- SQLite schema for LNFaucet
CREATE TABLE IF NOT EXISTS users (
pubkey TEXT PRIMARY KEY,
nostr_first_seen_at INTEGER,
notes_count INTEGER NOT NULL DEFAULT 0,
followers_count INTEGER NOT NULL DEFAULT 0,
following_count INTEGER NOT NULL DEFAULT 0,
activity_score INTEGER NOT NULL DEFAULT 0,
last_metadata_fetch_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pubkey TEXT NOT NULL,
claimed_at INTEGER NOT NULL,
payout_sats INTEGER NOT NULL,
ip_hash TEXT NOT NULL,
payout_destination_hash TEXT,
status TEXT NOT NULL,
lnbits_payment_hash TEXT,
error_message TEXT,
quote_id TEXT
);
CREATE TABLE IF NOT EXISTS ip_limits (
ip_hash TEXT PRIMARY KEY,
last_claimed_at INTEGER,
claim_count_period INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS quotes (
quote_id TEXT PRIMARY KEY,
pubkey TEXT NOT NULL,
payout_sats INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
status TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS nonces (
nonce TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_claims_pubkey ON claims(pubkey);
CREATE INDEX IF NOT EXISTS idx_claims_claimed_at ON claims(claimed_at);
CREATE INDEX IF NOT EXISTS idx_quotes_expires_at ON quotes(expires_at);
CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status);

View File

@@ -0,0 +1,69 @@
-- Postgres schema
CREATE TABLE IF NOT EXISTS users (
pubkey TEXT PRIMARY KEY,
nostr_first_seen_at BIGINT,
notes_count INTEGER DEFAULT 0,
followers_count INTEGER DEFAULT 0,
following_count INTEGER DEFAULT 0,
activity_score INTEGER DEFAULT 0,
last_metadata_fetch_at BIGINT,
lightning_address TEXT,
name TEXT,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::BIGINT),
updated_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::BIGINT)
);
CREATE TABLE IF NOT EXISTS claims (
id SERIAL PRIMARY KEY,
pubkey TEXT NOT NULL REFERENCES users(pubkey),
claimed_at BIGINT NOT NULL,
payout_sats INTEGER NOT NULL,
ip_hash TEXT NOT NULL,
payout_destination_hash TEXT,
status TEXT NOT NULL CHECK(status IN ('pending','paid','failed')),
lnbits_payment_hash TEXT,
error_message TEXT
);
CREATE TABLE IF NOT EXISTS ip_limits (
ip_hash TEXT PRIMARY KEY,
last_claimed_at BIGINT NOT NULL,
claim_count_period INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS quotes (
quote_id TEXT PRIMARY KEY,
pubkey TEXT NOT NULL,
payout_sats INTEGER NOT NULL,
lightning_address TEXT,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('active','consumed','expired'))
);
CREATE TABLE IF NOT EXISTS daily_stats (
date TEXT PRIMARY KEY,
total_paid_sats INTEGER NOT NULL DEFAULT 0,
total_claims INTEGER NOT NULL DEFAULT 0,
unique_pubkeys INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS nonces (
nonce TEXT PRIMARY KEY,
expires_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS deposits (
id SERIAL PRIMARY KEY,
created_at BIGINT NOT NULL,
amount_sats INTEGER NOT NULL,
source TEXT NOT NULL CHECK(source IN ('lightning','cashu')),
lnbits_payment_hash TEXT
);
CREATE INDEX IF NOT EXISTS idx_claims_pubkey ON claims(pubkey);
CREATE INDEX IF NOT EXISTS idx_claims_claimed_at ON claims(claimed_at);
CREATE INDEX IF NOT EXISTS idx_quotes_expires_at ON quotes(expires_at);
CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status);
CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at);
CREATE INDEX IF NOT EXISTS idx_deposits_lnbits_payment_hash ON deposits(lnbits_payment_hash);

70
backend/src/db/schema.sql Normal file
View File

@@ -0,0 +1,70 @@
-- SQLite schema
CREATE TABLE IF NOT EXISTS users (
pubkey TEXT PRIMARY KEY,
nostr_first_seen_at INTEGER,
notes_count INTEGER DEFAULT 0,
followers_count INTEGER DEFAULT 0,
following_count INTEGER DEFAULT 0,
activity_score INTEGER DEFAULT 0,
last_metadata_fetch_at INTEGER,
lightning_address TEXT,
name TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pubkey TEXT NOT NULL,
claimed_at INTEGER NOT NULL,
payout_sats INTEGER NOT NULL,
ip_hash TEXT NOT NULL,
payout_destination_hash TEXT,
status TEXT NOT NULL CHECK(status IN ('pending','paid','failed')),
lnbits_payment_hash TEXT,
error_message TEXT,
FOREIGN KEY (pubkey) REFERENCES users(pubkey)
);
CREATE TABLE IF NOT EXISTS ip_limits (
ip_hash TEXT PRIMARY KEY,
last_claimed_at INTEGER NOT NULL,
claim_count_period INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS quotes (
quote_id TEXT PRIMARY KEY,
pubkey TEXT NOT NULL,
payout_sats INTEGER NOT NULL,
lightning_address TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
status TEXT NOT NULL CHECK(status IN ('active','consumed','expired'))
);
CREATE TABLE IF NOT EXISTS daily_stats (
date TEXT PRIMARY KEY,
total_paid_sats INTEGER NOT NULL DEFAULT 0,
total_claims INTEGER NOT NULL DEFAULT 0,
unique_pubkeys INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS nonces (
nonce TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS deposits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at INTEGER NOT NULL,
amount_sats INTEGER NOT NULL,
source TEXT NOT NULL CHECK(source IN ('lightning','cashu')),
lnbits_payment_hash TEXT
);
CREATE INDEX IF NOT EXISTS idx_claims_pubkey ON claims(pubkey);
CREATE INDEX IF NOT EXISTS idx_claims_claimed_at ON claims(claimed_at);
CREATE INDEX IF NOT EXISTS idx_quotes_expires_at ON quotes(expires_at);
CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status);
CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at);
CREATE INDEX IF NOT EXISTS idx_deposits_lnbits_payment_hash ON deposits(lnbits_payment_hash);

287
backend/src/db/sqlite.ts Normal file
View File

@@ -0,0 +1,287 @@
import Database from "better-sqlite3";
import { readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import type { ClaimRow, Db, DepositSource, IpLimitRow, QuoteRow, UserRow } from "./types.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
export function createSqliteDb(path: string): Db {
const db = new Database(path);
return {
async runMigrations() {
const schema = readFileSync(join(__dirname, "schema.sql"), "utf-8");
db.exec(schema);
try {
db.exec("ALTER TABLE users ADD COLUMN lightning_address TEXT");
} catch (_) {}
try {
db.exec("ALTER TABLE users ADD COLUMN name TEXT");
} catch (_) {}
try {
db.exec(
"CREATE TABLE IF NOT EXISTS deposits (id INTEGER PRIMARY KEY AUTOINCREMENT, created_at INTEGER NOT NULL, amount_sats INTEGER NOT NULL, source TEXT NOT NULL CHECK(source IN ('lightning','cashu')), lnbits_payment_hash TEXT)"
);
db.exec("CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at)");
} catch (_) {}
try {
db.exec("ALTER TABLE deposits ADD COLUMN lnbits_payment_hash TEXT");
} catch (_) {}
try {
db.exec("CREATE INDEX IF NOT EXISTS idx_deposits_lnbits_payment_hash ON deposits(lnbits_payment_hash)");
} catch (_) {}
try {
db.exec(
"UPDATE deposits SET amount_sats = amount_sats / 1000 WHERE source = 'lightning' AND lnbits_payment_hash IS NOT NULL AND amount_sats >= 1000"
);
} catch (_) {}
},
async getUser(pubkey: string): Promise<UserRow | null> {
const row = db.prepare("SELECT * FROM users WHERE pubkey = ?").get(pubkey) as UserRow | undefined;
return row ?? null;
},
async upsertUser(row: Omit<UserRow, "created_at" | "updated_at">): Promise<void> {
const now = Math.floor(Date.now() / 1000);
db.prepare(
`INSERT INTO users (pubkey, nostr_first_seen_at, notes_count, followers_count, following_count, activity_score, last_metadata_fetch_at, lightning_address, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(pubkey) DO UPDATE SET
nostr_first_seen_at = excluded.nostr_first_seen_at,
notes_count = excluded.notes_count,
followers_count = excluded.followers_count,
following_count = excluded.following_count,
activity_score = excluded.activity_score,
last_metadata_fetch_at = excluded.last_metadata_fetch_at,
lightning_address = excluded.lightning_address,
name = excluded.name,
updated_at = excluded.updated_at`
).run(
row.pubkey,
row.nostr_first_seen_at ?? null,
row.notes_count ?? 0,
row.followers_count ?? 0,
row.following_count ?? 0,
row.activity_score ?? 0,
row.last_metadata_fetch_at ?? null,
row.lightning_address ?? null,
row.name ?? null,
now,
now
);
},
async updateUserNostrCache(
pubkey: string,
data: {
nostr_first_seen_at: number | null;
notes_count: number;
followers_count: number;
following_count: number;
activity_score: number;
last_metadata_fetch_at: number;
}
): Promise<void> {
const now = Math.floor(Date.now() / 1000);
db.prepare(
`UPDATE users SET
nostr_first_seen_at = ?, notes_count = ?, followers_count = ?, following_count = ?,
activity_score = ?, last_metadata_fetch_at = ?, updated_at = ?
WHERE pubkey = ?`
).run(
data.nostr_first_seen_at,
data.notes_count,
data.followers_count,
data.following_count,
data.activity_score,
data.last_metadata_fetch_at,
now,
pubkey
);
},
async getLastSuccessfulClaimByPubkey(pubkey: string): Promise<ClaimRow | null> {
const row = db
.prepare("SELECT * FROM claims WHERE pubkey = ? AND status = 'paid' ORDER BY claimed_at DESC LIMIT 1")
.get(pubkey) as ClaimRow | undefined;
return row ?? null;
},
async getLastClaimByIpHash(ipHash: string): Promise<ClaimRow | null> {
const row = db
.prepare("SELECT * FROM claims WHERE ip_hash = ? AND status = 'paid' ORDER BY claimed_at DESC LIMIT 1")
.get(ipHash) as ClaimRow | undefined;
return row ?? null;
},
async getClaimCountForIpSince(ipHash: string, sinceTs: number): Promise<number> {
const row = db
.prepare(
"SELECT COUNT(*) as c FROM claims WHERE ip_hash = ? AND status = 'paid' AND claimed_at >= ?"
)
.get(ipHash, sinceTs) as { c: number };
return row?.c ?? 0;
},
async createClaim(row: Omit<ClaimRow, "id">): Promise<number> {
const result = db
.prepare(
`INSERT INTO claims (pubkey, claimed_at, payout_sats, ip_hash, payout_destination_hash, status, lnbits_payment_hash, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
row.pubkey,
row.claimed_at,
row.payout_sats,
row.ip_hash,
row.payout_destination_hash ?? null,
row.status,
row.lnbits_payment_hash ?? null,
row.error_message ?? null
);
return result.lastInsertRowid as number;
},
async updateClaimStatus(
id: number,
status: ClaimRow["status"],
lnbitsPaymentHash?: string,
errorMessage?: string
): Promise<void> {
db.prepare(
"UPDATE claims SET status = ?, lnbits_payment_hash = ?, error_message = ? WHERE id = ?"
).run(status, lnbitsPaymentHash ?? null, errorMessage ?? null, id);
},
async getIpLimit(ipHash: string): Promise<IpLimitRow | null> {
const row = db.prepare("SELECT * FROM ip_limits WHERE ip_hash = ?").get(ipHash) as IpLimitRow | undefined;
return row ?? null;
},
async upsertIpLimit(ipHash: string, lastClaimedAt: number, claimCountPeriod: number): Promise<void> {
db.prepare(
`INSERT INTO ip_limits (ip_hash, last_claimed_at, claim_count_period) VALUES (?, ?, ?)
ON CONFLICT(ip_hash) DO UPDATE SET last_claimed_at = ?, claim_count_period = ?`
).run(ipHash, lastClaimedAt, claimCountPeriod, lastClaimedAt, claimCountPeriod);
},
async createQuote(quoteId: string, pubkey: string, payoutSats: number, lightningAddress: string, expiresAt: number): Promise<void> {
const now = Math.floor(Date.now() / 1000);
db.prepare(
"INSERT INTO quotes (quote_id, pubkey, payout_sats, lightning_address, created_at, expires_at, status) VALUES (?, ?, ?, ?, ?, ?, 'active')"
).run(quoteId, pubkey, payoutSats, lightningAddress, now, expiresAt);
},
async getQuote(quoteId: string): Promise<QuoteRow | null> {
const row = db.prepare("SELECT * FROM quotes WHERE quote_id = ?").get(quoteId) as QuoteRow | undefined;
return row ?? null;
},
async consumeQuote(quoteId: string): Promise<void> {
db.prepare("UPDATE quotes SET status = 'consumed' WHERE quote_id = ?").run(quoteId);
},
async setNonce(nonce: string, expiresAt: number): Promise<void> {
db.prepare("INSERT OR REPLACE INTO nonces (nonce, expires_at) VALUES (?, ?)").run(nonce, expiresAt);
},
async hasNonce(nonce: string): Promise<boolean> {
const now = Math.floor(Date.now() / 1000);
const row = db.prepare("SELECT 1 FROM nonces WHERE nonce = ? AND expires_at > ?").get(nonce, now);
return !!row;
},
async deleteExpiredNonces(): Promise<void> {
const now = Math.floor(Date.now() / 1000);
db.prepare("DELETE FROM nonces WHERE expires_at <= ?").run(now);
},
async getTotalPaidSats(): Promise<number> {
const row = db
.prepare("SELECT COALESCE(SUM(payout_sats), 0) as total FROM claims WHERE status = 'paid'")
.get() as { total: number };
return row?.total ?? 0;
},
async getTotalClaimsCount(): Promise<number> {
const row = db
.prepare("SELECT COUNT(*) as c FROM claims WHERE status = 'paid'")
.get() as { c: number };
return row?.c ?? 0;
},
async getClaimsCountSince(sinceTs: number): Promise<number> {
const row = db
.prepare("SELECT COUNT(*) as c FROM claims WHERE status = 'paid' AND claimed_at >= ?")
.get(sinceTs) as { c: number };
return row?.c ?? 0;
},
async getPaidSatsSince(sinceTs: number): Promise<number> {
const row = db
.prepare("SELECT COALESCE(SUM(payout_sats), 0) as total FROM claims WHERE status = 'paid' AND claimed_at >= ?")
.get(sinceTs) as { total: number };
return row?.total ?? 0;
},
async getRecentPayouts(
limit: number
): Promise<{ pubkey_prefix: string; payout_sats: number; claimed_at: number }[]> {
const rows = db
.prepare(
"SELECT pubkey, payout_sats, claimed_at FROM claims WHERE status = 'paid' ORDER BY claimed_at DESC LIMIT ?"
)
.all(limit) as { pubkey: string; payout_sats: number; claimed_at: number }[];
return rows.map((r) => ({
pubkey_prefix: r.pubkey.slice(0, 8) + "…",
payout_sats: r.payout_sats,
claimed_at: r.claimed_at,
}));
},
async insertDeposit(
amountSats: number,
source: DepositSource,
lnbitsPaymentHash?: string | null,
createdAt?: number
): Promise<void> {
const now = createdAt ?? Math.floor(Date.now() / 1000);
db.prepare(
"INSERT INTO deposits (created_at, amount_sats, source, lnbits_payment_hash) VALUES (?, ?, ?, ?)"
).run(now, amountSats, source, lnbitsPaymentHash ?? null);
},
async hasDepositWithPaymentHash(paymentHash: string): Promise<boolean> {
const row = db
.prepare("SELECT 1 FROM deposits WHERE lnbits_payment_hash = ? LIMIT 1")
.get(paymentHash);
return !!row;
},
async updateDepositCreatedAtIfMissing(paymentHash: string, createdAt: number): Promise<boolean> {
const result = db
.prepare(
"UPDATE deposits SET created_at = ? WHERE lnbits_payment_hash = ? AND (created_at IS NULL OR created_at < 1000000000)"
)
.run(createdAt, paymentHash);
return result.changes > 0;
},
async getRecentDeposits(
limit: number
): Promise<{ amount_sats: number; source: DepositSource; created_at: number }[]> {
const rows = db
.prepare(
"SELECT amount_sats, source, created_at FROM deposits ORDER BY created_at DESC LIMIT ?"
)
.all(limit) as { amount_sats: number; source: DepositSource; created_at: number }[];
return rows.map((r) => ({
amount_sats: r.amount_sats,
source: r.source as DepositSource,
created_at: r.created_at,
}));
},
};
}

101
backend/src/db/types.ts Normal file
View File

@@ -0,0 +1,101 @@
export interface UserRow {
pubkey: string;
nostr_first_seen_at: number | null;
notes_count: number;
followers_count: number;
following_count: number;
activity_score: number;
last_metadata_fetch_at: number | null;
lightning_address: string | null;
name: string | null;
created_at: number;
updated_at: number;
}
export interface ClaimRow {
id: number;
pubkey: string;
claimed_at: number;
payout_sats: number;
ip_hash: string;
payout_destination_hash: string | null;
status: "pending" | "paid" | "failed";
lnbits_payment_hash: string | null;
error_message: string | null;
}
export interface QuoteRow {
quote_id: string;
pubkey: string;
payout_sats: number;
lightning_address: string | null;
created_at: number;
expires_at: number;
status: "active" | "consumed" | "expired";
}
export interface IpLimitRow {
ip_hash: string;
last_claimed_at: number;
claim_count_period: number;
}
export type DepositSource = "lightning" | "cashu";
export interface DepositRow {
id: number;
created_at: number;
amount_sats: number;
source: DepositSource;
}
export interface Db {
runMigrations(): Promise<void>;
getUser(pubkey: string): Promise<UserRow | null>;
upsertUser(row: Omit<UserRow, "created_at" | "updated_at">): Promise<void>;
updateUserNostrCache(
pubkey: string,
data: {
nostr_first_seen_at: number | null;
notes_count: number;
followers_count: number;
following_count: number;
activity_score: number;
last_metadata_fetch_at: number;
}
): Promise<void>;
getLastSuccessfulClaimByPubkey(pubkey: string): Promise<ClaimRow | null>;
getLastClaimByIpHash(ipHash: string): Promise<ClaimRow | null>;
getClaimCountForIpSince(ipHash: string, sinceTs: number): Promise<number>;
createClaim(row: Omit<ClaimRow, "id">): Promise<number>;
updateClaimStatus(id: number, status: ClaimRow["status"], lnbitsPaymentHash?: string, errorMessage?: string): Promise<void>;
getIpLimit(ipHash: string): Promise<IpLimitRow | null>;
upsertIpLimit(ipHash: string, lastClaimedAt: number, claimCountPeriod: number): Promise<void>;
createQuote(quoteId: string, pubkey: string, payoutSats: number, lightningAddress: string, expiresAt: number): Promise<void>;
getQuote(quoteId: string): Promise<QuoteRow | null>;
consumeQuote(quoteId: string): Promise<void>;
setNonce(nonce: string, expiresAt: number): Promise<void>;
hasNonce(nonce: string): Promise<boolean>;
deleteExpiredNonces(): Promise<void>;
getTotalPaidSats(): Promise<number>;
getTotalClaimsCount(): Promise<number>;
getClaimsCountSince(sinceTs: number): Promise<number>;
getPaidSatsSince(sinceTs: number): Promise<number>;
getRecentPayouts(limit: number): Promise<{ pubkey_prefix: string; payout_sats: number; claimed_at: number }[]>;
insertDeposit(
amountSats: number,
source: DepositSource,
lnbitsPaymentHash?: string | null,
createdAt?: number
): Promise<void>;
hasDepositWithPaymentHash(paymentHash: string): Promise<boolean>;
updateDepositCreatedAtIfMissing(paymentHash: string, createdAt: number): Promise<boolean>;
getRecentDeposits(limit: number): Promise<{ amount_sats: number; source: DepositSource; created_at: number }[]>;
}

64
backend/src/index.ts Normal file
View File

@@ -0,0 +1,64 @@
import express from "express";
import cors from "cors";
import rateLimit from "express-rate-limit";
import { config } from "./config.js";
import { getDb } from "./db/index.js";
import { startLnbitsDepositSync } from "./services/syncLnbitsDeposits.js";
import publicRoutes from "./routes/public.js";
import authRoutes from "./routes/auth.js";
import claimRoutes from "./routes/claim.js";
import userRoutes from "./routes/user.js";
async function main() {
const db = getDb();
await db.runMigrations();
db.deleteExpiredNonces().catch(() => {});
const app = express();
if (config.trustProxy) app.set("trust proxy", 1);
app.use(express.json({ limit: "10kb" }));
app.use(
cors({
origin: (origin, cb) => {
if (!origin) return cb(null, true);
if (origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:")) return cb(null, true);
if (config.allowedOrigins.includes(origin)) return cb(null, true);
return cb(null, false);
},
credentials: true,
})
);
app.use("/", publicRoutes);
app.use("/auth", authRoutes);
app.use(
"/claim",
rateLimit({
windowMs: 60 * 1000,
max: 20,
message: { code: "rate_limited", message: "Too many requests." },
}),
claimRoutes
);
app.use(
"/user",
rateLimit({
windowMs: 60 * 1000,
max: 30,
message: { code: "rate_limited", message: "Too many requests." },
}),
userRoutes
);
app.listen(config.port, () => {
console.log(`Faucet API listening on port ${config.port}`);
if (config.lnbitsBaseUrl && config.lnbitsAdminKey) {
startLnbitsDepositSync();
}
});
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,22 @@
import { Request, Response, NextFunction } from "express";
import { verifyJwt } from "../auth/jwt.js";
import { nip98Auth } from "./nip98.js";
const BEARER_PREFIX = "Bearer ";
/**
* Accept either Bearer JWT or NIP-98. Sets req.nostr = { pubkey }.
*/
export function authOrNip98(req: Request, res: Response, next: NextFunction): void {
const auth = req.headers.authorization;
if (auth?.startsWith(BEARER_PREFIX)) {
const token = auth.slice(BEARER_PREFIX.length).trim();
const payload = verifyJwt(token);
if (payload) {
req.nostr = { pubkey: payload.pubkey, eventId: "" };
next();
return;
}
}
nip98Auth(req, res, next);
}

View File

@@ -0,0 +1,40 @@
import { Request, Response, NextFunction } from "express";
import { createHmac } from "crypto";
import { config } from "../config.js";
declare global {
namespace Express {
interface Request {
ipHash?: string;
}
}
}
/**
* Resolve client IP: X-Forwarded-For (first hop) when TRUST_PROXY, else socket remoteAddress.
*/
function getClientIp(req: Request): string {
if (config.trustProxy) {
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) {
const first = typeof forwarded === "string" ? forwarded.split(",")[0] : forwarded[0];
const ip = first?.trim();
if (ip) return ip;
}
}
const addr = req.socket?.remoteAddress;
return addr ?? "0.0.0.0";
}
/**
* HMAC-SHA256(ip, HMAC_IP_SECRET) as hex.
*/
function hmacIp(ip: string): string {
return createHmac("sha256", config.hmacIpSecret).update(ip).digest("hex");
}
export function ipHashMiddleware(req: Request, _res: Response, next: NextFunction): void {
const ip = getClientIp(req);
req.ipHash = hmacIp(ip);
next();
}

View File

@@ -0,0 +1,153 @@
import { Request, Response, NextFunction } from "express";
import { verifyEvent, getEventHash } from "nostr-tools";
import { config } from "../config.js";
import { getDb } from "../db/index.js";
const AUTH_SCHEME = "Nostr ";
export interface NostrAuthPayload {
pubkey: string;
eventId: string;
}
declare global {
namespace Express {
interface Request {
nostr?: NostrAuthPayload;
}
}
}
function getTag(event: { tags: string[][] }, name: string): string | null {
const row = event.tags.find((t) => t[0] === name);
return row && row[1] ? row[1] : null;
}
export async function nip98Auth(req: Request, res: Response, next: NextFunction): Promise<void> {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith(AUTH_SCHEME)) {
res.status(401).json({
code: "invalid_nip98",
message: "Missing or invalid Authorization header. Use NIP-98 Nostr scheme.",
});
return;
}
const base64 = auth.slice(AUTH_SCHEME.length).trim();
let event: { id: string; pubkey: string; kind: number; created_at: number; tags: string[][]; content: string; sig: string };
try {
const decoded = Buffer.from(base64, "base64").toString("utf-8");
const parsed = JSON.parse(decoded) as Record<string, unknown>;
event = {
id: String(parsed.id ?? ""),
pubkey: String(parsed.pubkey ?? ""),
kind: Number(parsed.kind),
created_at: Number(parsed.created_at),
tags: Array.isArray(parsed.tags) ? (parsed.tags as string[][]) : [],
content: typeof parsed.content === "string" ? parsed.content : "",
sig: String(parsed.sig ?? ""),
};
} catch {
res.status(401).json({
code: "invalid_nip98",
message: "Invalid NIP-98 payload: not valid base64 or JSON.",
});
return;
}
if (event.kind !== 27235) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 event kind must be 27235.",
});
return;
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(event.created_at - now) > config.nip98MaxSkewSeconds) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 event timestamp is outside allowed window.",
});
return;
}
const u = getTag(event, "u");
const method = getTag(event, "method");
if (!u || !method) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 event must include 'u' and 'method' tags.",
});
return;
}
// Reconstruct absolute URL (protocol + host + path + query)
const proto = req.headers["x-forwarded-proto"] ?? (req.socket as { encrypted?: boolean }).encrypted ? "https" : "http";
const host = req.headers["x-forwarded-host"] ?? req.headers.host ?? "";
const path = req.originalUrl ?? req.url;
const absoluteUrl = `${proto}://${host}${path}`;
if (u !== absoluteUrl) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 'u' tag does not match request URL.",
});
return;
}
if (method.toUpperCase() !== req.method) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 'method' tag does not match request method.",
});
return;
}
const computedId = getEventHash({
kind: event.kind,
pubkey: event.pubkey,
created_at: event.created_at,
tags: event.tags,
content: event.content,
sig: event.sig,
} as Parameters<typeof getEventHash>[0]);
if (computedId !== event.id) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 event id does not match computed hash.",
});
return;
}
const valid = verifyEvent({
id: event.id,
kind: event.kind,
pubkey: event.pubkey,
created_at: event.created_at,
tags: event.tags,
content: event.content,
sig: event.sig,
} as Parameters<typeof verifyEvent>[0]);
if (!valid) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 signature verification failed.",
});
return;
}
const db = getDb();
const hasNonce = await db.hasNonce(event.id);
if (hasNonce) {
res.status(401).json({
code: "invalid_nip98",
message: "NIP-98 nonce already used (replay).",
});
return;
}
const expiresAt = now + config.nonceTtlSeconds;
await db.setNonce(event.id, expiresAt);
req.nostr = { pubkey: event.pubkey, eventId: event.id };
next();
}

View File

@@ -0,0 +1,29 @@
import { Router, Request, Response } from "express";
import { nip98Auth } from "../middleware/nip98.js";
import { signJwt, verifyJwt } from "../auth/jwt.js";
const router = Router();
/** Sign in with NIP-98 once; returns a JWT for subsequent requests. */
router.post("/login", nip98Auth, (req: Request, res: Response) => {
const pubkey = req.nostr!.pubkey;
const token = signJwt(pubkey);
res.json({ token, pubkey });
});
/** Return current user from JWT (Bearer only). Used to restore session. */
router.get("/me", (req: Request, res: Response) => {
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) {
res.status(401).json({ code: "unauthorized", message: "Bearer token required." });
return;
}
const payload = verifyJwt(auth.slice(7).trim());
if (!payload) {
res.status(401).json({ code: "invalid_token", message: "Invalid or expired token." });
return;
}
res.json({ pubkey: payload.pubkey });
});
export default router;

159
backend/src/routes/claim.ts Normal file
View File

@@ -0,0 +1,159 @@
import { Router, Request, Response } from "express";
import { createHmac } from "crypto";
import { config } from "../config.js";
import { getDb } from "../db/index.js";
import { checkEligibility } from "../services/eligibility.js";
import { createQuote } from "../services/quote.js";
import { payToLightningAddress } from "../services/lnbits.js";
import { authOrNip98 } from "../middleware/auth.js";
import { ipHashMiddleware } from "../middleware/ip.js";
const router = Router();
router.use(ipHashMiddleware);
function hashDestination(lightningAddress: string): string {
return createHmac("sha256", config.hmacIpSecret).update(lightningAddress).digest("hex");
}
function parseLightningAddress(body: unknown): string | null {
if (body && typeof body === "object" && "lightning_address" in body && typeof (body as { lightning_address: unknown }).lightning_address === "string") {
const v = (body as { lightning_address: string }).lightning_address.trim();
if (/^[^@]+@[^@]+$/.test(v)) return v;
}
return null;
}
router.post("/quote", authOrNip98, async (req: Request, res: Response) => {
const pubkey = req.nostr!.pubkey;
const ipHash = req.ipHash!;
const lightningAddress = parseLightningAddress(req.body);
if (!lightningAddress) {
res.status(400).json({
code: "invalid_lightning_address",
message: "Valid lightning_address (user@domain) is required.",
});
return;
}
const eligibility = await checkEligibility(pubkey, ipHash);
if (!eligibility.eligible) {
res.status(403).json({
code: eligibility.denialCode,
message: eligibility.denialMessage,
next_eligible_at: eligibility.nextEligibleAt,
});
return;
}
const quote = await createQuote(pubkey, lightningAddress);
if (!quote) {
res.status(403).json({
code: "daily_budget_exceeded",
message: "Daily budget reached. Try again tomorrow.",
});
return;
}
res.json({
quote_id: quote.quoteId,
payout_sats: quote.payoutSats,
expires_at: quote.expiresAt,
});
});
router.post("/confirm", authOrNip98, async (req: Request, res: Response) => {
const pubkey = req.nostr!.pubkey;
const ipHash = req.ipHash!;
const quoteId = typeof req.body?.quote_id === "string" ? req.body.quote_id.trim() : null;
if (!quoteId) {
res.status(400).json({
code: "invalid_request",
message: "quote_id is required.",
});
return;
}
const db = getDb();
const quote = await db.getQuote(quoteId);
if (!quote) {
res.status(404).json({
code: "quote_expired",
message: "Quote not found or expired.",
});
return;
}
if (quote.pubkey !== pubkey) {
res.status(403).json({
code: "invalid_nip98",
message: "Quote does not belong to this pubkey.",
});
return;
}
if (quote.status !== "active") {
res.status(200).json({
success: true,
already_consumed: true,
message: "This quote was already used.",
payout_sats: quote.payout_sats,
next_eligible_at: undefined,
});
return;
}
const now = Math.floor(Date.now() / 1000);
if (quote.expires_at < now) {
res.status(400).json({
code: "quote_expired",
message: "Quote has expired.",
});
return;
}
const lightningAddress = quote.lightning_address;
if (!lightningAddress) {
res.status(400).json({
code: "invalid_lightning_address",
message: "Quote has no payout address.",
});
return;
}
const claimId = await db.createClaim({
pubkey: quote.pubkey,
claimed_at: now,
payout_sats: quote.payout_sats,
ip_hash: ipHash,
payout_destination_hash: hashDestination(lightningAddress),
status: "pending",
lnbits_payment_hash: null,
error_message: null,
});
try {
const { paymentHash } = await payToLightningAddress(lightningAddress, quote.payout_sats);
await db.updateClaimStatus(claimId, "paid", paymentHash);
await db.consumeQuote(quoteId);
const cooldownEnd = now + config.cooldownDays * 86400;
const ipSince = now - config.ipCooldownDays * 86400;
const ipCount = await db.getClaimCountForIpSince(ipHash, ipSince);
await db.upsertIpLimit(ipHash, now, ipCount);
res.json({
success: true,
payout_sats: quote.payout_sats,
next_eligible_at: cooldownEnd,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Payment failed";
const stack = err instanceof Error ? err.stack : undefined;
console.error("[claim/confirm] Lightning payment failed:", message);
if (stack) console.error("[claim/confirm] Stack:", stack);
await db.updateClaimStatus(claimId, "failed", undefined, message);
res.status(502).json({
code: "payout_failed",
message: "Lightning payment failed. Your cooldown was not applied.",
details: message,
});
}
});
export default router;

View File

@@ -0,0 +1,134 @@
import { Router, Request, Response } from "express";
import { config } from "../config.js";
import { getDb } from "../db/index.js";
import { getWalletBalanceSats } from "../services/lnbits.js";
const router = Router();
router.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok" });
});
router.get("/config", (_req: Request, res: Response) => {
res.json({
faucetEnabled: config.faucetEnabled,
emergencyStop: config.emergencyStop,
cooldownDays: config.cooldownDays,
minAccountAgeDays: config.minAccountAgeDays,
minActivityScore: config.minActivityScore,
faucetMinSats: config.faucetMinSats,
faucetMaxSats: config.faucetMaxSats,
});
});
router.get("/stats", async (_req: Request, res: Response) => {
try {
const db = getDb();
const [balance, totalPaid, totalClaims, claims24h, recent, recentDeposits] = await Promise.all([
getWalletBalanceSats().catch(() => 0),
db.getTotalPaidSats(),
db.getTotalClaimsCount(),
db.getClaimsCountSince(Math.floor(Date.now() / 1000) - 86400),
db.getRecentPayouts(20),
db.getRecentDeposits(20),
]);
res.json({
balanceSats: balance,
totalPaidSats: totalPaid,
totalClaims,
claimsLast24h: claims24h,
dailyBudgetSats: config.dailyBudgetSats,
recentPayouts: recent,
recentDeposits,
});
} catch (e) {
res.status(500).json({
code: "internal_error",
message: "Failed to load stats",
});
}
});
router.get("/deposit", (_req: Request, res: Response) => {
res.json({
lightningAddress: config.depositLightningAddress,
lnurlp: config.depositLnurlp,
});
});
router.post("/deposit/redeem-cashu", async (req: Request, res: Response) => {
const token = typeof req.body?.token === "string" ? req.body.token.trim() : null;
if (!token || !token.toLowerCase().startsWith("cashu")) {
res.status(400).json({
success: false,
error: "Valid Cashu token (cashuA... or cashuB...) is required.",
});
return;
}
const lightningAddress = config.depositLightningAddress;
if (!lightningAddress || !/^[^@]+@[^@]+$/.test(lightningAddress)) {
res.status(503).json({
success: false,
error: "Faucet deposit Lightning address is not configured.",
});
return;
}
const redeemUrl = `${config.cashuRedeemApiUrl}/api/redeem`;
try {
const redeemRes = await fetch(redeemUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, lightningAddress }),
});
const data = (await redeemRes.json().catch(() => ({}))) as {
success?: boolean;
error?: string;
errorType?: string;
paid?: boolean;
amount?: number;
invoiceAmount?: number;
to?: string;
netAmount?: number;
message?: string;
};
if (!redeemRes.ok) {
const status = redeemRes.status >= 500 ? 502 : redeemRes.status;
res.status(status).json({
success: false,
error: data.error ?? `Redeem failed: ${redeemRes.status}`,
...(data.errorType && { errorType: data.errorType }),
});
return;
}
if (!data.success) {
res.status(400).json({
success: false,
error: data.error ?? "Redeem failed",
...(data.errorType && { errorType: data.errorType }),
});
return;
}
const amountSats = typeof data.amount === "number" && data.amount > 0 ? data.amount : data.netAmount;
if (typeof amountSats === "number" && amountSats > 0) {
getDb().insertDeposit(amountSats, "cashu").catch((err) => console.error("[deposit] record deposit", err));
}
res.json({
success: true,
paid: data.paid,
amount: data.amount,
invoiceAmount: data.invoiceAmount,
netAmount: data.netAmount,
to: data.to ?? lightningAddress,
message: data.message,
});
} catch (e) {
const message = e instanceof Error ? e.message : "Redeem request failed";
console.error("[deposit/redeem-cashu]", message);
res.status(502).json({
success: false,
error: message,
});
}
});
export default router;

View File

@@ -0,0 +1,30 @@
import { Router, Request, Response } from "express";
import { getDb } from "../db/index.js";
import { fetchAndScorePubkey } from "../services/nostr.js";
import { authOrNip98 } from "../middleware/auth.js";
const router = Router();
/**
* Refresh Nostr profile (kind 0) and return cached lightning_address and name.
* Pre-fills the frontend and stores in DB.
*/
router.post("/refresh-profile", authOrNip98, async (req: Request, res: Response) => {
const pubkey = req.nostr!.pubkey;
try {
await fetchAndScorePubkey(pubkey, true);
const db = getDb();
const user = await db.getUser(pubkey);
res.json({
lightning_address: user?.lightning_address ?? null,
name: user?.name ?? null,
});
} catch (e) {
res.status(500).json({
code: "profile_fetch_failed",
message: e instanceof Error ? e.message : "Failed to fetch profile",
});
}
});
export default router;

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);
}

17
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,267 @@
# frontend_overview.md
## 1. Purpose
This document defines the complete frontend architecture, user experience, and interaction model for the Sats Faucet application.
The frontend must:
* Provide a simple, clean, and trustworthy interface.
* Integrate Nostr login (NIP-07 or external signer).
* Guide users through eligibility check and claim confirmation.
* Display transparent faucet statistics and funding information.
* Clearly communicate rules, cooldowns, and denial reasons.
The frontend must never expose secrets or LNbits keys.
---
## 2. Tech Stack Requirements
Recommended:
* Framework: React (Next.js preferred) or similar SPA framework
* Styling: TailwindCSS or clean minimal CSS system
* QR generation library for Lightning deposit QR
* Nostr integration via NIP-07 browser extension (window.nostr)
Frontend must be deployable as static site or server-rendered app.
---
## 3. Global App Structure
### 3.1 Pages
1. Home Page (/)
2. Claim Modal or Claim Section
3. Transparency / Stats Section
4. Deposit Section
5. Optional: About / Rules page
Navigation should be minimal and focused.
---
## 4. Nostr Authentication
### 4.1 Connect Flow
* User clicks "Connect Nostr".
* Frontend requests pubkey via NIP-07.
* Pubkey stored in memory (not localStorage unless necessary).
* Show truncated pubkey (npub format preferred).
### 4.2 NIP-98 Signing
For protected API calls:
* Build request payload (method + URL + timestamp + nonce).
* Request signature via NIP-07.
* Attach NIP-98 header to backend request.
Frontend must:
* Generate secure nonce.
* Handle signature errors gracefully.
---
## 5. Home Page Layout
### 5.1 Hero Section
* Title: Sats Faucet
* Short description of rules
* Claim button
### 5.2 Live Stats Section
Display:
* Current pool balance
* Total sats paid
* Total claims
* Claims in last 24h
* Daily budget progress bar
Stats pulled from GET /stats.
Must auto-refresh every 3060 seconds.
---
## 6. Claim Flow UI
### 6.1 Step 1: Connect
If not connected:
* Disable claim button.
* Prompt user to connect Nostr.
### 6.2 Step 2: Enter Lightning Address
Input field:
* Validate basic format (user@domain).
* Do not over-validate client-side.
### 6.3 Step 3: Quote
On "Check Eligibility":
* Send POST /claim/quote with NIP-98.
Possible responses:
Eligible:
* Show payout amount.
* Show Confirm button.
Not eligible:
* Show clear message from backend.
* If cooldown, show next eligible date.
* If account too new, show required age.
### 6.4 Step 4: Confirm
On confirm:
* Call POST /claim/confirm.
* Show loading state.
* On success:
* Show payout amount.
* Show next eligible time.
* On failure:
* Show clear error.
Must prevent double-click submission.
---
## 7. Deposit Section
Display:
* Lightning Address (copyable).
* QR code of LNURL.
* Optional: "Create invoice" button.
Copy buttons required for:
* Lightning address
* LNURL
Deposit section must feel transparent and trustworthy.
---
## 8. Transparency Section
Display:
* Recent payouts (anonymized pubkey prefix).
* Payout amount.
* Timestamp.
Optional:
* Distribution breakdown (small/medium/large payouts).
---
## 9. Error Handling UX
Frontend must handle:
* Network failures.
* Signature rejection.
* Expired quote.
* Payout failure.
All errors must be displayed in a clean alert component.
Do not expose internal error stack traces.
---
## 10. State Management
Minimal state required:
* pubkey
* lightning_address
* eligibility result
* quote_id
* payout_sats
* next_eligible_at
Use React state or lightweight state manager.
No need for complex global store.
---
## 11. Security Requirements
Frontend must:
* Never store secrets.
* Never expose LNbits keys.
* Never trust client-side eligibility logic.
* Rely entirely on backend for final validation.
If Turnstile or CAPTCHA enabled:
* Include widget only when backend indicates required.
---
## 12. Design Principles
* Minimal, modern UI.
* Clear visual feedback.
* No clutter.
* Show transparency clearly.
* Make it feel fair and legitimate.
Recommended style:
* Dark mode default.
* Bitcoin-inspired accent color.
* Clean typography.
---
## 13. Performance Requirements
* Fast initial load.
* Avoid blocking Nostr relay calls on frontend.
* All relay interaction handled by backend.
---
## 14. Accessibility
* Buttons must have disabled states.
* Inputs labeled clearly.
* QR accessible with copy option.
---
## 15. Completion Criteria
Frontend is complete when:
* User can connect via NIP-07.
* Eligibility check works and displays correct messages.
* Confirm claim triggers payout.
* Deposit Lightning address and QR visible.
* Live stats update correctly.
* Cooldown and denial reasons clearly displayed.
* No sensitive data exposed in frontend code.

359
context/backend_overview.md Normal file
View File

@@ -0,0 +1,359 @@
# backend_overview.md
## 1. Purpose
This document defines the complete backend architecture and behavior for the Sats Faucet application.
The backend is responsible for:
* Verifying NIP-98 signed authentication.
* Enforcing all eligibility rules.
* Fetching and caching Nostr account data.
* Enforcing cooldowns and anti-abuse constraints.
* Generating and locking entropy payout quotes.
* Executing Lightning payouts via LNbits.
* Maintaining transparency statistics.
* Providing operational safety controls.
This backend must be production-safe, abuse-resistant, and configurable entirely via environment variables.
---
## 2. Tech Stack Requirements
Recommended stack:
* Language: Go, TypeScript (Node/Express), or Python (FastAPI)
* Database: PostgreSQL
* Optional: Redis (rate limiting + nonce replay protection)
* LNbits for Lightning payments
* Reverse proxy with TLS (nginx or Caddy)
The backend must be stateless except for database persistence.
---
## 3. Environment Variables
All logic must be driven via .env configuration.
Key categories:
### 3.1 Security
* JWT_SECRET
* HMAC_IP_SECRET
* NIP98_MAX_SKEW_SECONDS
* NONCE_TTL_SECONDS
* TRUST_PROXY
* ALLOWED_ORIGINS
### 3.2 Faucet Economics
* FAUCET_ENABLED
* EMERGENCY_STOP
* FAUCET_MIN_SATS
* FAUCET_MAX_SATS
* PAYOUT_WEIGHT_SMALL
* PAYOUT_WEIGHT_MEDIUM
* PAYOUT_WEIGHT_LARGE
* PAYOUT_WEIGHT_JACKPOT
* PAYOUT_SMALL_SATS
* PAYOUT_MEDIUM_SATS
* PAYOUT_LARGE_SATS
* PAYOUT_JACKPOT_SATS
* DAILY_BUDGET_SATS
* MAX_CLAIMS_PER_DAY
* MIN_WALLET_BALANCE_SATS
### 3.3 Eligibility
* MIN_ACCOUNT_AGE_DAYS
* MIN_ACTIVITY_SCORE
* MIN_NOTES_COUNT
* MIN_FOLLOWING_COUNT
* MIN_FOLLOWERS_COUNT
* ACTIVITY_LOOKBACK_DAYS
### 3.4 Cooldowns
* COOLDOWN_DAYS
* IP_COOLDOWN_DAYS
* MAX_CLAIMS_PER_IP_PER_PERIOD
### 3.5 Nostr
* NOSTR_RELAYS
* RELAY_TIMEOUT_MS
* MAX_EVENTS_FETCH
* METADATA_CACHE_HOURS
### 3.6 LNbits
* LNBITS_BASE_URL
* LNBITS_ADMIN_KEY
* LNBITS_WALLET_ID
* DEPOSIT_LIGHTNING_ADDRESS
* DEPOSIT_LNURLP
---
## 4. Database Schema
### 4.1 users
* pubkey (PK)
* nostr_first_seen_at
* notes_count
* followers_count
* following_count
* activity_score
* last_metadata_fetch_at
* created_at
* updated_at
### 4.2 claims
* id (PK)
* pubkey (FK users.pubkey)
* claimed_at
* payout_sats
* ip_hash
* payout_destination_hash
* status (pending, paid, failed)
* lnbits_payment_hash
* error_message
### 4.3 ip_limits
* ip_hash (PK)
* last_claimed_at
* claim_count_period
### 4.4 quotes
* quote_id (PK)
* pubkey
* payout_sats
* created_at
* expires_at
* status (active, consumed, expired)
### 4.5 daily_stats (optional)
* date (PK)
* total_paid_sats
* total_claims
* unique_pubkeys
All IP addresses must be stored as HMAC(IP, HMAC_IP_SECRET).
---
## 5. NIP-98 Authentication
Every protected endpoint must:
1. Extract NIP-98 header or payload.
2. Verify signature against pubkey.
3. Verify HTTP method and URL match signed payload.
4. Verify timestamp within allowed skew.
5. Verify nonce not previously used.
6. Reject if invalid.
Nonces must be stored in Redis or DB for NONCE_TTL_SECONDS.
---
## 6. IP Resolution
If TRUST_PROXY=true:
* Read first valid IP from X-Forwarded-For.
Else:
* Use request remote address.
Then:
* Hash IP using HMAC_IP_SECRET.
* Never store raw IP.
---
## 7. Eligibility Engine
Eligibility flow:
1. Check FAUCET_ENABLED.
2. Check EMERGENCY_STOP.
3. Check LNbits wallet balance >= MIN_WALLET_BALANCE_SATS.
4. Check pubkey cooldown.
5. Check IP cooldown.
6. Fetch or load cached Nostr profile.
7. Compute account age.
8. Compute activity score.
9. Compare with thresholds.
Return structured result with denial code if failed.
---
## 8. Nostr Data Fetching
For a given pubkey:
1. Query relays in parallel.
2. Fetch:
* kind 0 (metadata)
* kind 1 (notes)
* kind 3 (contacts)
3. Compute:
* earliest created_at
* notes count in ACTIVITY_LOOKBACK_DAYS
* following count
Cache results for METADATA_CACHE_HOURS.
If no events found:
* Deny as account_too_new.
---
## 9. Activity Scoring
Example scoring logic:
* Has metadata: +10
* Notes >= MIN_NOTES_COUNT: +20
* Following >= MIN_FOLLOWING_COUNT: +10
* Followers >= MIN_FOLLOWERS_COUNT: +10
Score must be deterministic and logged.
---
## 10. Entropy Payout System
Weighted random selection:
1. Build array based on configured weights.
2. Generate secure random number.
3. Select payout bucket.
Before finalizing quote:
* Check daily spend.
* Check MAX_CLAIMS_PER_DAY.
If budget exceeded:
* Either deny or downgrade payout to FAUCET_MIN_SATS.
---
## 11. Claim Flow
### 11.1 POST /claim/quote
Input:
* lightning_address
Steps:
* Run eligibility engine.
* If eligible, generate payout.
* Insert quote record with expiry (e.g., 60 seconds).
* Return quote_id and payout_sats.
### 11.2 POST /claim/confirm
Input:
* quote_id
Steps:
* Verify quote exists and active.
* Re-check cooldown and budget.
* Execute LNbits payment.
* Update claim record.
* Mark quote consumed.
Must be idempotent.
---
## 12. LNbits Integration
Payout flow:
1. Resolve Lightning address to LNURL.
2. Fetch LNURL payRequest.
3. Call callback with amount in millisats.
4. Handle success/failure response.
5. Store payment hash.
On failure:
* Mark claim failed.
* Do not lock cooldown unless configured.
---
## 13. Public Endpoints
GET /health
GET /config
GET /stats
GET /deposit
No authentication required.
---
## 14. Logging and Monitoring
Each claim attempt must log:
* pubkey
* ip_hash
* eligibility result
* payout amount
* payment status
Metrics to track:
* denial reasons count
* payout distribution
* daily spend
---
## 15. Security Hard Requirements
* Strict CORS
* Rate limit /claim endpoints
* Nonce replay protection
* HMAC IP hashing
* Admin keys never exposed
* All secrets loaded from env
---
## 16. Production Readiness Checklist
Backend is complete when:
* NIP-98 auth fully verified.
* Pubkey and IP cooldown enforced.
* Account age check enforced.
* Activity score enforced.
* Entropy payout cannot be rerolled.
* Daily budget cannot be exceeded.
* LNbits payout works and errors handled safely.
* Emergency stop disables claims instantly.
* Logs clearly show denial reasons.

386
context/overview.md Normal file
View File

@@ -0,0 +1,386 @@
# Sats Faucet App
## 1. Purpose
Build a sats faucet web app that lets Nostr users claim a small, randomized sats payout (entropy payout) on a cooldown schedule, with strong anti-abuse protections.
Key goals:
* Fun community experiment that can evolve into a long-term public faucet.
* Simple UX: connect Nostr, enter Lightning address, claim.
* Abuse-resistant: NIP-98 signed requests, account age checks, activity scoring, per-pubkey cooldown, per-IP cooldown, and budget guards.
* Transparent: public stats and recent payouts (anonymized).
* Sustainable: daily budget cap, emergency stop switch, and minimum wallet balance guard.
Non-goals:
* Perfect Sybil resistance. The system raises the cost of abuse rather than claiming to fully prevent it.
## 2. Core Requirements
### 2.1 Claim rules
A user may claim only if all conditions are true:
* Authenticated via Nostr using NIP-98 signature on the claim request.
* Account age is at least MIN_ACCOUNT_AGE_DAYS (default 14 days), based on earliest observed event timestamp on configured relays.
* Activity score meets or exceeds MIN_ACTIVITY_SCORE.
* Pubkey has not successfully claimed within the last COOLDOWN_DAYS (default 7 days).
* IP has not successfully claimed within the last IP_COOLDOWN_DAYS (default 7 days) and under MAX_CLAIMS_PER_IP_PER_PERIOD.
* Faucet is enabled and not in emergency stop.
* LNbits wallet balance is at or above MIN_WALLET_BALANCE_SATS.
* Daily budget not exceeded.
### 2.2 Payout
* Payout uses weighted randomness (entropy) configured by environment variables.
* Reward amount is fixed per bucket (example buckets: 10, 25, 50, 100 sats), selected by weight.
* The payout selected for a claim is locked to a short-lived quote to prevent re-rolling by refreshing.
* Payout is sent via LNbits to a Lightning address provided by the user.
### 2.3 Deposits
* The app must display a deposit Lightning address and QR for community funding.
* Preferred: LNURLp/Lightning Address for deposits (static address and QR).
* Optional: allow users to create an invoice for a chosen deposit amount.
### 2.4 Transparency
Publicly visible:
* Current faucet pool balance (from LNbits).
* Total sats paid.
* Total claim count.
* Claims in last 24h.
* Daily budget and daily spend.
* Recent claims list (anonymized, limited number).
## 3. System Overview
### 3.1 Components
1. Frontend Web App
* Connect Nostr (NIP-07 or external signer).
* Collect Lightning address.
* Calls backend to get a quote and confirm claim.
* Shows eligibility failures with friendly reasons and next eligible time.
* Shows deposit info and public transparency stats.
2. Faucet Backend API
* Validates NIP-98 signed requests.
* Resolves client IP safely (with proxy support).
* Enforces cooldown locks and rate limiting.
* Fetches Nostr metadata + activity from relays and caches it.
* Computes account age and activity score.
* Computes payout quote, enforces daily budget, executes LNbits payout.
* Records claims, payout results, and stats.
3. LNbits
* Holds the faucet wallet.
* Executes outgoing payments.
* Provides deposit address (Lightning Address/LNURLp).
### 3.2 Trust boundaries
* LNbits keys live only on the backend.
* Frontend never sees LNbits keys.
* Backend stores only HMAC-hashed IPs, not raw IPs.
* Lightning address should not be stored in plaintext unless explicitly desired; store a hash for privacy.
## 4. User Experience
### 4.1 Home page
* Primary call-to-action: Claim sats.
* Deposit section:
* Show DEPOSIT_LIGHTNING_ADDRESS.
* Show QR code for DEPOSIT_LNURLP (or encoded LNURL).
* Optional button: Create deposit invoice.
* Live stats:
* Balance, total paid, total claims, last 24h.
* Recent payouts list.
* Rules summary:
* Cooldown days, min age, min score, per-IP limit.
### 4.2 Claim flow
1. User clicks Connect Nostr.
2. User enters Lightning address.
3. User clicks Check / Quote.
4. App shows:
* If ineligible: exact reason and next eligible time.
* If eligible: payout amount and Confirm button.
5. User confirms.
6. App shows success state:
* payout amount
* status
* next eligible date
UI must clearly separate:
* Eligibility check
* Payment attempt
## 5. Backend Behavior
### 5.1 NIP-98 authentication
The backend must verify each protected request:
* Signature is valid for the provided pubkey.
* Signed payload includes correct method and URL.
* Timestamp is within NIP98_MAX_SKEW_SECONDS.
* Nonce is present and not reused (store for NONCE_TTL_SECONDS).
### 5.2 IP handling
* Backend must support TRUST_PROXY mode.
* When TRUST_PROXY=true, derive IP from X-Forwarded-For correctly.
* Hash IP using HMAC_IP_SECRET and store only the hash.
### 5.3 Eligibility engine
For a given pubkey and ip_hash:
* Check emergency stop and faucet enabled flags.
* Check wallet balance guard.
* Check per-pubkey cooldown: latest successful claim timestamp.
* Check per-IP cooldown: latest successful claim timestamp and quota.
* Fetch or load cached Nostr profile and activity metrics.
* Verify account age and activity score.
Return structured result:
* eligible: boolean
* denial_reason: enum
* denial_message: user-friendly text
* next_eligible_at: timestamp if applicable
### 5.4 Quote then confirm
Use a two-step claim to prevent payout rerolling.
POST /claim/quote
* Auth required (NIP-98)
* Input: lightning_address
* Performs full eligibility check.
* Selects payout using weighted randomness.
* Enforces daily budget guard:
* If today_spent + payout > DAILY_BUDGET_SATS, either deny or reduce to FAUCET_MIN_SATS based on config.
* Creates a short-lived quote record (quote_id, pubkey, payout_sats, expires_at).
* Response includes payout_sats and quote_id.
POST /claim/confirm
* Auth required (NIP-98)
* Input: quote_id
* Re-check critical guards (pubkey lock, ip lock, budget, balance, quote expiry).
* Executes LNbits payment.
* Records claim row with status.
* Returns success/failure payload.
Notes:
* Confirm must be idempotent: multiple confirms for same quote_id should not pay twice.
* If confirm is called after quote expiry, return a clean error.
### 5.5 LNbits payout
Backend sends payment to the users Lightning address.
Must handle:
* Lightning address validation (basic format and domain resolve).
* LNURLp resolution: fetch payRequest, then call callback with amount.
* Handle failures:
* Mark claim as failed, store error.
* Do not consume cooldown if payout did not actually send (configurable, but recommended).
Retries:
* For MVP, 0 retries.
* For long-term, add a background worker to retry transient failures.
## 6. Nostr data sourcing
### 6.1 Relay list
Backend reads NOSTR_RELAYS from env.
Must query multiple relays in parallel.
### 6.2 Account age
Compute nostr_first_seen_at as earliest observed event timestamp for the pubkey.
Practical strategy:
* Query kinds: 0, 1, 3.
* Use earliest created_at found.
* Cache result.
If no events found:
* Treat as new or unknown and deny with a friendly message.
### 6.3 Activity score
Compute a simple score (0 to 100) using cached metrics.
Suggested inputs:
* Has kind 0 metadata.
* Notes count in last ACTIVITY_LOOKBACK_DAYS.
* Following count.
* Followers count (optional if expensive).
* Optional zap receipts.
The scoring formula must be controlled via env thresholds so it can be tuned as the faucet grows.
## 7. Persistence and Storage
### 7.1 Database
Use Postgres.
Required tables:
* users: pubkey, nostr_first_seen_at, cached metrics, last fetch timestamps.
* claims: pubkey, claimed_at, payout, ip_hash, status, lnbits identifiers, errors.
* ip_limits: ip_hash, last_claimed_at, rolling counters.
* quotes: quote_id, pubkey, payout_sats, expires_at, status.
* stats_daily (optional): daily totals.
### 7.2 Privacy
* Store ip_hash only (HMAC).
* Store payout destination as hash.
* Never log raw Lightning address in plaintext logs.
## 8. Observability
Logging must include:
* Request id
* pubkey
* eligibility denial reason
* payout amount (if eligible)
* LNbits payment result (success/failure)
Metrics dashboard (optional):
* total claims
* total paid
* denial counts by reason
* payout distribution
## 9. Operational Controls
Environment variables must support:
* FAUCET_ENABLED
* EMERGENCY_STOP
* DAILY_BUDGET_SATS
* MAX_CLAIMS_PER_DAY
* MIN_WALLET_BALANCE_SATS
Behavior:
* If EMERGENCY_STOP=true, deny all claims with a maintenance message.
* If wallet balance below MIN_WALLET_BALANCE_SATS, deny claims and encourage deposits.
## 10. API Surface
Public endpoints
* GET /health
* GET /config
* GET /stats
* GET /deposit
Claim endpoints (auth: NIP-98)
* POST /claim/quote
* POST /claim/confirm
Optional admin endpoints (later)
* GET /admin/claims
* GET /admin/users
* POST /admin/ban
* POST /admin/allow
## 11. Error Handling
All errors must return:
* code (stable string)
* message (user-friendly)
* details (optional for debugging)
Common denial codes:
* 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
## 12. Security Checklist
Minimum required:
* Strict NIP-98 verification (method, url, timestamp, nonce).
* Nonce replay prevention.
* IP hashing.
* Cloudflare or similar rate limiting on claim endpoints.
* CORS restricted to FRONTEND_URL.
* No secret keys in frontend.
Nice to have:
* Bot protection (Turnstile) behind a feature flag.
* VPN/proxy detection behind a feature flag.
## 13. Deployment
* Backend behind a reverse proxy with TLS.
* TRUST_PROXY=true when behind proxy.
* Use database migrations.
Recommended:
* Docker compose for backend + Postgres + optional Redis.
* Separate LNbits deployment.
## 14. Acceptance Criteria
A developer is done when:
* A Nostr user can connect and claim sats successfully to a Lightning address.
* The faucet enforces cooldowns per pubkey and per IP.
* The faucet rejects accounts younger than 14 days.
* The faucet rejects accounts below activity threshold.
* Payouts are randomized and cannot be rerolled by refreshing.
* Public page shows deposit address and QR.
* Public stats show balance and transparency counters.
* Admin can stop the faucet instantly via env.
* No raw IPs or LNbits keys leak to the client.

3
frontend/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Backend API URL (required in dev when frontend runs on different port)
# Leave empty if frontend is served from same origin as API
VITE_API_URL=http://localhost:3001

View File

@@ -0,0 +1,374 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Free Bitcoins</title>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: Arial, sans-serif;
background: #fff;
color: #333;
}
.topbar {
background: #1a1a1a;
height: 6px;
width: 100%;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 20px;
display: flex;
gap: 30px;
}
.sidebar {
width: 140px;
flex-shrink: 0;
padding-top: 10px;
}
.sidebar p {
font-size: 13px;
margin-bottom: 8px;
color: #555;
}
.sidebar .label {
font-size: 13px;
color: #555;
margin-top: 20px;
margin-bottom: 4px;
}
.sidebar a {
display: block;
color: #0645ad;
font-size: 14px;
font-weight: bold;
text-decoration: none;
margin-bottom: 4px;
}
.sidebar a:hover { text-decoration: underline; }
.main { flex: 1; }
.header {
display: flex;
align-items: center;
margin-bottom: 30px;
border-bottom: 2px solid #eee;
padding-bottom: 16px;
}
.faucet-svg {
width: 110px;
height: 150px;
}
h1 {
font-size: 2.8rem;
font-weight: normal;
color: #bbb;
letter-spacing: 1px;
}
.content h2 {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 12px;
color: #222;
}
.content p {
font-size: 14px;
margin-bottom: 16px;
color: #444;
line-height: 1.5;
}
.turnstile-wrap {
margin-bottom: 16px;
}
/* ── Entropy Generator ── */
.entropy-box {
display: inline-flex;
align-items: center;
gap: 14px;
background: #f7f7f7;
border: 1px solid #ddd;
border-radius: 6px;
padding: 10px 16px;
margin-bottom: 16px;
}
.entropy-label {
font-size: 12px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.entropy-display {
font-size: 2rem;
font-weight: bold;
color: #f39c12;
min-width: 32px;
text-align: center;
font-family: monospace;
transition: color 0.15s;
}
.entropy-display.rolling {
color: #ccc;
}
.entropy-unit {
font-size: 12px;
color: #888;
}
.entropy-bar {
display: flex;
gap: 4px;
align-items: center;
}
.entropy-pip {
width: 10px;
height: 10px;
border-radius: 50%;
background: #ddd;
transition: background 0.2s;
}
.entropy-pip.active {
background: #f39c12;
}
.entropy-btn {
font-size: 11px;
background: #fff;
border: 1px solid #bbb;
border-radius: 3px;
padding: 3px 8px;
cursor: pointer;
color: #555;
}
.entropy-btn:hover { background: #f0f0f0; }
.entropy-source {
font-size: 10px;
color: #bbb;
font-style: italic;
}
/* Address row */
.address-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.address-row label {
font-size: 14px;
color: #444;
}
.address-row input[type="text"] {
width: 220px;
height: 26px;
border: 1px solid #aaa;
padding: 2px 6px;
font-size: 13px;
}
.address-row button {
height: 26px;
padding: 0 14px;
font-size: 13px;
background: #f0f0f0;
border: 1px solid #aaa;
cursor: pointer;
border-radius: 2px;
}
.address-row button:hover { background: #e0e0e0; }
</style>
</head>
<body>
<div class="topbar"></div>
<div class="container">
<div class="sidebar">
<p>Sats available</p>
<p class="label">Other Sites:</p>
<a href="https://bitcoin.org/en/">Bitcoin.org</a>
<a href="https://bitcoin.org/en/buy">Bitcoin Market</a>
</div>
<div class="main">
<div class="header">
<svg class="faucet-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="52" cy="14" rx="10" ry="6" fill="#c0392b"/>
<rect x="49" y="14" width="6" height="16" fill="#c0392b"/>
<rect x="30" y="28" width="44" height="30" rx="6" fill="#c0392b"/>
<rect x="72" y="36" width="24" height="14" rx="4" fill="#c0392b"/>
<rect x="85" y="50" width="12" height="32" rx="4" fill="#c0392b"/>
<ellipse cx="89" cy="91" rx="3" ry="5" fill="#5dade2" opacity="0.85"/>
<ellipse cx="92" cy="102" rx="2" ry="4" fill="#5dade2" opacity="0.65"/>
<ellipse cx="87" cy="108" rx="2" ry="3.5" fill="#5dade2" opacity="0.5"/>
<ellipse cx="91" cy="116" rx="1.5" ry="3" fill="#5dade2" opacity="0.35"/>
<rect x="10" y="33" width="22" height="18" rx="3" fill="#c0392b"/>
<rect x="6" y="36" width="8" height="12" rx="2" fill="#922b21"/>
</svg>
<h1>Free Bitcoins</h1>
</div>
<div class="content">
<h2>Get Bitcoins from the Bitcoin Faucet</h2>
<p>I'm giving away 1 to 5 satoshis per visitor; just solve the "captcha" then enter your Lightning address and press Get Some:</p>
<!-- Entropy Generator -->
<div class="entropy-box">
<div>
<div class="entropy-label">You will receive</div>
<div style="display:flex; align-items:baseline; gap:6px;">
<div class="entropy-display" id="entropy-display">?</div>
<div class="entropy-unit">sats</div>
</div>
<div class="entropy-source" id="entropy-source">pending roll…</div>
</div>
<div>
<div class="entropy-bar" id="entropy-bar">
<div class="entropy-pip" id="pip-1"></div>
<div class="entropy-pip" id="pip-2"></div>
<div class="entropy-pip" id="pip-3"></div>
<div class="entropy-pip" id="pip-4"></div>
<div class="entropy-pip" id="pip-5"></div>
</div>
<div style="margin-top:8px;">
<button class="entropy-btn" onclick="rollEntropy()">&#x21bb; Re-roll</button>
</div>
</div>
</div>
<!-- Cloudflare Turnstile CAPTCHA -->
<div class="turnstile-wrap">
<div class="cf-turnstile"
data-sitekey="0x4AAAAAAChmQ1hiZcL5Tf1s"
data-callback="onTurnstileSuccess">
</div>
</div>
<!-- Lightning Address -->
<div class="address-row">
<label>Your Lightning Address:</label>
<input type="text" id="lightning-address" placeholder="you@wallet.com">
<button onclick="handleGetSome()">Get Some!</button>
</div>
</div>
</div>
</div>
<script>
let turnstileToken = null;
let satAmount = null;
function onTurnstileSuccess(token) {
turnstileToken = token;
}
// Uses crypto.getRandomValues for cryptographic-quality entropy
function cryptoRandInt(min, max) {
const range = max - min + 1;
const buf = new Uint32Array(1);
let result;
// Rejection sampling to avoid modulo bias
do {
crypto.getRandomValues(buf);
result = buf[0] % range;
} while (buf[0] > Math.floor(0xFFFFFFFF / range) * range);
return min + result;
}
function rollEntropy() {
const display = document.getElementById('entropy-display');
const source = document.getElementById('entropy-source');
satAmount = null;
// Animate a short roll
let ticks = 0;
const totalTicks = 14;
display.classList.add('rolling');
const interval = setInterval(() => {
const temp = cryptoRandInt(1, 5);
display.textContent = temp;
updatePips(temp);
ticks++;
if (ticks >= totalTicks) {
clearInterval(interval);
// Final authoritative roll
satAmount = cryptoRandInt(1, 5);
// Collect extra entropy from timing jitter
const jitterBuf = new Uint32Array(4);
crypto.getRandomValues(jitterBuf);
const entropyHex = Array.from(jitterBuf).map(n => n.toString(16).padStart(8,'0')).join('').slice(0, 12);
display.textContent = satAmount;
display.classList.remove('rolling');
updatePips(satAmount);
source.textContent = 'entropy: 0x' + entropyHex + '…';
}
}, 60);
}
function updatePips(n) {
for (let i = 1; i <= 5; i++) {
const pip = document.getElementById('pip-' + i);
pip.classList.toggle('active', i <= n);
}
}
// Roll on page load
window.addEventListener('DOMContentLoaded', rollEntropy);
function handleGetSome() {
const address = document.getElementById('lightning-address').value.trim();
if (satAmount === null) {
alert('Please wait for the entropy roll to complete.');
return;
}
if (!turnstileToken) {
alert('Please complete the CAPTCHA first.');
return;
}
if (!address) {
alert('Please enter your Lightning address.');
return;
}
console.log('Sats to send:', satAmount);
console.log('Turnstile token:', turnstileToken);
console.log('Lightning address:', address);
alert('Request submitted! Sending ' + satAmount + ' sat(s) to ' + address);
}
</script>
</body>
</html>

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sats Faucet</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2297
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "lnfaucet-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"canvas-confetti": "^1.9.4",
"framer-motion": "^11.11.17",
"nostr-tools": "^2.4.4",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}

101
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,101 @@
import { useState, useEffect, useCallback } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { getToken, getAuthMe, clearToken } from "./api";
import { Header } from "./components/Header";
import { Footer } from "./components/Footer";
import { ClaimWizard } from "./components/ClaimWizard";
import { StatsSection } from "./components/StatsSection";
import { DepositSection } from "./components/DepositSection";
import { TransactionsPage } from "./pages/TransactionsPage";
const FaucetSvg = () => (
<svg className="faucet-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="52" cy="14" rx="10" ry="6" fill="#c0392b" />
<rect x="49" y="14" width="6" height="16" fill="#c0392b" />
<rect x="30" y="28" width="44" height="30" rx="6" fill="#c0392b" />
<rect x="72" y="36" width="24" height="14" rx="4" fill="#c0392b" />
<rect x="85" y="50" width="12" height="32" rx="4" fill="#c0392b" />
<ellipse cx="89" cy="91" rx="3" ry="5" fill="#5dade2" opacity="0.85" />
<ellipse cx="92" cy="102" rx="2" ry="4" fill="#5dade2" opacity="0.65" />
<ellipse cx="87" cy="108" rx="2" ry="3.5" fill="#5dade2" opacity="0.5" />
<ellipse cx="91" cy="116" rx="1.5" ry="3" fill="#5dade2" opacity="0.35" />
<rect x="10" y="33" width="22" height="18" rx="3" fill="#c0392b" />
<rect x="6" y="36" width="8" height="12" rx="2" fill="#922b21" />
</svg>
);
export default function App() {
const [pubkey, setPubkey] = useState<string | null>(null);
const [statsRefetchTrigger, setStatsRefetchTrigger] = useState(0);
useEffect(() => {
const token = getToken();
if (!token) return;
getAuthMe()
.then((r) => setPubkey(r.pubkey))
.catch(() => {
clearToken();
setPubkey(null);
});
}, []);
const handleClaimSuccess = useCallback(() => {
setStatsRefetchTrigger((t) => t + 1);
}, []);
return (
<BrowserRouter>
<div className="app">
<Header />
<div className="topbar" />
<div className="app-body">
<Routes>
<Route
path="/"
element={
<div className="container">
<aside className="sidebar sidebar-left">
<div className="funding-panel">
<DepositSection />
</div>
<div className="sidebar-links">
<p className="sidebar-links-title">Sats available</p>
<p className="label">Other Sites:</p>
<a href="https://bitcoin.org/en/" target="_blank" rel="noopener noreferrer">
Bitcoin.org
</a>
<a href="https://bitcoin.org/en/buy" target="_blank" rel="noopener noreferrer">
Bitcoin Market
</a>
</div>
</aside>
<main className="main">
<div className="header">
<FaucetSvg />
<h1>Sats Faucet</h1>
</div>
<ClaimWizard pubkey={pubkey} onPubkeyChange={setPubkey} onClaimSuccess={handleClaimSuccess} />
</main>
<aside className="sidebar sidebar-right">
<StatsSection refetchTrigger={statsRefetchTrigger} />
</aside>
</div>
}
/>
<Route
path="/transactions"
element={
<div className="container container--single">
<main className="main main--full">
<TransactionsPage />
</main>
</div>
}
/>
</Routes>
</div>
<Footer />
</div>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,43 @@
import { Component, ErrorInfo, ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("App error:", error, errorInfo);
}
render() {
if (this.state.hasError && this.state.error) {
return (
<div style={{ padding: "2rem", maxWidth: 900, margin: "0 auto", fontFamily: "Arial", color: "#333" }}>
<h1 style={{ color: "#c0392b", marginBottom: "1rem" }}>Something went wrong</h1>
<pre style={{ background: "#f7f7f7", padding: "1rem", overflow: "auto", fontSize: 13 }}>
{this.state.error.message}
</pre>
<button
type="button"
onClick={() => this.setState({ hasError: false, error: null })}
style={{ marginTop: "1rem", padding: "8px 16px", cursor: "pointer" }}
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}

310
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,310 @@
import { nip98 } from "nostr-tools";
const API_BASE = (import.meta.env.VITE_API_URL as string) || "";
const TOKEN_KEY = "lnfaucet_token";
/** Build full request URL: no double slashes, works with empty or trailing-slash API_BASE. */
function apiUrl(path: string): string {
if (path.startsWith("http")) return path;
const base = (API_BASE || "").replace(/\/$/, "");
const p = path.startsWith("/") ? path : `/${path}`;
return base ? `${base}${p}` : p;
}
/** Absolute URL for NIP-98 signing (must match what the server sees). */
function absoluteApiUrl(path: string): string {
const relative = apiUrl(path);
return relative.startsWith("http") ? relative : `${window.location.origin}${relative}`;
}
function getLoginUrl(): string {
const base = API_BASE.startsWith("http") ? API_BASE : `${window.location.origin}${API_BASE}`;
return `${base.replace(/\/$/, "")}/auth/login`;
}
export function getToken(): string | null {
try {
return sessionStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
export function setToken(token: string): void {
sessionStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
sessionStorage.removeItem(TOKEN_KEY);
}
export interface ApiError {
code: string;
message: string;
details?: string;
next_eligible_at?: number;
}
export type DepositSource = "lightning" | "cashu";
export interface Stats {
balanceSats: number;
totalPaidSats: number;
totalClaims: number;
claimsLast24h: number;
dailyBudgetSats: number;
recentPayouts: { pubkey_prefix: string; payout_sats: number; claimed_at: number }[];
recentDeposits: { amount_sats: number; source: DepositSource; created_at: number }[];
}
export interface DepositInfo {
lightningAddress: string;
lnurlp: string;
}
export interface QuoteResult {
quote_id: string;
payout_sats: number;
expires_at: number;
}
export interface ConfirmResult {
success: boolean;
already_consumed?: boolean;
payout_sats?: number;
next_eligible_at?: number;
message?: string;
/** Optional; shown in success UI when backend includes it */
payment_hash?: string;
}
export interface FaucetConfig {
faucetEnabled: boolean;
emergencyStop: boolean;
cooldownDays: number;
minAccountAgeDays: number;
minActivityScore: number;
faucetMinSats: number;
faucetMaxSats: number;
}
export interface UserProfile {
lightning_address: string | null;
name: string | null;
}
declare global {
interface Window {
nostr?: {
getPublicKey(): Promise<string>;
signEvent(event: { kind: number; created_at: number; tags: string[][]; content: string }): Promise<{ id: string; sig: string }>;
};
}
}
async function getNip98Header(method: string, absoluteUrl: string, body?: string): Promise<string> {
const nostr = window.nostr;
if (!nostr) throw new Error("Nostr extension (NIP-07) not found. Install a Nostr wallet extension.");
const pubkey = await nostr.getPublicKey();
const u = absoluteUrl.startsWith("http") ? absoluteUrl : `${window.location.origin}${absoluteUrl}`;
const created_at = Math.floor(Date.now() / 1000);
const event = {
kind: 27235,
created_at,
tags: [
["u", u],
["method", method],
],
content: "",
};
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body));
const payloadHex = Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
event.tags.push(["payload", payloadHex]);
}
const signed = await nostr.signEvent(event);
const fullEvent = { ...event, id: signed.id, pubkey, sig: signed.sig };
return "Nostr " + btoa(JSON.stringify(fullEvent));
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const url = apiUrl(path);
const res = await fetch(url, {
...options,
headers: { "Content-Type": "application/json", ...options.headers },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err: ApiError = {
code: data.code ?? "request_failed",
message: data.message ?? res.statusText,
details: data.details,
next_eligible_at: data.next_eligible_at,
};
throw err;
}
return data as T;
}
async function requestWithNip98<T>(method: string, path: string, body?: object): Promise<T> {
const url = apiUrl(path);
const absoluteUrl = absoluteApiUrl(path);
const bodyStr = body ? JSON.stringify(body) : undefined;
const auth = await getNip98Header(method, absoluteUrl, bodyStr);
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
Authorization: auth,
},
body: bodyStr,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err: ApiError = {
code: data.code ?? "request_failed",
message: data.message ?? res.statusText,
details: data.details,
next_eligible_at: data.next_eligible_at,
};
throw err;
}
return data as T;
}
async function requestWithBearer<T>(method: string, path: string, body?: object): Promise<T> {
const token = getToken();
if (!token) throw new Error("Not logged in");
const url = apiUrl(path);
const bodyStr = body ? JSON.stringify(body) : undefined;
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: bodyStr,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err: ApiError = {
code: data.code ?? "request_failed",
message: data.message ?? res.statusText,
details: data.details,
next_eligible_at: data.next_eligible_at,
};
throw err;
}
return data as T;
}
/** Signer: given an event template, returns the signed event (e.g. extension, bunker, or local nsec). */
export type Nip98Signer = (e: { kind: number; tags: string[][]; content: string; created_at: number }) => Promise<{ id: string; sig: string; pubkey: string; kind: number; tags: string[][]; content: string; created_at: number }>;
/** Login with NIP-98 using a custom signer (extension, remote signer, or nsec). Returns token and pubkey. */
export async function postAuthLoginWithSigner(sign: Nip98Signer): Promise<{ token: string; pubkey: string }> {
const loginUrl = getLoginUrl();
const authValue = await nip98.getToken(loginUrl, "post", sign, true, {});
const url = apiUrl("/auth/login");
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: authValue },
body: "{}",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err: ApiError = {
code: data.code ?? "request_failed",
message: data.message ?? res.statusText,
details: data.details,
};
throw err;
}
return data as { token: string; pubkey: string };
}
/** Login with NIP-98 (sign once) via extension; returns token. Store with setToken(). */
export async function postAuthLogin(): Promise<{ token: string; pubkey: string }> {
return requestWithNip98<{ token: string; pubkey: string }>("POST", "/auth/login", {});
}
/** Get current user from session (Bearer). */
export async function getAuthMe(): Promise<{ pubkey: string }> {
const token = getToken();
if (!token) throw new Error("Not logged in");
const url = apiUrl("/auth/me");
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message ?? "Session invalid");
return data as { pubkey: string };
}
export async function getConfig(): Promise<FaucetConfig> {
return request<FaucetConfig>("/config");
}
export async function getStats(): Promise<Stats> {
return request<Stats>("/stats");
}
export async function getDeposit(): Promise<DepositInfo> {
return request<DepositInfo>("/deposit");
}
export interface CashuRedeemResult {
success: boolean;
paid?: boolean;
amount?: number;
invoiceAmount?: number;
netAmount?: number;
to?: string;
message?: string;
}
export interface CashuRedeemError {
success: false;
error: string;
errorType?: string;
}
export async function postRedeemCashu(token: string): Promise<CashuRedeemResult> {
const res = await fetch(apiUrl("/deposit/redeem-cashu"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: token.trim() }),
});
const data = (await res.json().catch(() => ({}))) as CashuRedeemResult | CashuRedeemError;
if (!res.ok) {
const err = data as CashuRedeemError;
throw new Error(err.error ?? `Redeem failed: ${res.status}`);
}
if (!(data as CashuRedeemResult).success) {
throw new Error((data as CashuRedeemError).error ?? "Redeem failed");
}
return data as CashuRedeemResult;
}
export async function postClaimQuote(lightningAddress: string): Promise<QuoteResult> {
const body = { lightning_address: lightningAddress.trim() };
if (getToken()) return requestWithBearer<QuoteResult>("POST", "/claim/quote", body);
return requestWithNip98<QuoteResult>("POST", "/claim/quote", body);
}
export async function postClaimConfirm(quoteId: string): Promise<ConfirmResult> {
const body = { quote_id: String(quoteId).trim() };
if (getToken()) return requestWithBearer<ConfirmResult>("POST", "/claim/confirm", body);
return requestWithNip98<ConfirmResult>("POST", "/claim/confirm", body);
}
export async function postUserRefreshProfile(): Promise<UserProfile> {
if (getToken()) return requestWithBearer<UserProfile>("POST", "/user/refresh-profile", {});
return requestWithNip98<UserProfile>("POST", "/user/refresh-profile", {});
}
export function hasNostr(): boolean {
return Boolean(typeof window !== "undefined" && window.nostr);
}

View File

@@ -0,0 +1,24 @@
import type { DenialState } from "../hooks/useClaimFlow";
interface ClaimDenialCardProps {
denial: DenialState;
onDismiss?: () => void;
}
export function ClaimDenialCard({ denial, onDismiss }: ClaimDenialCardProps) {
return (
<div className="claim-denial-card">
<p className="claim-denial-message">{denial.message}</p>
{denial.next_eligible_at != null && (
<p className="claim-denial-next">
Next eligible: {new Date(denial.next_eligible_at * 1000).toLocaleString()}
</p>
)}
{onDismiss && (
<button type="button" className="btn-secondary claim-denial-dismiss" onClick={onDismiss}>
Dismiss
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { useState } from "react";
import { Countdown } from "./Countdown";
import type { DenialState } from "../hooks/useClaimFlow";
const DENIAL_CODE_EXPLANATIONS: Record<string, string> = {
cooldown_pubkey: "You've already claimed recently. Each pubkey has a cooldown period.",
cooldown_ip: "This IP has reached the claim limit for the cooldown period.",
account_too_new: "Your Nostr account is too new. The faucet requires a minimum account age.",
low_activity: "Your Nostr profile doesn't meet the minimum activity score (notes, following).",
invalid_nip98: "Nostr signature verification failed.",
invalid_lightning_address: "The Lightning address format is invalid or could not be resolved.",
quote_expired: "The quote expired before confirmation.",
payout_failed: "The Lightning payment failed. You can try again.",
faucet_disabled: "The faucet is temporarily disabled.",
emergency_stop: "The faucet is in emergency stop mode.",
insufficient_balance: "The faucet pool has insufficient balance.",
daily_budget_exceeded: "The daily payout budget has been reached.",
};
interface ClaimDenialPanelProps {
denial: DenialState;
onDismiss?: () => void;
/** When provided, shows a "Check again" button (e.g. in wizard step 2) */
onCheckAgain?: () => void;
}
export function ClaimDenialPanel({ denial, onDismiss, onCheckAgain }: ClaimDenialPanelProps) {
const [whyExpanded, setWhyExpanded] = useState(false);
const explanation = denial.code ? DENIAL_CODE_EXPLANATIONS[denial.code] ?? null : null;
return (
<div className="claim-denial-panel">
<div className="claim-denial-panel-icon" aria-hidden>
<span className="claim-denial-panel-icon-inner"></span>
</div>
<h3 className="claim-denial-panel-title">Not eligible yet</h3>
<p className="claim-denial-panel-message">{denial.message}</p>
{denial.next_eligible_at != null && (
<p className="claim-denial-panel-countdown">
Next claim in: <Countdown targetUnixSeconds={denial.next_eligible_at} format="duration" />
</p>
)}
{(denial.code || explanation) && (
<div className="claim-denial-panel-why">
<button
type="button"
className="claim-denial-panel-why-trigger"
onClick={() => setWhyExpanded((e) => !e)}
aria-expanded={whyExpanded}
>
Why?
</button>
{whyExpanded && (
<div className="claim-denial-panel-why-content">
{denial.code && <p className="claim-denial-panel-why-code">Code: {denial.code}</p>}
{explanation && <p className="claim-denial-panel-why-text">{explanation}</p>}
</div>
)}
</div>
)}
<div className="claim-denial-panel-actions">
{onCheckAgain != null && (
<button type="button" className="btn-primary claim-denial-panel-check-again" onClick={onCheckAgain}>
Check again
</button>
)}
{onDismiss && (
<button type="button" className="btn-secondary claim-denial-panel-dismiss" onClick={onDismiss}>
Dismiss
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,231 @@
import React, { useState, useEffect, useRef } from "react";
import {
getConfig,
postUserRefreshProfile,
type UserProfile,
type FaucetConfig,
} from "../api";
import { useClaimFlow, ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow";
import { ConnectNostr } from "./ConnectNostr";
import { Modal } from "./Modal";
import { PayoutCard } from "./PayoutCard";
import { ClaimModal, type ClaimModalPhase } from "./ClaimModal";
import { ClaimDenialPanel } from "./ClaimDenialPanel";
import { ClaimStepIndicator } from "./ClaimStepIndicator";
const QUOTE_TO_MODAL_DELAY_MS = 900;
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
function isValidLightningAddress(addr: string): boolean {
return LIGHTNING_ADDRESS_REGEX.test(addr.trim());
}
interface Props {
pubkey: string | null;
onPubkeyChange: (pk: string | null) => void;
onClaimSuccess?: () => void;
}
export function ClaimFlow({ pubkey, onPubkeyChange, onClaimSuccess }: Props) {
const [config, setConfig] = React.useState<FaucetConfig | null>(null);
const [profile, setProfile] = React.useState<UserProfile | null>(null);
const [lightningAddress, setLightningAddress] = React.useState("");
const [lightningAddressTouched, setLightningAddressTouched] = React.useState(false);
const [showQuoteModal, setShowQuoteModal] = useState(false);
const quoteModalDelayRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lightningAddressInvalid =
lightningAddressTouched && lightningAddress.trim() !== "" && !isValidLightningAddress(lightningAddress);
const claim = useClaimFlow();
React.useEffect(() => {
getConfig().then(setConfig).catch(() => setConfig(null));
}, []);
React.useEffect(() => {
if (!pubkey) {
setProfile(null);
setLightningAddress("");
return;
}
postUserRefreshProfile()
.then((p) => {
setProfile(p);
const addr = (p.lightning_address ?? "").trim();
setLightningAddress(addr);
})
.catch(() => setProfile(null));
}, [pubkey]);
useEffect(() => {
if (claim.quote && claim.claimState === "quote_ready") {
quoteModalDelayRef.current = setTimeout(() => {
setShowQuoteModal(true);
}, QUOTE_TO_MODAL_DELAY_MS);
return () => {
if (quoteModalDelayRef.current) clearTimeout(quoteModalDelayRef.current);
};
}
setShowQuoteModal(false);
}, [claim.quote, claim.claimState]);
const handleDisconnect = () => {
onPubkeyChange(null);
setProfile(null);
claim.cancelQuote();
claim.resetSuccess();
claim.clearDenial();
claim.clearConfirmError();
};
const handleDone = () => {
claim.resetSuccess();
onClaimSuccess?.();
};
const handleCheckEligibility = () => {
claim.checkEligibility(lightningAddress);
};
const quoteExpired =
claim.quote != null && claim.quote.expires_at <= Math.floor(Date.now() / 1000);
const modalOpen =
(claim.quote != null && (showQuoteModal || claim.loading === "confirm" || claim.confirmError != null)) ||
claim.success != null;
const modalPhase: ClaimModalPhase = claim.success
? "success"
: claim.loading === "confirm"
? "sending"
: claim.confirmError != null
? "failure"
: "quote";
const modalTitle =
modalPhase === "quote" || modalPhase === "sending"
? "Confirm payout"
: modalPhase === "success"
? "Sats sent"
: "Claim";
return (
<div className="content claim-flow-content">
<div className="claim-flow-layout">
<ClaimStepIndicator claimState={claim.claimState} hasPubkey={!!pubkey} />
<div className="claim-flow-main">
<h2>Get sats from the faucet</h2>
<p className="claim-flow-desc">
Connect with Nostr once to sign in. Your Lightning address is filled from your profile. Check eligibility, then confirm in the modal to receive sats.
</p>
<ConnectNostr
pubkey={pubkey}
displayName={profile?.name}
onConnect={(pk) => onPubkeyChange(pk)}
onDisconnect={handleDisconnect}
/>
{pubkey && (
<>
<PayoutCard
config={{
minSats: Number(config?.faucetMinSats) || 1,
maxSats: Number(config?.faucetMaxSats) || 5,
}}
quote={claim.quote}
expired={quoteExpired}
onRecheck={() => {
claim.cancelQuote();
claim.clearDenial();
}}
/>
<div className="claim-flow-address-section">
<div className="address-row">
<label>Your Lightning Address:</label>
<input
type="text"
value={lightningAddress}
onChange={(e) => setLightningAddress(e.target.value)}
onBlur={() => setLightningAddressTouched(true)}
placeholder="you@wallet.com"
disabled={!!claim.quote}
readOnly={!!profile?.lightning_address && lightningAddress.trim() === (profile.lightning_address ?? "").trim()}
title={profile?.lightning_address ? "From your Nostr profile" : undefined}
aria-invalid={lightningAddressInvalid || undefined}
aria-describedby={lightningAddressInvalid ? "lightning-address-hint" : undefined}
/>
<button
type="button"
className="btn-primary btn-eligibility"
onClick={handleCheckEligibility}
disabled={claim.loading !== "idle"}
>
{claim.loading === "quote"
? ELIGIBILITY_PROGRESS_STEPS[claim.eligibilityProgressStep ?? 0]
: "Check eligibility"}
</button>
</div>
{lightningAddressInvalid && (
<p id="lightning-address-hint" className="claim-flow-input-hint" role="alert">
Enter a valid Lightning address (user@domain)
</p>
)}
{profile?.lightning_address && lightningAddress.trim() === profile.lightning_address.trim() && (
<div
className="profile-hint profile-hint-pill"
title="From your Nostr profile (kind:0 lightning field)"
>
<span className="profile-hint-pill-icon" aria-hidden></span>
Filled from profile
</div>
)}
</div>
{claim.denial && (
<ClaimDenialPanel denial={claim.denial} onDismiss={claim.clearDenial} />
)}
</>
)}
</div>
</div>
{modalOpen && (
<Modal
open={true}
onClose={() => {
if (claim.success) {
handleDone();
} else {
claim.cancelQuote();
claim.clearConfirmError();
setShowQuoteModal(false);
}
}}
title={modalTitle}
preventClose={claim.loading === "confirm"}
>
<ClaimModal
phase={modalPhase}
quote={claim.quote}
confirmResult={claim.success}
confirmError={claim.confirmError}
lightningAddress={lightningAddress}
quoteExpired={quoteExpired}
onConfirm={claim.confirmClaim}
onCancel={() => {
claim.cancelQuote();
claim.clearConfirmError();
setShowQuoteModal(false);
}}
onRetry={claim.confirmClaim}
onDone={handleDone}
/>
</Modal>
)}
</div>
);
}

View File

@@ -0,0 +1,195 @@
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Countdown } from "./Countdown";
import { useToast } from "../contexts/ToastContext";
import type { QuoteResult, ConfirmResult } from "../api";
import type { ConfirmErrorState } from "../hooks/useClaimFlow";
export type ClaimModalPhase = "quote" | "sending" | "success" | "failure";
interface ClaimModalProps {
phase: ClaimModalPhase;
quote: QuoteResult | null;
confirmResult: ConfirmResult | null;
confirmError: ConfirmErrorState | null;
lightningAddress: string;
quoteExpired: boolean;
onConfirm: () => void;
onCancel: () => void;
onRetry: () => void;
onDone: () => void;
}
function CheckIcon() {
return (
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M20 6L9 17l-5-5" />
</svg>
);
}
function SpinnerIcon() {
return (
<svg className="claim-modal-spinner" width="40" height="40" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function ClaimModal({
phase,
quote,
confirmResult,
confirmError,
lightningAddress,
quoteExpired,
onConfirm,
onCancel,
onRetry,
onDone,
}: ClaimModalProps) {
const { showToast } = useToast();
const [paymentHashExpanded, setPaymentHashExpanded] = useState(false);
const handleShare = () => {
const amount = confirmResult?.payout_sats ?? 0;
const text = `Just claimed ${amount} sats from the faucet!`;
navigator.clipboard.writeText(text).then(() => showToast("Copied"));
};
const copyPaymentHash = () => {
const hash = confirmResult?.payment_hash;
if (!hash) return;
navigator.clipboard.writeText(hash).then(() => showToast("Copied"));
};
return (
<div className="claim-modal-content">
<AnimatePresence mode="wait">
{phase === "quote" && quote && (
<motion.div
key="quote"
className="claim-modal-phase claim-modal-quote"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<h3 className="claim-modal-phase-title">Confirm payout</h3>
<div className="claim-modal-quote-amount-large">
<span className="claim-modal-quote-amount-value">{quote.payout_sats}</span>
<span className="claim-modal-quote-amount-unit">sats</span>
</div>
<div className="claim-modal-quote-destination">
<span className="claim-modal-quote-destination-label">To</span>
<span className="claim-modal-quote-destination-value">{lightningAddress}</span>
</div>
<div className="claim-modal-quote-expiry-ring">
<Countdown targetUnixSeconds={quote.expires_at} format="clock" />
<span className="claim-modal-quote-expiry-label">Expires in</span>
</div>
<div className="claim-modal-actions">
<button type="button" className="btn-primary btn-primary-large" onClick={onConfirm} disabled={quoteExpired}>
Send sats
</button>
<button type="button" className="btn-secondary" onClick={onCancel}>
Cancel
</button>
</div>
</motion.div>
)}
{phase === "sending" && (
<motion.div
key="sending"
className="claim-modal-phase claim-modal-sending"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<SpinnerIcon />
<p className="claim-modal-sending-text">Sending sats via Lightning</p>
</motion.div>
)}
{phase === "success" && confirmResult && (
<motion.div
key="success"
className="claim-modal-phase claim-modal-success"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="claim-modal-success-icon">
<CheckIcon />
</div>
<p className="claim-modal-success-headline">Sent {confirmResult.payout_sats ?? 0} sats</p>
{confirmResult.payment_hash && (
<div className="claim-modal-success-payment-hash">
<button
type="button"
className="claim-modal-payment-hash-btn"
onClick={() => {
setPaymentHashExpanded((e) => !e);
}}
aria-expanded={paymentHashExpanded}
>
{paymentHashExpanded
? confirmResult.payment_hash
: `${confirmResult.payment_hash.slice(0, 12)}`}
</button>
<button type="button" className="btn-secondary claim-modal-copy-btn" onClick={copyPaymentHash}>
Copy
</button>
</div>
)}
{confirmResult.next_eligible_at != null && (
<p className="claim-modal-success-next">
Next eligible: <Countdown targetUnixSeconds={confirmResult.next_eligible_at} format="duration" />
</p>
)}
<div className="claim-modal-actions">
<button type="button" className="btn-primary btn-primary-large" onClick={onDone}>
Done
</button>
<button type="button" className="btn-secondary" onClick={handleShare}>
Share
</button>
</div>
</motion.div>
)}
{phase === "failure" && confirmError && (
<motion.div
key="failure"
className="claim-modal-phase claim-modal-failure"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<p className="claim-modal-failure-message">{confirmError.message}</p>
<div className="claim-modal-actions">
{confirmError.allowRetry && !quoteExpired && (
<button type="button" className="btn-primary" onClick={onRetry}>
Try again
</button>
)}
{(!confirmError.allowRetry || quoteExpired) && (
<button type="button" className="btn-primary" onClick={onCancel}>
Re-check eligibility
</button>
)}
<button type="button" className="btn-secondary" onClick={onCancel}>
Close
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useState, useEffect } from "react";
import type { QuoteResult } from "../api";
import type { ConfirmErrorState } from "../hooks/useClaimFlow";
interface ClaimQuoteModalProps {
quote: QuoteResult;
lightningAddress: string;
loading: boolean;
confirmError: ConfirmErrorState | null;
onConfirm: () => void;
onCancel: () => void;
onRetry: () => void;
}
function formatCountdown(expiresAt: number): string {
const now = Math.floor(Date.now() / 1000);
const left = Math.max(0, expiresAt - now);
const m = Math.floor(left / 60);
const s = left % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function ClaimQuoteModal({
quote,
lightningAddress,
loading,
confirmError,
onConfirm,
onCancel,
onRetry,
}: ClaimQuoteModalProps) {
const [countdown, setCountdown] = useState(() => formatCountdown(quote.expires_at));
const expired = quote.expires_at <= Math.floor(Date.now() / 1000);
useEffect(() => {
const t = setInterval(() => {
setCountdown(formatCountdown(quote.expires_at));
}, 1000);
return () => clearInterval(t);
}, [quote.expires_at]);
if (confirmError) {
return (
<div className="claim-modal-content claim-quote-error">
<p className="claim-modal-error-text">{confirmError.message}</p>
<div className="claim-modal-actions">
{confirmError.allowRetry && (
<button type="button" className="btn-primary" onClick={onRetry} disabled={loading}>
{loading ? "Retrying…" : "Retry"}
</button>
)}
<button type="button" className="btn-secondary" onClick={onCancel}>
Close
</button>
</div>
</div>
);
}
return (
<div className="claim-modal-content">
<div className="claim-quote-amount">
<span className="claim-quote-amount-value">{quote.payout_sats}</span>
<span className="claim-quote-amount-unit">sats</span>
</div>
<div className="claim-quote-countdown">
{expired ? (
<span className="claim-quote-expired">Quote expired</span>
) : (
<>Expires in {countdown}</>
)}
</div>
<div className="claim-quote-address">
<span className="claim-quote-address-label">To</span>
<span className="claim-quote-address-value">{lightningAddress}</span>
</div>
<div className="claim-modal-actions">
<button
type="button"
className="btn-primary"
onClick={onConfirm}
disabled={loading || expired}
>
{loading ? "Sending…" : "Confirm"}
</button>
<button type="button" className="btn-secondary" onClick={onCancel} disabled={loading}>
Cancel
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import type { ClaimFlowState } from "../hooks/useClaimFlow";
const STEPS = [
{ id: 1, label: "Connect" },
{ id: 2, label: "Check" },
{ id: 3, label: "Confirm" },
{ id: 4, label: "Receive" },
] as const;
function stepFromState(claimState: ClaimFlowState, hasPubkey: boolean): number {
if (!hasPubkey) return 1;
switch (claimState) {
case "connected_idle":
case "quoting":
case "denied":
return 2;
case "quote_ready":
case "confirming":
case "error":
return 3;
case "success":
return 4;
default:
return 2;
}
}
interface ClaimStepIndicatorProps {
claimState: ClaimFlowState;
hasPubkey: boolean;
}
export function ClaimStepIndicator({ claimState, hasPubkey }: ClaimStepIndicatorProps) {
const currentStep = stepFromState(claimState, hasPubkey);
return (
<div className="claim-step-indicator" role="list" aria-label="Claim steps">
{STEPS.map((step, index) => {
const isActive = step.id === currentStep;
const isPast = step.id < currentStep;
return (
<div
key={step.id}
className={`claim-step-indicator-item ${isActive ? "claim-step-indicator-item--active" : ""} ${isPast ? "claim-step-indicator-item--past" : ""}`}
role="listitem"
>
<div className="claim-step-indicator-dot" aria-current={isActive ? "step" : undefined} />
<span className="claim-step-indicator-label">{step.label}</span>
{index < STEPS.length - 1 && <div className="claim-step-indicator-line" />}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { useEffect } from "react";
import confetti from "canvas-confetti";
import type { ConfirmResult } from "../api";
interface ClaimSuccessModalProps {
result: ConfirmResult;
onClose: () => void;
}
export function ClaimSuccessModal({ result, onClose }: ClaimSuccessModalProps) {
const amount = result.payout_sats ?? 0;
const nextAt = result.next_eligible_at;
useEffect(() => {
const t = setTimeout(() => {
confetti({
particleCount: 60,
spread: 60,
origin: { y: 0.6 },
colors: ["#f97316", "#22c55e", "#eab308"],
});
}, 300);
return () => clearTimeout(t);
}, []);
return (
<div className="claim-modal-content claim-success-content">
<div className="claim-success-amount">
<span className="claim-success-amount-value">{amount}</span>
<span className="claim-success-amount-unit">sats sent</span>
</div>
{nextAt != null && (
<p className="claim-success-next">
Next claim after <strong>{new Date(nextAt * 1000).toLocaleString()}</strong>
</p>
)}
<button type="button" className="btn-primary claim-success-close" onClick={onClose}>
Done
</button>
</div>
);
}

View File

@@ -0,0 +1,184 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import { postUserRefreshProfile, type UserProfile } from "../api";
import { useClaimFlow } from "../hooks/useClaimFlow";
import { StepIndicator } from "./StepIndicator";
import { ConnectStep } from "./ConnectStep";
import { EligibilityStep } from "./EligibilityStep";
import { ConfirmStep } from "./ConfirmStep";
import { SuccessStep } from "./SuccessStep";
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
function isValidLightningAddress(addr: string): boolean {
return LIGHTNING_ADDRESS_REGEX.test(addr.trim());
}
function getWizardStep(
hasPubkey: boolean,
claimState: ReturnType<typeof useClaimFlow>["claimState"]
): 1 | 2 | 3 | 4 {
if (!hasPubkey) return 1;
if (claimState === "success") return 4;
if (claimState === "quote_ready" || claimState === "confirming" || claimState === "error") return 3;
return 2;
}
interface ClaimWizardProps {
pubkey: string | null;
onPubkeyChange: (pk: string | null) => void;
onClaimSuccess?: () => void;
}
export function ClaimWizard({ pubkey, onPubkeyChange, onClaimSuccess }: ClaimWizardProps) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [lightningAddress, setLightningAddress] = useState("");
const [lightningAddressTouched, setLightningAddressTouched] = useState(false);
const wizardRef = useRef<HTMLDivElement>(null);
const claim = useClaimFlow();
const currentStep = useMemo(
() => getWizardStep(!!pubkey, claim.claimState),
[pubkey, claim.claimState]
);
useEffect(() => {
if (!pubkey) {
setProfile(null);
setLightningAddress("");
return;
}
postUserRefreshProfile()
.then((p) => {
setProfile(p);
const addr = (p.lightning_address ?? "").trim();
setLightningAddress(addr);
})
.catch(() => setProfile(null));
}, [pubkey]);
useEffect(() => {
if (currentStep === 2 && pubkey && wizardRef.current) {
wizardRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, [currentStep, pubkey]);
useEffect(() => {
if (currentStep === 4 && wizardRef.current) {
wizardRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, [currentStep]);
const handleDisconnect = () => {
onPubkeyChange(null);
setProfile(null);
claim.cancelQuote();
claim.resetSuccess();
claim.clearDenial();
claim.clearConfirmError();
};
const handleDone = () => {
claim.resetSuccess();
onClaimSuccess?.();
};
const handleClaimAgain = () => {
claim.resetSuccess();
claim.clearDenial();
claim.clearConfirmError();
setLightningAddressTouched(false);
// Stay on step 2 (eligibility)
};
const handleCheckEligibility = () => {
claim.checkEligibility(lightningAddress);
};
const handleCancelQuote = () => {
claim.cancelQuote();
claim.clearConfirmError();
};
const lightningAddressInvalid =
lightningAddressTouched && lightningAddress.trim() !== "" && !isValidLightningAddress(lightningAddress);
const fromProfile =
Boolean(profile?.lightning_address) &&
lightningAddress.trim() === (profile?.lightning_address ?? "").trim();
const quoteExpired =
claim.quote != null && claim.quote.expires_at <= Math.floor(Date.now() / 1000);
return (
<div className="content claim-wizard-content" ref={wizardRef}>
<div className="ClaimWizard claim-wizard-root">
<header className="claim-wizard-header">
<div className="claim-wizard-header-row">
<h2 className="claim-wizard-title">Get sats from the faucet</h2>
{pubkey && (
<button
type="button"
className="claim-wizard-disconnect"
onClick={handleDisconnect}
aria-label="Disconnect account"
>
Disconnect
</button>
)}
</div>
<StepIndicator currentStep={currentStep} />
</header>
<div className="claim-wizard-body">
{currentStep === 1 && (
<ConnectStep
pubkey={pubkey}
displayName={profile?.name}
onConnect={(pk) => onPubkeyChange(pk)}
onDisconnect={handleDisconnect}
/>
)}
{currentStep === 2 && (
<EligibilityStep
lightningAddress={lightningAddress}
onLightningAddressChange={setLightningAddress}
lightningAddressTouched={lightningAddressTouched}
setLightningAddressTouched={setLightningAddressTouched}
invalid={lightningAddressInvalid}
fromProfile={fromProfile}
loading={claim.loading === "quote"}
eligibilityProgressStep={claim.eligibilityProgressStep}
denial={claim.denial}
onCheckEligibility={handleCheckEligibility}
onClearDenial={claim.clearDenial}
onCheckAgain={() => {
claim.clearDenial();
}}
/>
)}
{currentStep === 3 && claim.quote && (
<ConfirmStep
quote={claim.quote}
lightningAddress={lightningAddress}
quoteExpired={quoteExpired}
confirming={claim.loading === "confirm"}
confirmError={claim.confirmError}
onConfirm={claim.confirmClaim}
onCancel={handleCancelQuote}
onRetry={claim.confirmClaim}
/>
)}
{currentStep === 4 && claim.success && (
<SuccessStep
result={claim.success}
onDone={handleDone}
onClaimAgain={handleClaimAgain}
/>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Countdown } from "./Countdown";
import type { QuoteResult } from "../api";
import type { ConfirmErrorState } from "../hooks/useClaimFlow";
interface ConfirmStepProps {
quote: QuoteResult | null;
lightningAddress: string;
quoteExpired: boolean;
confirming: boolean;
confirmError: ConfirmErrorState | null;
onConfirm: () => void;
onCancel: () => void;
onRetry: () => void;
}
function SpinnerIcon() {
return (
<svg className="claim-wizard-spinner" width="32" height="32" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function ConfirmStep({
quote,
lightningAddress,
quoteExpired,
confirming,
confirmError,
onConfirm,
onCancel,
onRetry,
}: ConfirmStepProps) {
if (!quote) return null;
return (
<div className="claim-wizard-step claim-wizard-step-confirm">
<AnimatePresence mode="wait">
{confirming ? (
<motion.div
key="sending"
className="claim-wizard-confirm-sending"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<SpinnerIcon />
<p className="claim-wizard-confirm-sending-text">Sending sats via Lightning</p>
</motion.div>
) : confirmError ? (
<motion.div
key="error"
className="claim-wizard-confirm-error"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<h3 className="claim-wizard-step-title">Something went wrong</h3>
<p className="claim-wizard-confirm-error-message">{confirmError.message}</p>
<div className="claim-wizard-step-actions claim-wizard-step-actions--row">
{confirmError.allowRetry && !quoteExpired && (
<button type="button" className="btn-primary claim-wizard-btn-primary" onClick={onRetry}>
Try again
</button>
)}
<button type="button" className="btn-secondary" onClick={onCancel}>
{confirmError.allowRetry && !quoteExpired ? "Cancel" : "Back"}
</button>
</div>
</motion.div>
) : (
<motion.div
key="quote"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<h3 className="claim-wizard-step-title">Confirm payout</h3>
<div className="claim-wizard-quote-amount">
<span className="claim-wizard-quote-amount-value">{quote.payout_sats}</span>
<span className="claim-wizard-quote-amount-unit">sats</span>
</div>
<div className="claim-wizard-quote-expiry">
<Countdown targetUnixSeconds={quote.expires_at} format="clock" />
<span className="claim-wizard-quote-expiry-label">Locked for</span>
</div>
<p className="claim-wizard-quote-destination">
To <strong>{lightningAddress}</strong>
</p>
<div className="claim-wizard-step-actions claim-wizard-step-actions--row">
<button
type="button"
className="btn-primary claim-wizard-btn-primary"
onClick={onConfirm}
disabled={quoteExpired}
>
Confirm payout
</button>
<button type="button" className="btn-secondary" onClick={onCancel}>
Cancel
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import React from "react";
import { clearToken } from "../api";
import { nip19 } from "nostr-tools";
import { LoginModal } from "./LoginModal";
interface Props {
pubkey: string | null;
displayName?: string | null;
onConnect: (pubkey: string) => void;
onDisconnect: () => void;
}
function getInitial(name: string | null | undefined): string {
const n = (name ?? "").trim();
if (n.length > 0) return n[0].toUpperCase();
return "?";
}
export function ConnectNostr({ pubkey, displayName, onConnect, onDisconnect }: Props) {
const [modalOpen, setModalOpen] = React.useState(false);
const handleDisconnect = () => {
clearToken();
onDisconnect();
};
if (pubkey) {
const npub = nip19.npubEncode(pubkey);
const shortNpub = npub.slice(0, 12) + "…";
const display = displayName?.trim() || shortNpub;
const initial = getInitial(displayName);
return (
<div className="connect-pill-wrap">
<div className="connect-pill">
<span className="connect-pill-dot" aria-hidden />
<span className="connect-pill-avatar" aria-hidden>
{initial}
</span>
<span className="connect-pill-name">{display}</span>
<span className="connect-pill-npub">{shortNpub}</span>
</div>
<button
type="button"
className="connect-pill-disconnect"
onClick={handleDisconnect}
aria-label="Disconnect"
title="Disconnect"
>
<span className="connect-pill-disconnect-icon" aria-hidden></span>
</button>
</div>
);
}
return (
<>
<div className="address-row">
<button type="button" onClick={() => setModalOpen(true)}>
Connect Nostr
</button>
</div>
<LoginModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSuccess={onConnect}
/>
</>
);
}

View File

@@ -0,0 +1,28 @@
import React from "react";
import { ConnectNostr } from "./ConnectNostr";
interface ConnectStepProps {
pubkey: string | null;
displayName?: string | null;
onConnect: (pubkey: string) => void;
onDisconnect: () => void;
}
export function ConnectStep({ pubkey, displayName, onConnect, onDisconnect }: ConnectStepProps) {
return (
<div className="claim-wizard-step claim-wizard-step-connect">
<h3 className="claim-wizard-step-title">Connect your Nostr account</h3>
<p className="claim-wizard-step-desc">
Sign in with your Nostr key to prove your identity. Your Lightning address can be filled from your profile.
</p>
<div className="claim-wizard-connect-cta">
<ConnectNostr
pubkey={pubkey}
displayName={displayName}
onConnect={onConnect}
onDisconnect={onDisconnect}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { useState, useEffect, useRef } from "react";
interface CountUpNumberProps {
value: number;
durationMs?: number;
className?: string;
}
export function CountUpNumber({ value, durationMs = 600, className }: CountUpNumberProps) {
const [display, setDisplay] = useState(0);
const prevValue = useRef(value);
const startTime = useRef<number | null>(null);
const rafId = useRef<number>(0);
useEffect(() => {
if (value === prevValue.current) return;
prevValue.current = value;
setDisplay(0);
startTime.current = null;
const tick = (now: number) => {
if (startTime.current === null) startTime.current = now;
const elapsed = now - startTime.current;
const t = Math.min(elapsed / durationMs, 1);
const eased = 1 - (1 - t) * (1 - t);
setDisplay(Math.round(eased * value));
if (t < 1) rafId.current = requestAnimationFrame(tick);
};
rafId.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId.current);
}, [value, durationMs]);
return <span className={className}>{display}</span>;
}

View File

@@ -0,0 +1,50 @@
import { useState, useEffect } from "react";
export type CountdownFormat = "clock" | "duration";
interface CountdownProps {
targetUnixSeconds: number;
format?: CountdownFormat;
className?: string;
}
function formatClock(secondsLeft: number): string {
const m = Math.floor(secondsLeft / 60);
const s = secondsLeft % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
/**
* Format seconds into "Xd Xh Xm" for "next eligible" style countdown.
* Exported for reuse in denial panel and success modal.
*/
export function formatDuration(secondsLeft: number): string {
if (secondsLeft <= 0) return "0d 0h 0m";
const d = Math.floor(secondsLeft / 86400);
const h = Math.floor((secondsLeft % 86400) / 3600);
const m = Math.floor((secondsLeft % 3600) / 60);
const parts: string[] = [];
if (d > 0) parts.push(`${d}d`);
parts.push(`${h}h`);
parts.push(`${m}m`);
return parts.join(" ");
}
function getSecondsLeft(targetUnixSeconds: number): number {
return Math.max(0, targetUnixSeconds - Math.floor(Date.now() / 1000));
}
export function Countdown({ targetUnixSeconds, format = "clock", className }: CountdownProps) {
const [secondsLeft, setSecondsLeft] = useState(() => getSecondsLeft(targetUnixSeconds));
useEffect(() => {
setSecondsLeft(getSecondsLeft(targetUnixSeconds));
const t = setInterval(() => {
setSecondsLeft(getSecondsLeft(targetUnixSeconds));
}, 1000);
return () => clearInterval(t);
}, [targetUnixSeconds]);
const text = format === "duration" ? formatDuration(secondsLeft) : formatClock(secondsLeft);
return <span className={className}>{text}</span>;
}

View File

@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import QRCode from "qrcode";
import { getDeposit, postRedeemCashu, type DepositInfo } from "../api";
import { useToast } from "../contexts/ToastContext";
export function DepositSection() {
const [deposit, setDeposit] = useState<DepositInfo | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [cashuToken, setCashuToken] = useState("");
const [cashuLoading, setCashuLoading] = useState(false);
const { showToast } = useToast();
useEffect(() => {
getDeposit().then((d) => {
setDeposit(d);
const qrContent = d.lightningAddress || d.lnurlp;
if (qrContent) QRCode.toDataURL(qrContent, { width: 180 }).then(setQrDataUrl);
}).catch(() => setDeposit(null));
}, []);
const copyAddress = () => {
if (!deposit?.lightningAddress) return;
navigator.clipboard.writeText(deposit.lightningAddress);
showToast("Copied");
};
const handleRedeemCashu = async () => {
const token = cashuToken.trim();
if (!token || !token.toLowerCase().startsWith("cashu")) {
showToast("Enter a valid Cashu token (cashuA... or cashuB...)");
return;
}
setCashuLoading(true);
try {
const result = await postRedeemCashu(token);
setCashuToken("");
const amount = result.amount ?? result.netAmount ?? result.invoiceAmount;
showToast(amount != null ? `Redeemed ${amount} sats to faucet!` : "Cashu token redeemed to faucet.");
} catch (e) {
showToast(e instanceof Error ? e.message : "Redeem failed");
} finally {
setCashuLoading(false);
}
};
if (!deposit) return null;
if (!deposit.lightningAddress && !deposit.lnurlp) return null;
return (
<div className="deposit-box">
<h3>Fund the faucet</h3>
{deposit.lightningAddress && (
<div className="copy-row">
<input type="text" readOnly value={deposit.lightningAddress} />
<button type="button" onClick={copyAddress}>
Copy
</button>
</div>
)}
{qrDataUrl && (
<div className="qr-wrap">
<img src={qrDataUrl} alt="Deposit QR" width={180} height={180} />
</div>
)}
<div className="cashu-redeem">
<label className="cashu-redeem-label">Redeem Cashu token to faucet</label>
<textarea
className="cashu-redeem-input"
placeholder="Paste Cashu token (cashuA... or cashuB...)"
value={cashuToken}
onChange={(e) => setCashuToken(e.target.value)}
rows={2}
disabled={cashuLoading}
/>
<button
type="button"
className="cashu-redeem-btn"
onClick={handleRedeemCashu}
disabled={cashuLoading || !cashuToken.trim()}
>
{cashuLoading ? "Redeeming…" : "Redeem to faucet"}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import React from "react";
import { ClaimDenialPanel } from "./ClaimDenialPanel";
import { ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow";
import type { DenialState } from "../hooks/useClaimFlow";
interface EligibilityStepProps {
lightningAddress: string;
onLightningAddressChange: (value: string) => void;
lightningAddressTouched: boolean;
setLightningAddressTouched: (t: boolean) => void;
invalid: boolean;
fromProfile: boolean;
loading: boolean;
eligibilityProgressStep: number | null;
denial: DenialState | null;
onCheckEligibility: () => void;
onClearDenial: () => void;
onCheckAgain?: () => void;
}
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
export function EligibilityStep({
lightningAddress,
onLightningAddressChange,
lightningAddressTouched,
setLightningAddressTouched,
invalid,
fromProfile,
loading,
eligibilityProgressStep,
denial,
onCheckEligibility,
onClearDenial,
onCheckAgain,
}: EligibilityStepProps) {
const canCheck = !loading && lightningAddress.trim() !== "" && LIGHTNING_ADDRESS_REGEX.test(lightningAddress.trim());
return (
<div className="claim-wizard-step claim-wizard-step-eligibility">
<h3 className="claim-wizard-step-title">Check eligibility</h3>
<p className="claim-wizard-step-desc">
Enter your Lightning address. Well verify cooldown and calculate your payout.
</p>
<div className="claim-wizard-address-row">
<label htmlFor="wizard-lightning-address">Lightning address</label>
<input
id="wizard-lightning-address"
type="text"
value={lightningAddress}
onChange={(e) => onLightningAddressChange(e.target.value)}
onBlur={() => setLightningAddressTouched(true)}
placeholder="you@wallet.com"
disabled={loading}
readOnly={fromProfile}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? "wizard-lightning-hint" : undefined}
/>
{fromProfile && (
<span className="claim-wizard-profile-badge" title="From your Nostr profile">
From profile
</span>
)}
</div>
{invalid && (
<p id="wizard-lightning-hint" className="claim-wizard-input-hint" role="alert">
Enter a valid Lightning address (user@domain)
</p>
)}
{loading ? (
<div className="claim-wizard-progress" role="status" aria-live="polite">
<div className="claim-wizard-progress-bar" />
<p className="claim-wizard-progress-text">
{ELIGIBILITY_PROGRESS_STEPS[eligibilityProgressStep ?? 0]}
</p>
</div>
) : (
<div className="claim-wizard-step-actions">
<button
type="button"
className="btn-primary claim-wizard-btn-primary"
onClick={onCheckEligibility}
disabled={!canCheck}
>
Check eligibility
</button>
</div>
)}
{denial && (
<div className="claim-wizard-denial-wrap">
<ClaimDenialPanel
denial={denial}
onDismiss={onClearDenial}
onCheckAgain={onCheckAgain}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { Link } from "react-router-dom";
export function Footer() {
const year = new Date().getFullYear();
return (
<footer className="site-footer">
<div className="site-footer-inner">
<nav className="site-footer-nav">
<Link to="/">Home</Link>
<Link to="/transactions">Transactions</Link>
<a href="https://bitcoin.org/en/" target="_blank" rel="noopener noreferrer">
Bitcoin.org
</a>
</nav>
<p className="site-footer-copy">
© {year} Sats Faucet. Fund the faucet to keep it running.
</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,29 @@
import { Link, useLocation } from "react-router-dom";
export function Header() {
const location = useLocation();
return (
<header className="site-header">
<div className="site-header-inner">
<Link to="/" className="site-logo">
<span className="site-logo-text">Sats Faucet</span>
</Link>
<nav className="site-nav">
<Link
to="/"
className={location.pathname === "/" ? "site-nav-link active" : "site-nav-link"}
>
Home
</Link>
<Link
to="/transactions"
className={location.pathname === "/transactions" ? "site-nav-link active" : "site-nav-link"}
>
Transactions
</Link>
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,323 @@
import { useCallback, useRef, useState } from "react";
import { nip19, SimplePool, generateSecretKey, finalizeEvent } from "nostr-tools";
import { BunkerSigner, parseBunkerInput } from "nostr-tools/nip46";
import {
postAuthLoginWithSigner,
postAuthLogin,
postUserRefreshProfile,
setToken,
hasNostr,
type ApiError,
} from "../api";
import { Modal } from "./Modal";
import { RemoteSignerQR } from "./RemoteSignerQR";
import { isValidRemoteSignerInput } from "../utils/remoteSignerValidation";
const REMOTE_SIGNER_MODE_KEY = "nostr_remote_signer_mode";
const MOBILE_BREAKPOINT = 640;
type Tab = "extension" | "remote" | "nsec";
type RemoteSignerMode = "paste" | "scan";
function getDefaultRemoteSignerMode(): RemoteSignerMode {
if (typeof window === "undefined") return "paste";
const saved = localStorage.getItem(REMOTE_SIGNER_MODE_KEY);
if (saved === "paste" || saved === "scan") return saved;
return window.innerWidth <= MOBILE_BREAKPOINT ? "scan" : "paste";
}
interface Props {
open: boolean;
onClose: () => void;
onSuccess: (pubkey: string) => void;
}
export function LoginModal({ open, onClose, onSuccess }: Props) {
const [tab, setTab] = useState<Tab>("extension");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [remoteSignerMode, setRemoteSignerModeState] = useState<RemoteSignerMode>(getDefaultRemoteSignerMode);
const setRemoteSignerMode = useCallback((mode: RemoteSignerMode) => {
setRemoteSignerModeState(mode);
localStorage.setItem(REMOTE_SIGNER_MODE_KEY, mode);
}, []);
const [bunkerInput, setBunkerInput] = useState("");
const [pasteInlineError, setPasteInlineError] = useState<string | null>(null);
const [nsecNpubInput, setNsecNpubInput] = useState("");
const handleClose = () => {
if (!loading) {
setError(null);
setPasteInlineError(null);
setBunkerInput("");
setNsecNpubInput("");
onClose();
}
};
const runLogin = useCallback(
async (sign: () => Promise<{ token: string; pubkey: string }>) => {
setLoading(true);
setError(null);
setPasteInlineError(null);
try {
const { token, pubkey } = await sign();
setToken(token);
// Trigger profile fetch so backend has token before ClaimFlow effect runs (helps remote signer login)
postUserRefreshProfile().catch(() => {});
onSuccess(pubkey);
handleClose();
} catch (e) {
const msg = e instanceof Error ? e.message : (e as ApiError)?.message ?? "Login failed";
setError(msg);
} finally {
setLoading(false);
}
},
[onSuccess]
);
const runLoginRef = useRef(runLogin);
runLoginRef.current = runLogin;
const handleExtension = () => {
if (!hasNostr()) {
setError("Install a Nostr extension (e.g. nos2x, Alby) to use this option.");
return;
}
runLogin(() => postAuthLogin());
};
const handleRemoteSignerPaste = async () => {
const raw = bunkerInput.trim();
if (!raw) {
setError("Enter a bunker URL (bunker://...) or NIP-05 (user@domain).");
return;
}
if (!isValidRemoteSignerInput(raw)) {
setPasteInlineError("Enter a valid bunker URL (bunker://...) or NIP-05 (name@domain.com).");
return;
}
setPasteInlineError(null);
const bp = await parseBunkerInput(raw);
if (!bp || bp.relays.length === 0) {
setError("Could not parse bunker URL or NIP-05. Need at least one relay.");
return;
}
const pool = new SimplePool();
const clientSecret = generateSecretKey();
try {
const signer = BunkerSigner.fromBunker(clientSecret, bp, { pool });
await signer.connect();
const sign = (e: { kind: number; tags: string[][]; content: string; created_at: number }) => signer.signEvent(e);
await runLogin(() => postAuthLoginWithSigner(sign));
} finally {
pool.destroy();
}
};
const handleRemoteSignerConnectViaQR = useCallback(
async (sign: Parameters<typeof postAuthLoginWithSigner>[0]) => {
await runLoginRef.current(() => postAuthLoginWithSigner(sign));
},
[]
);
const handleRemoteSignerQRError = useCallback((message: string | null) => {
setError(message);
}, []);
const handlePasteBlur = () => {
const raw = bunkerInput.trim();
if (!raw) {
setPasteInlineError(null);
return;
}
setPasteInlineError(isValidRemoteSignerInput(raw) ? null : "Enter a valid bunker URL (bunker://...) or NIP-05 (name@domain.com).");
};
const handleNsecNpub = async () => {
const raw = nsecNpubInput.trim();
if (!raw) {
setError("Paste your nsec (secret key) or npub (public key).");
return;
}
try {
const decoded = nip19.decode(raw);
if (decoded.type === "npub") {
setError("Npub (public key) cannot sign. Paste your nsec to log in and claim, or use Extension / Remote signer.");
return;
}
if (decoded.type !== "nsec") {
setError("Unsupported format. Use nsec or npub.");
return;
}
const secretKey = decoded.data;
const sign = (e: { kind: number; tags: string[][]; content: string; created_at: number }) =>
Promise.resolve(finalizeEvent(e, secretKey));
await runLogin(() => postAuthLoginWithSigner(sign));
} catch (err) {
setError(err instanceof Error ? err.message : "Invalid nsec/npub. Check the format.");
}
};
return (
<Modal open={open} onClose={handleClose} title="Log in with Nostr" preventClose={loading}>
<div className="login-modal-tabs">
{(["extension", "remote", "nsec"] as const).map((t) => (
<button
key={t}
type="button"
className={`login-modal-tab ${tab === t ? "active" : ""}`}
onClick={() => {
setTab(t);
setError(null);
setPasteInlineError(null);
}}
>
{t === "extension" && "Extension"}
{t === "remote" && "Remote signer"}
{t === "nsec" && "Nsec / Npub"}
</button>
))}
</div>
<div className="login-modal-body">
{tab === "extension" && (
<div className="login-method">
<p className="login-method-desc">Use a Nostr browser extension (NIP-07) such as nos2x or Alby.</p>
<button
type="button"
className="login-method-btn"
onClick={handleExtension}
disabled={loading || !hasNostr()}
>
{loading ? "Connecting…" : hasNostr() ? "Login with extension" : "Extension not detected"}
</button>
</div>
)}
{tab === "remote" && (
<>
<div className="login-segment" role="group" aria-label="Remote signer method">
<button
type="button"
className="login-segment-option"
aria-pressed={remoteSignerMode === "paste"}
onClick={() => {
setRemoteSignerMode("paste");
setError(null);
}}
>
Paste link
</button>
<button
type="button"
className="login-segment-option"
aria-pressed={remoteSignerMode === "scan"}
onClick={() => {
setRemoteSignerMode("scan");
setError(null);
}}
>
Scan QR
</button>
</div>
{remoteSignerMode === "paste" && (
<div className="login-method">
<h3 className="login-method-title">Connect a remote signer</h3>
<p className="login-method-desc">
Paste a bunker URL or NIP-05. Your signer stays in control.
</p>
<input
type="text"
className="login-modal-input"
placeholder="bunker://... or name@domain.com"
value={bunkerInput}
onChange={(e) => {
setBunkerInput(e.target.value);
if (pasteInlineError) setPasteInlineError(null);
}}
onBlur={handlePasteBlur}
disabled={loading}
aria-invalid={!!pasteInlineError}
aria-describedby={pasteInlineError ? "paste-inline-error" : undefined}
/>
{pasteInlineError && (
<p id="paste-inline-error" className="login-modal-inline-error" role="alert">
{pasteInlineError}
</p>
)}
<button
type="button"
className="login-method-btn"
onClick={handleRemoteSignerPaste}
disabled={loading}
>
{loading ? "Connecting…" : "Connect"}
</button>
<button
type="button"
className="login-modal-secondary-link"
onClick={() => setRemoteSignerMode("scan")}
>
Prefer scanning? Scan QR
</button>
</div>
)}
{remoteSignerMode === "scan" && (
<div className="login-method">
<h3 className="login-method-title">Scan with your signer</h3>
<p className="login-method-desc">
Open your signer app and scan to connect. Approve requests in the signer.
</p>
<RemoteSignerQR
onConnect={handleRemoteSignerConnectViaQR}
onError={handleRemoteSignerQRError}
disabled={false}
signingIn={loading}
/>
<button
type="button"
className="login-modal-secondary-link"
onClick={() => setRemoteSignerMode("paste")}
>
Prefer pasting? Paste link
</button>
</div>
)}
</>
)}
{tab === "nsec" && (
<div className="login-method">
<p className="login-method-desc">
Paste your <strong>nsec</strong> to sign in (kept in this tab only). Npub alone cannot sign.
</p>
<textarea
className="login-modal-textarea"
placeholder="nsec1… or npub1…"
value={nsecNpubInput}
onChange={(e) => setNsecNpubInput(e.target.value)}
rows={3}
disabled={loading}
/>
<button
type="button"
className="login-method-btn"
onClick={handleNsecNpub}
disabled={loading}
>
{loading ? "Signing in…" : "Login with nsec"}
</button>
</div>
)}
{error && <p className="login-modal-error" role="alert">{error}</p>}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,118 @@
import React, { useEffect, useCallback, useRef } from "react";
import { motion, useReducedMotion } from "framer-motion";
interface ModalProps {
open: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
/** If true, do not close on overlay click (e.g. when loading). */
preventClose?: boolean;
}
const FOCUSABLE = "button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])";
function getFocusables(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => !el.hasAttribute("disabled") && el.offsetParent != null
);
}
export function Modal({ open, onClose, title, children, preventClose }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const reduceMotion = useReducedMotion();
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" && !preventClose) onClose();
},
[onClose, preventClose]
);
useEffect(() => {
if (!open) return;
document.addEventListener("keydown", handleEscape);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", handleEscape);
document.body.style.overflow = "";
};
}, [open, handleEscape]);
useEffect(() => {
if (!open || !modalRef.current) return;
const focusables = getFocusables(modalRef.current);
const first = focusables[0];
if (first) first.focus();
}, [open]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key !== "Tab" || !modalRef.current) return;
const focusables = getFocusables(modalRef.current);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const target = e.target as HTMLElement;
if (e.shiftKey) {
if (target === first) {
e.preventDefault();
last.focus();
}
} else {
if (target === last) {
e.preventDefault();
first.focus();
}
}
},
[]
);
const handleOverlayClick = () => {
if (!preventClose) onClose();
};
if (!open) return null;
const duration = reduceMotion ? 0 : 0.2;
return (
<motion.div
className="modal-overlay"
onClick={handleOverlayClick}
role="dialog"
aria-modal="true"
aria-labelledby={title ? "modal-title" : undefined}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration }}
>
<motion.div
ref={modalRef}
className="modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
initial={reduceMotion ? false : { opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration }}
>
{(title != null || !preventClose) && (
<div className="modal-header">
{title != null && (
<h2 id="modal-title" className="modal-title">
{title}
</h2>
)}
{!preventClose && (
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
×
</button>
)}
</div>
)}
<div className="modal-body">{children}</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,189 @@
import { useState, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import { Countdown } from "./Countdown";
import { CountUpNumber } from "./CountUpNumber";
import type { QuoteResult } from "../api";
const SLOT_DURATION_MS = 750;
const QUOTE_TTL_SECONDS = 60;
interface PayoutCardConfig {
minSats: number;
maxSats: number;
}
interface PayoutCardProps {
config: PayoutCardConfig;
quote: QuoteResult | null;
expired: boolean;
onRecheck: () => void;
}
function useReducedMotionOrDefault(): boolean {
if (typeof window === "undefined") return false;
try {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
} catch {
return false;
}
}
export function PayoutCard({ config, quote, expired, onRecheck }: PayoutCardProps) {
const [slotPhase, setSlotPhase] = useState<"idle" | "spinning" | "locked">("idle");
const [slotValue, setSlotValue] = useState(0);
const reduceMotion = useReducedMotionOrDefault();
const maxSats = Math.max(1, config.maxSats || 5);
const minSats = Math.max(1, config.minSats || 1);
const tiers = Array.from({ length: maxSats - minSats + 1 }, (_, i) => minSats + i);
const hasQuote = quote != null && !expired;
const payoutSats = quote?.payout_sats ?? 0;
const expiresAt = quote?.expires_at ?? 0;
const slotDuration = reduceMotion ? 0 : SLOT_DURATION_MS;
const hasAnimatedRef = useRef(false);
useEffect(() => {
if (!hasQuote || payoutSats < minSats || payoutSats > maxSats) {
setSlotPhase("idle");
setSlotValue(0);
hasAnimatedRef.current = false;
return;
}
if (hasAnimatedRef.current) {
setSlotPhase("locked");
setSlotValue(payoutSats);
return;
}
hasAnimatedRef.current = true;
setSlotPhase("spinning");
setSlotValue(0);
if (slotDuration <= 0) {
setSlotPhase("locked");
setSlotValue(payoutSats);
return;
}
const interval = setInterval(() => {
setSlotValue((v) => (v >= maxSats ? minSats : v + 1));
}, 50);
const timeout = setTimeout(() => {
clearInterval(interval);
setSlotPhase("locked");
setSlotValue(payoutSats);
}, slotDuration);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
}, [hasQuote, payoutSats, minSats, maxSats, slotDuration]);
if (expired && quote) {
return (
<motion.div
className="payout-card payout-card-expired"
initial={false}
animate={{ opacity: 1 }}
>
<p className="payout-card-expired-label">Quote expired</p>
<button type="button" className="payout-card-recheck-btn" onClick={onRecheck}>
<span className="payout-card-recheck-icon" aria-hidden></span>
Re-check
</button>
</motion.div>
);
}
if (hasQuote && slotPhase !== "idle") {
return (
<PayoutCardLocked
quote={quote}
expiresAt={expiresAt}
slotPhase={slotPhase}
slotValue={slotValue}
payoutSats={payoutSats}
reduceMotion={reduceMotion}
/>
);
}
return (
<motion.div className="payout-card payout-card-potential" layout>
<p className="payout-card-potential-label">Potential range</p>
<div className="payout-card-amount-row">
<span className="payout-card-amount-value">{minSats}{maxSats}</span>
<span className="payout-card-amount-unit">sats</span>
</div>
<div className="payout-card-dots" role="img" aria-label={`Payout range ${minSats} to ${maxSats} sats`}>
{tiers.map((i) => (
<div key={i} className="payout-card-dot" />
))}
</div>
</motion.div>
);
}
function PayoutCardLocked({
quote,
expiresAt,
slotPhase,
slotValue,
payoutSats,
reduceMotion,
}: {
quote: QuoteResult;
expiresAt: number;
slotPhase: "spinning" | "locked";
slotValue: number;
payoutSats: number;
reduceMotion: boolean;
}) {
const [secondsLeft, setSecondsLeft] = useState(() =>
Math.max(0, expiresAt - Math.floor(Date.now() / 1000))
);
useEffect(() => {
setSecondsLeft(Math.max(0, expiresAt - Math.floor(Date.now() / 1000)));
const t = setInterval(() => {
setSecondsLeft(Math.max(0, expiresAt - Math.floor(Date.now() / 1000)));
}, 1000);
return () => clearInterval(t);
}, [expiresAt]);
const progressPct = quote.expires_at > 0 ? (secondsLeft / QUOTE_TTL_SECONDS) * 100 : 0;
return (
<motion.div
className="payout-card payout-card-locked"
initial={reduceMotion ? false : { scale: 0.98 }}
animate={{ scale: 1 }}
transition={{ duration: 0.25 }}
>
<div className="payout-card-amount-row">
<span className="payout-card-amount-value">
{slotPhase === "locked" ? (
<CountUpNumber value={payoutSats} durationMs={400} />
) : (
slotValue
)}
</span>
<span className="payout-card-amount-unit">sats</span>
</div>
<p className="payout-card-locked-subtitle">
Locked for <Countdown targetUnixSeconds={expiresAt} format="clock" />
</p>
<div
className="payout-card-expiry-bar"
role="progressbar"
aria-valuenow={Math.round(progressPct)}
aria-valuemin={0}
aria-valuemax={100}
>
<motion.div
className="payout-card-expiry-fill"
animate={{ width: `${progressPct}%` }}
transition={{ duration: 1 }}
/>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,185 @@
import { useCallback, useEffect, useRef, useState } from "react";
import QRCode from "qrcode";
import { SimplePool, generateSecretKey, getPublicKey } from "nostr-tools";
import { BunkerSigner, createNostrConnectURI } from "nostr-tools/nip46";
import { useToast } from "../contexts/ToastContext";
import type { Nip98Signer } from "../api";
const DEFAULT_RELAYS = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
];
const WAIT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
function randomHex(bytes: number): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
interface RemoteSignerQRProps {
onConnect: (sign: Nip98Signer) => Promise<void>;
onError: (message: string | null) => void;
disabled?: boolean;
/** When true, show "Signing in…" but keep mounted so the signer pool stays alive. */
signingIn?: boolean;
}
export function RemoteSignerQR({ onConnect, onError, disabled, signingIn }: RemoteSignerQRProps) {
const { showToast } = useToast();
const [connectionUri, setConnectionUri] = useState<string | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copyLabel, setCopyLabel] = useState("Copy");
const [waiting, setWaiting] = useState(false);
const poolRef = useRef<SimplePool | null>(null);
const abortRef = useRef<AbortController | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimeoutRef = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
const generateUri = useCallback(() => {
const clientSecret = generateSecretKey();
const clientPubkey = getPublicKey(clientSecret);
const secret = randomHex(32);
const uri = createNostrConnectURI({
clientPubkey,
relays: DEFAULT_RELAYS,
secret,
name: "Sats Faucet",
url: typeof window !== "undefined" ? window.location.origin : "",
});
return { uri, clientSecret };
}, []);
const startWaiting = useCallback(
(uri: string, clientSecret: Uint8Array) => {
clearTimeoutRef();
const pool = new SimplePool();
poolRef.current = pool;
const abort = new AbortController();
abortRef.current = abort;
setWaiting(true);
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
if (abortRef.current?.signal.aborted) return;
abortRef.current?.abort();
onError("Connection timed out. Try regenerating the QR code.");
setWaiting(false);
}, WAIT_TIMEOUT_MS);
BunkerSigner.fromURI(clientSecret, uri, { pool }, abort.signal)
.then(async (signer) => {
if (abortRef.current?.signal.aborted) return;
clearTimeoutRef();
setWaiting(false);
const sign: Nip98Signer = (e) => signer.signEvent(e);
try {
await onConnect(sign);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
onError(msg);
}
})
.catch((err) => {
if (abortRef.current?.signal.aborted) return;
clearTimeoutRef();
setWaiting(false);
const msg = err instanceof Error ? err.message : String(err);
onError(msg);
});
},
[onConnect, onError, clearTimeoutRef]
);
const regenerate = useCallback(() => {
clearTimeoutRef();
abortRef.current?.abort();
abortRef.current = null;
if (poolRef.current) {
poolRef.current.destroy();
poolRef.current = null;
}
onError(null);
const { uri, clientSecret } = generateUri();
setConnectionUri(uri);
QRCode.toDataURL(uri, { width: 220, margin: 1 }).then(setQrDataUrl).catch(() => setQrDataUrl(null));
startWaiting(uri, clientSecret);
}, [generateUri, startWaiting, onError, clearTimeoutRef]);
useEffect(() => {
if (disabled) return;
const { uri, clientSecret } = generateUri();
setConnectionUri(uri);
setQrDataUrl(null);
QRCode.toDataURL(uri, { width: 220, margin: 1 }).then(setQrDataUrl).catch(() => setQrDataUrl(null));
startWaiting(uri, clientSecret);
return () => {
clearTimeoutRef();
abortRef.current?.abort();
if (poolRef.current) {
poolRef.current.destroy();
poolRef.current = null;
}
};
}, [disabled, generateUri, startWaiting, clearTimeoutRef]);
const handleCopy = useCallback(() => {
if (!connectionUri) return;
navigator.clipboard.writeText(connectionUri).then(() => {
setCopyLabel("Copied");
showToast("Copied");
setTimeout(() => setCopyLabel("Copy"), 1500);
});
}, [connectionUri, showToast]);
if (disabled) return null;
return (
<div className="remote-signer-qr-content">
<div className="remote-signer-qr-card">
{qrDataUrl ? (
<img src={qrDataUrl} alt="Scan to connect with your signer" />
) : (
<span className="remote-signer-qr-placeholder">Generating QR</span>
)}
</div>
{signingIn ? (
<p className="remote-signer-waiting" role="status">
Signing in
</p>
) : waiting ? (
<p className="remote-signer-waiting" role="status">
Waiting for signer approval
</p>
) : null}
<div className="remote-signer-connection-row">
<input
type="text"
readOnly
value={connectionUri ?? ""}
aria-label="Connection string"
/>
<button
type="button"
className="remote-signer-copy-btn"
onClick={handleCopy}
disabled={!connectionUri}
aria-label="Copy connection string"
>
{copyLabel}
</button>
</div>
<button type="button" className="remote-signer-regenerate-btn" onClick={regenerate}>
Regenerate QR
</button>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import React from "react";
import { motion } from "framer-motion";
import { getStats, type Stats } from "../api";
const REFRESH_MS = 45_000;
export interface StatsSectionProps {
/** Optional refetch trigger: when this value changes, stats are refetched. */
refetchTrigger?: number;
}
function AnimatedNumber({ value }: { value: number }) {
return (
<motion.span
key={value}
initial={{ opacity: 0.6 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
{value.toLocaleString()}
</motion.span>
);
}
export function StatsSection({ refetchTrigger }: StatsSectionProps) {
const [stats, setStats] = React.useState<Stats | null>(null);
const [loading, setLoading] = React.useState(true);
const [refreshing, setRefreshing] = React.useState(false);
const load = React.useCallback(async (userRefresh = false) => {
if (userRefresh) setRefreshing(true);
try {
const s = await getStats();
setStats(s);
} catch {
setStats(null);
} finally {
setLoading(false);
if (userRefresh) setRefreshing(false);
}
}, []);
React.useEffect(() => {
load();
const t = setInterval(load, REFRESH_MS);
return () => clearInterval(t);
}, [load, refetchTrigger]);
if (loading && !stats) {
return (
<div className="stats-box stats-skeleton">
<div className="skeleton-line balance" />
<div className="skeleton-line short" />
<div className="skeleton-line" />
<div className="skeleton-line" />
<div className="skeleton-line" />
</div>
);
}
if (!stats) return <div className="stats-box"><p>Stats unavailable</p></div>;
const n = (v: number | undefined | null) => Number(v ?? 0);
const ts = (v: number | undefined | null) => new Date(Number(v ?? 0) * 1000).toLocaleString();
const dailyBudget = Number(stats.dailyBudgetSats) || 1;
const budgetUsed = 0; /* API does not expose "spent today" in sats */
const budgetPct = Math.min(100, (budgetUsed / dailyBudget) * 100);
return (
<div className="stats-box">
<h3>Faucet stats</h3>
<p className="stats-balance">
<AnimatedNumber value={n(stats.balanceSats)} /> sats
</p>
<p className="stats-balance-label">Pool balance</p>
<div className="stats-progress-wrap">
<div className="stats-progress-label">
Daily budget: <AnimatedNumber value={n(stats.dailyBudgetSats)} /> sats
</div>
<div className="stats-progress-bar">
<div className="stats-progress-fill" style={{ width: `${100 - budgetPct}%` }} />
</div>
</div>
<div className="stats-rows">
<div className="stats-row">
<span>Total paid</span>
<span><AnimatedNumber value={n(stats.totalPaidSats)} /> sats</span>
</div>
<div className="stats-row">
<span>Total claims</span>
<span><AnimatedNumber value={n(stats.totalClaims)} /></span>
</div>
<div className="stats-row">
<span>Claims (24h)</span>
<span><AnimatedNumber value={n(stats.claimsLast24h)} /></span>
</div>
</div>
{stats.recentPayouts?.length > 0 && (
<>
<h3 className="stats-recent-title">Recent payouts</h3>
<ul className="stats-recent-list">
{stats.recentPayouts.slice(0, 10).map((p, i) => (
<li key={i}>
{p.pubkey_prefix ?? "…"} {n(p.payout_sats).toLocaleString()} sats {ts(p.claimed_at)}
</li>
))}
</ul>
</>
)}
<button
type="button"
className={`entropy-btn stats-refresh ${refreshing ? "stats-refresh--spinning" : ""}`}
onClick={() => load(true)}
disabled={refreshing}
>
<span className="stats-refresh-icon" aria-hidden></span>
Refresh
</button>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import React from "react";
const STEPS = [
{ step: 1, label: "Connect" },
{ step: 2, label: "Check" },
{ step: 3, label: "Confirm" },
{ step: 4, label: "Receive" },
] as const;
interface StepIndicatorProps {
currentStep: 1 | 2 | 3 | 4;
}
function CheckIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M20 6L9 17l-5-5" />
</svg>
);
}
export function StepIndicator({ currentStep }: StepIndicatorProps) {
return (
<div className="step-indicator" role="progressbar" aria-valuenow={currentStep} aria-valuemin={1} aria-valuemax={4} aria-label={`Step ${currentStep} of 4`}>
<ol className="step-indicator-list">
{STEPS.map(({ step, label }, index) => {
const isCompleted = step < currentStep;
const isCurrent = step === currentStep;
const isFuture = step > currentStep;
return (
<li
key={step}
className={`step-indicator-item ${isCurrent ? "step-indicator-item--current" : ""} ${isCompleted ? "step-indicator-item--completed" : ""} ${isFuture ? "step-indicator-item--future" : ""}`}
aria-current={isCurrent ? "step" : undefined}
>
{index > 0 && <span className="step-indicator-connector" aria-hidden />}
<span className="step-indicator-marker">
{isCompleted ? <CheckIcon /> : <span className="step-indicator-number">{step}</span>}
</span>
<span className="step-indicator-label">{label}</span>
</li>
);
})}
</ol>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import React from "react";
import { Countdown } from "./Countdown";
import { useToast } from "../contexts/ToastContext";
import type { ConfirmResult } from "../api";
interface SuccessStepProps {
result: ConfirmResult;
onDone: () => void;
onClaimAgain: () => void;
}
function CheckIcon() {
return (
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M20 6L9 17l-5-5" />
</svg>
);
}
export function SuccessStep({ result, onDone, onClaimAgain }: SuccessStepProps) {
const { showToast } = useToast();
const amount = result.payout_sats ?? 0;
const copyPaymentHash = () => {
const hash = result.payment_hash;
if (!hash) return;
navigator.clipboard.writeText(hash).then(() => showToast("Copied"));
};
return (
<div className="claim-wizard-step claim-wizard-step-success">
<div className="claim-wizard-success-icon" aria-hidden>
<CheckIcon />
</div>
<h3 className="claim-wizard-step-title">Sats sent</h3>
<p className="claim-wizard-success-amount">
<span className="claim-wizard-success-amount-value">{amount}</span>
<span className="claim-wizard-success-amount-unit"> sats</span>
</p>
{result.payment_hash && (
<div className="claim-wizard-success-payment-hash">
<code className="claim-wizard-payment-hash-code">{result.payment_hash.slice(0, 16)}</code>
<button type="button" className="btn-secondary claim-wizard-copy-btn" onClick={copyPaymentHash}>
Copy hash
</button>
</div>
)}
{result.next_eligible_at != null && (
<p className="claim-wizard-success-next">
Next eligible: <Countdown targetUnixSeconds={result.next_eligible_at} format="duration" />
</p>
)}
<div className="claim-wizard-step-actions claim-wizard-step-actions--row">
<button type="button" className="btn-primary claim-wizard-btn-primary" onClick={onDone}>
Done
</button>
<button type="button" className="btn-secondary" onClick={onClaimAgain}>
Claim again later
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { useEffect } from "react";
interface ToastProps {
message: string;
visible: boolean;
onDismiss: () => void;
durationMs?: number;
}
export function Toast({ message, visible, onDismiss, durationMs = 2500 }: ToastProps) {
useEffect(() => {
if (!visible || !message) return;
const t = setTimeout(onDismiss, durationMs);
return () => clearTimeout(t);
}, [visible, message, durationMs, onDismiss]);
if (!visible || !message) return null;
return (
<div className="toast" role="status" aria-live="polite">
{message}
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import { Toast } from "../components/Toast";
interface ToastContextValue {
showToast: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}
interface ToastProviderProps {
children: ReactNode;
}
export function ToastProvider({ children }: ToastProviderProps) {
const [message, setMessage] = useState("");
const [visible, setVisible] = useState(false);
const showToast = useCallback((msg: string) => {
setMessage(msg);
setVisible(true);
}, []);
const onDismiss = useCallback(() => setVisible(false), []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<Toast message={message} visible={visible} onDismiss={onDismiss} />
</ToastContext.Provider>
);
}

View File

@@ -0,0 +1,182 @@
import { useState, useCallback, useRef, useMemo } from "react";
import { postClaimQuote, postClaimConfirm, type QuoteResult, type ConfirmResult, type ApiError } from "../api";
export type ClaimLoadingState = "idle" | "quote" | "confirm";
/** Single claim flow state for UI; disconnected is inferred by caller when !pubkey */
export type ClaimFlowState =
| "connected_idle"
| "quoting"
| "quote_ready"
| "confirming"
| "success"
| "denied"
| "error";
export const ELIGIBILITY_PROGRESS_STEPS = [
"Verifying Nostr signature",
"Checking cooldown",
"Calculating payout",
"Locking in amount",
] as const;
export const ELIGIBILITY_MIN_MS = 900;
export interface DenialState {
message: string;
next_eligible_at?: number;
/** From API error code; used in "Why?" expandable */
code?: string;
}
export interface ConfirmErrorState {
message: string;
allowRetry: boolean;
}
export interface UseClaimFlowResult {
quote: QuoteResult | null;
success: ConfirmResult | null;
loading: ClaimLoadingState;
denial: DenialState | null;
confirmError: ConfirmErrorState | null;
/** Derived state for UI; use with pubkey to infer disconnected */
claimState: ClaimFlowState;
/** 0-3 during quoting; null otherwise */
eligibilityProgressStep: number | null;
checkEligibility: (lightningAddress: string) => Promise<void>;
confirmClaim: () => Promise<void>;
cancelQuote: () => void;
resetSuccess: () => void;
clearDenial: () => void;
clearConfirmError: () => void;
}
const RETRY_ALLOWED_CODES = new Set(["payout_failed"]);
function isQuoteExpired(quote: QuoteResult | null): boolean {
if (!quote) return true;
return quote.expires_at <= Math.floor(Date.now() / 1000);
}
export function useClaimFlow(): UseClaimFlowResult {
const [quote, setQuote] = useState<QuoteResult | null>(null);
const [success, setSuccess] = useState<ConfirmResult | null>(null);
const [loading, setLoading] = useState<ClaimLoadingState>("idle");
const [denial, setDenial] = useState<DenialState | null>(null);
const [confirmError, setConfirmError] = useState<ConfirmErrorState | null>(null);
const [eligibilityProgressStep, setEligibilityProgressStep] = useState<number | null>(null);
const quoteIdRef = useRef<string | null>(null);
const claimState: ClaimFlowState = useMemo(() => {
if (success) return "success";
if (confirmError) return "error";
if (denial) return "denied";
if (loading === "confirm") return "confirming";
if (loading === "quote") return "quoting";
if (quote && !isQuoteExpired(quote)) return "quote_ready";
return "connected_idle";
}, [success, confirmError, denial, loading, quote]);
const checkEligibility = useCallback(async (lightningAddress: string) => {
const addr = lightningAddress.trim();
if (!/^[^@]+@[^@]+$/.test(addr)) {
setDenial({ message: "Enter a valid Lightning address (user@domain)." });
return;
}
setDenial(null);
setConfirmError(null);
setQuote(null);
quoteIdRef.current = null;
setLoading("quote");
setEligibilityProgressStep(0);
const stepInterval = setInterval(() => {
setEligibilityProgressStep((s) => (s === null ? 0 : Math.min(3, s + 1)));
}, 300);
const apiPromise = postClaimQuote(addr);
const minDelay = new Promise<void>((r) => setTimeout(r, ELIGIBILITY_MIN_MS));
try {
const q = await Promise.all([apiPromise, minDelay]).then(([result]) => result);
if (q?.quote_id && q?.expires_at && typeof q.payout_sats === "number") {
quoteIdRef.current = q.quote_id;
setQuote(q);
} else {
setDenial({ message: "Invalid quote from server. Please try again." });
}
} catch (e) {
const err = e as ApiError;
setDenial({
message: err.message ?? "Request failed",
next_eligible_at: err.next_eligible_at,
code: err.code,
});
} finally {
clearInterval(stepInterval);
setEligibilityProgressStep(null);
setLoading("idle");
}
}, []);
const confirmClaim = useCallback(async () => {
const idToConfirm = quote?.quote_id ?? quoteIdRef.current;
if (!idToConfirm) return;
setConfirmError(null);
setLoading("confirm");
try {
const result = await postClaimConfirm(idToConfirm);
if (result?.success) {
setSuccess(result);
setQuote(null);
quoteIdRef.current = null;
} else {
setConfirmError({
message: result?.message ?? "Claim was not completed.",
allowRetry: false,
});
}
} catch (e) {
const err = e as ApiError;
const message = err.details
? `${err.message ?? "Payment failed"}: ${err.details}`
: (err.message ?? "Payment failed");
setConfirmError({
message,
allowRetry: RETRY_ALLOWED_CODES.has(err.code),
});
} finally {
setLoading("idle");
}
}, [quote]);
const cancelQuote = useCallback(() => {
quoteIdRef.current = null;
setQuote(null);
setConfirmError(null);
}, []);
const resetSuccess = useCallback(() => {
setSuccess(null);
}, []);
const clearDenial = useCallback(() => setDenial(null), []);
const clearConfirmError = useCallback(() => setConfirmError(null), []);
return {
quote,
success,
loading,
denial,
confirmError,
claimState,
eligibilityProgressStep,
checkEligibility,
confirmClaim,
cancelQuote,
resetSuccess,
clearDenial,
clearConfirmError,
};
}

21
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,21 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ErrorBoundary } from "./ErrorBoundary";
import { ToastProvider } from "./contexts/ToastContext";
import "./styles/global.css";
const rootEl = document.getElementById("root");
if (!rootEl) {
document.body.innerHTML = "<p>Root element #root not found.</p>";
} else {
ReactDOM.createRoot(rootEl).render(
<React.StrictMode>
<ErrorBoundary>
<ToastProvider>
<App />
</ToastProvider>
</ErrorBoundary>
</React.StrictMode>
);
}

View File

@@ -0,0 +1,142 @@
import { useState, useEffect, useMemo } from "react";
import { getStats, type Stats, type DepositSource } from "../api";
type TxDirection = "in" | "out";
type TxType = "lightning" | "cashu";
interface UnifiedTx {
at: number;
direction: TxDirection;
type: TxType;
amount_sats: number;
details: string;
}
function formatSource(s: DepositSource): TxType {
return s === "cashu" ? "cashu" : "lightning";
}
export function TransactionsPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getStats()
.then((s) => {
if (!cancelled) setStats(s);
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const n = (v: number | undefined | null) => Number(v ?? 0).toLocaleString();
/** Display amount: backend may have stored incoming Lightning in msats; show sats. */
const displaySats = (tx: UnifiedTx): number => {
const a = tx.amount_sats;
if (tx.direction === "in" && tx.type === "lightning" && a >= 1000) return Math.floor(a / 1000);
return a;
};
const formatDate = (ts: number) => {
if (!ts || ts < 1e9) return "—";
return new Date(ts * 1000).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
};
const transactions = useMemo((): UnifiedTx[] => {
if (!stats) return [];
const payouts = (stats.recentPayouts ?? []).map((p) => ({
at: p.claimed_at,
direction: "out" as TxDirection,
type: "lightning" as TxType,
amount_sats: p.payout_sats,
details: p.pubkey_prefix ?? "—",
}));
const deposits = (stats.recentDeposits ?? []).map((d) => ({
at: Number(d.created_at) || 0,
direction: "in" as TxDirection,
type: formatSource(d.source),
amount_sats: d.amount_sats,
details: d.source === "cashu" ? "Cashu redeem" : "Lightning",
}));
const merged = [...payouts, ...deposits].sort((a, b) => b.at - a.at);
return merged.slice(0, 50);
}, [stats]);
return (
<div className="transactions-page">
<h1 className="transactions-title">Transactions</h1>
<p className="transactions-intro">
Incoming (deposits) and outgoing (faucet payouts). Lightning and Cashu.
</p>
{loading && (
<div className="transactions-loading">
<div className="stats-skeleton">
<div className="skeleton-line balance" />
<div className="skeleton-line" />
<div className="skeleton-line" />
<div className="skeleton-line" />
</div>
</div>
)}
{error && (
<div className="transactions-error">
<p>{error}</p>
</div>
)}
{!loading && !error && (
<div className="transactions-box">
{transactions.length === 0 ? (
<p className="transactions-empty">No transactions yet.</p>
) : (
<table className="transactions-table">
<thead>
<tr>
<th>Date</th>
<th>Direction</th>
<th>Type</th>
<th>Amount</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{transactions.map((tx, i) => (
<tr key={i}>
<td>{formatDate(tx.at)}</td>
<td>
<span className={`transactions-direction transactions-direction--${tx.direction}`}>
{tx.direction === "in" ? "In" : "Out"}
</span>
</td>
<td>
<span className={`transactions-type transactions-type--${tx.type}`}>
{tx.type === "cashu" ? "Cashu" : "Lightning"}
</span>
</td>
<td className="transactions-amount">{n(displaySats(tx))} sats</td>
<td className="transactions-details">{tx.details}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
);
}

6
frontend/src/qrcode.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module "qrcode" {
export function toDataURL(
text: string,
options?: { width?: number; margin?: number }
): Promise<string>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
/**
* Validation helpers for remote signer input (bunker URL or NIP-05).
*/
export function isBunkerUrl(str: string): boolean {
const trimmed = str.trim();
if (!trimmed) return false;
return trimmed.toLowerCase().startsWith("bunker://");
}
/**
* NIP-05 style identifier: name@domain with at least one dot in the domain.
*/
export function isNip05(str: string): boolean {
const trimmed = str.trim();
if (!trimmed) return false;
return /^[^@]+@[^@]+\..+$/.test(trimmed);
}
export function isValidRemoteSignerInput(str: string): boolean {
return isBunkerUrl(str) || isNip05(str);
}

9
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

20
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true
},
"include": ["vite.config.ts"]
}

22
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:3001",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
"/claim": { target: "http://localhost:3001", changeOrigin: true },
"/auth": { target: "http://localhost:3001", changeOrigin: true },
"/user": { target: "http://localhost:3001", changeOrigin: true },
"/config": { target: "http://localhost:3001", changeOrigin: true },
"/stats": { target: "http://localhost:3001", changeOrigin: true },
"/deposit": { target: "http://localhost:3001", changeOrigin: true },
},
},
});

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "lnfaucet",
"private": true,
"scripts": {
"build": "npm run build:backend && npm run build:frontend",
"build:backend": "cd backend && npm run build",
"build:frontend": "cd frontend && npm run build",
"start": "cd backend && npm start",
"dev:backend": "cd backend && npm run dev",
"dev:frontend": "cd frontend && npm run dev"
}
}