first commit
Made-with: Cursor
This commit is contained in:
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal 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
62
backend/.env.example
Normal 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
2303
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
backend/package.json
Normal file
32
backend/package.json
Normal 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
32
backend/src/auth/jwt.ts
Normal 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
87
backend/src/config.ts
Normal 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
24
backend/src/db/index.ts
Normal 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
14
backend/src/db/migrate.ts
Normal 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
347
backend/src/db/pg.ts
Normal 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),
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
50
backend/src/db/schema-sqlite.sql
Normal file
50
backend/src/db/schema-sqlite.sql
Normal 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);
|
||||
69
backend/src/db/schema.pg.sql
Normal file
69
backend/src/db/schema.pg.sql
Normal 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
70
backend/src/db/schema.sql
Normal 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
287
backend/src/db/sqlite.ts
Normal 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
101
backend/src/db/types.ts
Normal 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
64
backend/src/index.ts
Normal 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);
|
||||
});
|
||||
22
backend/src/middleware/auth.ts
Normal file
22
backend/src/middleware/auth.ts
Normal 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);
|
||||
}
|
||||
40
backend/src/middleware/ip.ts
Normal file
40
backend/src/middleware/ip.ts
Normal 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();
|
||||
}
|
||||
153
backend/src/middleware/nip98.ts
Normal file
153
backend/src/middleware/nip98.ts
Normal 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();
|
||||
}
|
||||
29
backend/src/routes/auth.ts
Normal file
29
backend/src/routes/auth.ts
Normal 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
159
backend/src/routes/claim.ts
Normal 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;
|
||||
134
backend/src/routes/public.ts
Normal file
134
backend/src/routes/public.ts
Normal 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;
|
||||
30
backend/src/routes/user.ts
Normal file
30
backend/src/routes/user.ts
Normal 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;
|
||||
126
backend/src/services/eligibility.ts
Normal file
126
backend/src/services/eligibility.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { config } from "../config.js";
|
||||
import { getDb } from "../db/index.js";
|
||||
import { getWalletBalanceSats } from "./lnbits.js";
|
||||
import { fetchAndScorePubkey } from "./nostr.js";
|
||||
|
||||
export type DenialCode =
|
||||
| "faucet_disabled"
|
||||
| "emergency_stop"
|
||||
| "insufficient_balance"
|
||||
| "daily_budget_exceeded"
|
||||
| "cooldown_pubkey"
|
||||
| "cooldown_ip"
|
||||
| "account_too_new"
|
||||
| "low_activity"
|
||||
| "invalid_nip98"
|
||||
| "invalid_lightning_address"
|
||||
| "quote_expired"
|
||||
| "payout_failed";
|
||||
|
||||
export interface EligibilityResult {
|
||||
eligible: boolean;
|
||||
denialCode?: DenialCode;
|
||||
denialMessage?: string;
|
||||
nextEligibleAt?: number;
|
||||
}
|
||||
|
||||
const SECONDS_PER_DAY = 86400;
|
||||
|
||||
export async function checkEligibility(pubkey: string, ipHash: string): Promise<EligibilityResult> {
|
||||
if (config.emergencyStop) {
|
||||
return {
|
||||
eligible: false,
|
||||
denialCode: "emergency_stop",
|
||||
denialMessage: "The faucet is temporarily in maintenance. Please try again later.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.faucetEnabled) {
|
||||
return {
|
||||
eligible: false,
|
||||
denialCode: "faucet_disabled",
|
||||
denialMessage: "The faucet is currently disabled.",
|
||||
};
|
||||
}
|
||||
|
||||
let balanceSats: number;
|
||||
try {
|
||||
balanceSats = await getWalletBalanceSats();
|
||||
} catch {
|
||||
return {
|
||||
eligible: false,
|
||||
denialCode: "insufficient_balance",
|
||||
denialMessage: "Unable to check faucet balance. Please try again later.",
|
||||
};
|
||||
}
|
||||
|
||||
if (balanceSats < config.faucetMinSats) {
|
||||
return {
|
||||
eligible: false,
|
||||
denialCode: "insufficient_balance",
|
||||
denialMessage: balanceSats === 0
|
||||
? "The faucet pool is empty. Donations welcome!"
|
||||
: `The faucet pool is too low to pay out (${balanceSats} sats). Donations welcome!`,
|
||||
};
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const lastPubkeyClaim = await db.getLastSuccessfulClaimByPubkey(pubkey);
|
||||
const cooldownEnd = lastPubkeyClaim
|
||||
? lastPubkeyClaim.claimed_at + config.cooldownDays * SECONDS_PER_DAY
|
||||
: 0;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (cooldownEnd > now) {
|
||||
return {
|
||||
eligible: false,
|
||||
denialCode: "cooldown_pubkey",
|
||||
denialMessage: "You have already claimed recently.",
|
||||
nextEligibleAt: cooldownEnd,
|
||||
};
|
||||
}
|
||||
|
||||
const ipSince = now - config.ipCooldownDays * SECONDS_PER_DAY;
|
||||
const ipClaimCount = await db.getClaimCountForIpSince(ipHash, ipSince);
|
||||
if (ipClaimCount >= config.maxClaimsPerIpPerPeriod) {
|
||||
const lastIpClaim = await db.getLastClaimByIpHash(ipHash);
|
||||
const ipNextAt = lastIpClaim ? lastIpClaim.claimed_at + config.ipCooldownDays * SECONDS_PER_DAY : 0;
|
||||
return {
|
||||
eligible: false,
|
||||
denialCode: "cooldown_ip",
|
||||
denialMessage: "This IP has reached the claim limit for this period.",
|
||||
nextEligibleAt: ipNextAt,
|
||||
};
|
||||
}
|
||||
|
||||
const profile = await fetchAndScorePubkey(pubkey);
|
||||
const minAgeSec = config.minAccountAgeDays * SECONDS_PER_DAY;
|
||||
const cutoff = now - minAgeSec;
|
||||
if (profile.nostrFirstSeenAt === null || profile.nostrFirstSeenAt > cutoff) {
|
||||
return {
|
||||
eligible: false,
|
||||
denialCode: "account_too_new",
|
||||
denialMessage: `Your Nostr account must be at least ${config.minAccountAgeDays} days old.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (profile.activityScore < config.minActivityScore) {
|
||||
return {
|
||||
eligible: false,
|
||||
denialCode: "low_activity",
|
||||
denialMessage: `Your account does not meet the minimum activity score (${config.minActivityScore}). Be more active on Nostr and try again.`,
|
||||
};
|
||||
}
|
||||
|
||||
const since24h = now - 86400;
|
||||
const claims24h = await db.getClaimsCountSince(since24h);
|
||||
if (claims24h >= config.maxClaimsPerDay) {
|
||||
return {
|
||||
eligible: false,
|
||||
denialCode: "daily_budget_exceeded",
|
||||
denialMessage: "Daily claim limit reached. Try again tomorrow.",
|
||||
};
|
||||
}
|
||||
|
||||
return { eligible: true };
|
||||
}
|
||||
190
backend/src/services/lnbits.ts
Normal file
190
backend/src/services/lnbits.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { config } from "../config.js";
|
||||
|
||||
const base = config.lnbitsBaseUrl;
|
||||
const adminKey = config.lnbitsAdminKey;
|
||||
const walletId = config.lnbitsWalletId;
|
||||
|
||||
export async function getWalletBalanceSats(): Promise<number> {
|
||||
const res = await fetch(`${base}/api/v1/wallet`, {
|
||||
headers: { "X-Api-Key": adminKey },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`LNbits wallet fetch failed: ${res.status} ${text}`);
|
||||
}
|
||||
const data = (await res.json()) as { balance?: number };
|
||||
return Math.floor((data.balance ?? 0) / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay to a Lightning address via LNURL.
|
||||
* 1. Resolve Lightning address to LNURL (GET https://domain/.well-known/lnurlp/user)
|
||||
* 2. Call callback with amount in millisats
|
||||
*/
|
||||
export async function payToLightningAddress(
|
||||
lightningAddress: string,
|
||||
sats: number
|
||||
): Promise<{ paymentHash: string }> {
|
||||
const [user, domain] = lightningAddress.split("@");
|
||||
if (!user || !domain) {
|
||||
console.error("[lnbits] Invalid Lightning address format:", lightningAddress);
|
||||
throw new Error("Invalid Lightning address format");
|
||||
}
|
||||
|
||||
const lnurlpUrl = `https://${domain}/.well-known/lnurlp/${user}`;
|
||||
const lnurlRes = await fetch(lnurlpUrl);
|
||||
if (!lnurlRes.ok) {
|
||||
const text = await lnurlRes.text();
|
||||
console.error("[lnbits] LNURLp resolution failed:", {
|
||||
lightningAddress,
|
||||
lnurlpUrl,
|
||||
status: lnurlRes.status,
|
||||
statusText: lnurlRes.statusText,
|
||||
body: text.slice(0, 500),
|
||||
});
|
||||
throw new Error(`Could not resolve Lightning address: ${lnurlRes.status} ${text.slice(0, 200)}`);
|
||||
}
|
||||
const lnurlData = (await lnurlRes.json()) as { callback?: string; minSendable?: number; maxSendable?: number };
|
||||
const callback = lnurlData.callback;
|
||||
if (!callback) {
|
||||
console.error("[lnbits] No callback in LNURLp response:", { lightningAddress, lnurlpUrl, lnurlData });
|
||||
throw new Error("No callback in LNURLp");
|
||||
}
|
||||
|
||||
const millisats = sats * 1000;
|
||||
const separator = callback.includes("?") ? "&" : "?";
|
||||
const payReqUrl = `${callback}${separator}amount=${millisats}`;
|
||||
const payRes = await fetch(payReqUrl);
|
||||
const payBody = await payRes.text();
|
||||
if (!payRes.ok) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(payBody);
|
||||
} catch {
|
||||
parsed = payBody;
|
||||
}
|
||||
console.error("[lnbits] LNURL pay request failed:", {
|
||||
lightningAddress,
|
||||
sats,
|
||||
millisats,
|
||||
callbackHost: new URL(callback).host,
|
||||
status: payRes.status,
|
||||
statusText: payRes.statusText,
|
||||
body: parsed,
|
||||
});
|
||||
const detail = typeof parsed === "object" && parsed !== null && "reason" in parsed
|
||||
? (parsed as { reason?: string }).reason
|
||||
: payBody.slice(0, 300);
|
||||
throw new Error(`LNURL pay request failed: ${payRes.status} ${detail}`);
|
||||
}
|
||||
const payData = JSON.parse(payBody) as { pr?: string; reason?: string };
|
||||
const pr = payData.pr;
|
||||
if (!pr) {
|
||||
console.error("[lnbits] No invoice (pr) in pay response:", { lightningAddress, payData });
|
||||
throw new Error(`No invoice in pay response: ${payData.reason ?? JSON.stringify(payData).slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const payResult = await fetch(`${base}/api/v1/payments`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Api-Key": adminKey },
|
||||
body: JSON.stringify({ out: true, bolt11: pr }),
|
||||
});
|
||||
if (!payResult.ok) {
|
||||
const errText = await payResult.text();
|
||||
console.error("[lnbits] LNbits bolt11 payment failed:", {
|
||||
lightningAddress,
|
||||
sats,
|
||||
status: payResult.status,
|
||||
body: errText.slice(0, 500),
|
||||
});
|
||||
throw new Error(`LNbits pay failed: ${payResult.status} ${errText}`);
|
||||
}
|
||||
const result = (await payResult.json()) as { payment_hash?: string };
|
||||
return { paymentHash: result.payment_hash ?? "" };
|
||||
}
|
||||
|
||||
/** LNbits payment list item (GET /api/v1/payments). Amount in millisatoshis; positive = incoming, negative = outgoing. */
|
||||
/** Per LNbits OpenAPI: time, created_at, updated_at are "string" format "date-time" (ISO 8601). */
|
||||
export interface LnbitsPaymentItem {
|
||||
payment_hash?: string;
|
||||
amount?: number;
|
||||
pending?: boolean;
|
||||
time?: number | string;
|
||||
created_at?: number | string;
|
||||
updated_at?: number | string;
|
||||
timestamp?: number;
|
||||
date?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const MIN_VALID_UNIX = 1e9;
|
||||
|
||||
function parsePaymentTime(raw: unknown): number {
|
||||
if (raw == null) return 0;
|
||||
if (typeof raw === "number") {
|
||||
const ts = raw > 1e12 ? Math.floor(raw / 1000) : raw;
|
||||
return ts >= MIN_VALID_UNIX ? ts : 0;
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
const ms = Date.parse(raw);
|
||||
if (Number.isNaN(ms)) return 0;
|
||||
return Math.floor(ms / 1000);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizePaymentTime(p: LnbitsPaymentItem): number {
|
||||
const ts =
|
||||
parsePaymentTime(p.time) ||
|
||||
parsePaymentTime(p.created_at) ||
|
||||
parsePaymentTime(p.updated_at) ||
|
||||
parsePaymentTime(p.timestamp) ||
|
||||
parsePaymentTime(p.date);
|
||||
if (ts >= MIN_VALID_UNIX) return ts;
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recent payments from LNbits and return paid incoming ones (amount > 0, not pending).
|
||||
* LNbits returns amount in millisatoshis; we convert to sats for storage.
|
||||
*/
|
||||
export async function getIncomingPaymentsFromLnbits(limit = 100): Promise<
|
||||
{ payment_hash: string; amount_sats: number; paid_at: number }[]
|
||||
> {
|
||||
const res = await fetch(
|
||||
`${base}/api/v1/payments?limit=${limit}&sortby=time&direction=desc`,
|
||||
{ headers: { "X-Api-Key": adminKey } }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`LNbits payments list failed: ${res.status} ${text}`);
|
||||
}
|
||||
const data = (await res.json()) as LnbitsPaymentItem[] | { detail?: string; payments?: LnbitsPaymentItem[] };
|
||||
let items: LnbitsPaymentItem[];
|
||||
if (Array.isArray(data)) {
|
||||
items = data;
|
||||
} else if (data && typeof data === "object" && Array.isArray((data as { payments?: LnbitsPaymentItem[] }).payments)) {
|
||||
items = (data as { payments: LnbitsPaymentItem[] }).payments;
|
||||
} else {
|
||||
const detail = (data as { detail?: string })?.detail;
|
||||
throw new Error(detail ?? "LNbits payments list invalid response");
|
||||
}
|
||||
const incoming: { payment_hash: string; amount_sats: number; paid_at: number }[] = [];
|
||||
for (const p of items) {
|
||||
const hash = p.payment_hash;
|
||||
const amountMsats = Number(p.amount ?? 0);
|
||||
const pending = Boolean(p.pending);
|
||||
const paidAt = normalizePaymentTime(p);
|
||||
if (!hash || typeof hash !== "string") continue;
|
||||
if (pending) continue;
|
||||
if (amountMsats <= 0) continue;
|
||||
const amountSats = Math.floor(amountMsats / 1000);
|
||||
if (amountSats <= 0) continue;
|
||||
incoming.push({
|
||||
payment_hash: hash,
|
||||
amount_sats: amountSats,
|
||||
paid_at: paidAt,
|
||||
});
|
||||
}
|
||||
return incoming;
|
||||
}
|
||||
150
backend/src/services/nostr.ts
Normal file
150
backend/src/services/nostr.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { SimplePool } from "nostr-tools";
|
||||
import { config } from "../config.js";
|
||||
import { getDb } from "../db/index.js";
|
||||
|
||||
const pool = new SimplePool();
|
||||
|
||||
export interface NostrProfile {
|
||||
nostrFirstSeenAt: number | null;
|
||||
notesCount: number;
|
||||
followingCount: number;
|
||||
followersCount: number;
|
||||
activityScore: number;
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, rej) => setTimeout(() => rej(new Error("timeout")), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events from relays in parallel (kinds 0, 1, 3), compute metrics, optionally cache.
|
||||
* When forceRefreshProfile is true, always fetch from relays (skip cache) so kind 0 is parsed and lightning_address/name updated.
|
||||
*/
|
||||
export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile = false): Promise<NostrProfile> {
|
||||
const db = getDb();
|
||||
const cached = await db.getUser(pubkey);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const cacheHours = config.metadataCacheHours;
|
||||
const cacheValidUntil = (cached?.last_metadata_fetch_at ?? 0) + cacheHours * 3600;
|
||||
|
||||
if (!forceRefreshProfile && cached && cacheValidUntil > nowSec) {
|
||||
return {
|
||||
nostrFirstSeenAt: cached.nostr_first_seen_at,
|
||||
notesCount: cached.notes_count,
|
||||
followingCount: cached.following_count,
|
||||
followersCount: cached.followers_count,
|
||||
activityScore: cached.activity_score,
|
||||
};
|
||||
}
|
||||
|
||||
let events: { kind: number; created_at: number; content?: string; tags: string[][] }[] = [];
|
||||
try {
|
||||
const result = await withTimeout(
|
||||
pool.querySync(config.nostrRelays, { kinds: [0, 1, 3], authors: [pubkey], limit: config.maxEventsFetch }),
|
||||
config.relayTimeoutMs
|
||||
);
|
||||
events = Array.isArray(result) ? result : [];
|
||||
} catch (_) {
|
||||
// Timeout or relay error: use cache if any; otherwise upsert minimal user so /refresh-profile returns a row
|
||||
if (cached) {
|
||||
return {
|
||||
nostrFirstSeenAt: cached.nostr_first_seen_at,
|
||||
notesCount: cached.notes_count,
|
||||
followingCount: cached.following_count,
|
||||
followersCount: cached.followers_count,
|
||||
activityScore: cached.activity_score,
|
||||
};
|
||||
}
|
||||
const lastMetadataFetchAt = Math.floor(Date.now() / 1000);
|
||||
await db.upsertUser({
|
||||
pubkey,
|
||||
nostr_first_seen_at: null,
|
||||
notes_count: 0,
|
||||
followers_count: 0,
|
||||
following_count: 0,
|
||||
activity_score: 0,
|
||||
last_metadata_fetch_at: lastMetadataFetchAt,
|
||||
lightning_address: null,
|
||||
name: null,
|
||||
});
|
||||
return {
|
||||
nostrFirstSeenAt: null,
|
||||
notesCount: 0,
|
||||
followingCount: 0,
|
||||
followersCount: 0,
|
||||
activityScore: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const kind0 = events.filter((e) => e.kind === 0);
|
||||
const kind1 = events.filter((e) => e.kind === 1);
|
||||
const kind3 = events.filter((e) => e.kind === 3);
|
||||
|
||||
const earliestCreatedAt = events.length
|
||||
? Math.min(...events.map((e) => e.created_at))
|
||||
: null;
|
||||
|
||||
const lookbackSince = nowSec - config.activityLookbackDays * 86400;
|
||||
const notesInLookback = kind1.filter((e) => e.created_at >= lookbackSince).length;
|
||||
|
||||
let followingCount = 0;
|
||||
if (kind3.length > 0) {
|
||||
const contacts = kind3[0].tags?.filter((t) => t[0] === "p").length ?? 0;
|
||||
followingCount = contacts;
|
||||
}
|
||||
|
||||
const hasMetadata = kind0.length > 0;
|
||||
let score = 0;
|
||||
if (hasMetadata) score += 10;
|
||||
if (notesInLookback >= config.minNotesCount) score += 20;
|
||||
if (followingCount >= config.minFollowingCount) score += 10;
|
||||
if (0 >= config.minFollowersCount) score += 10; // followers not fetched for MVP; treat as 0
|
||||
|
||||
let lightning_address: string | null = null;
|
||||
let name: string | null = null;
|
||||
const lightningAddressRe = /^[^@]+@[^@]+$/;
|
||||
if (kind0.length > 0 && kind0[0].content) {
|
||||
try {
|
||||
const meta = JSON.parse(kind0[0].content) as Record<string, unknown>;
|
||||
// NIP-19 / common: lud16 is the Lightning address (user@domain). Fallbacks for other clients.
|
||||
for (const key of ["lud16", "lightning", "ln_address", "nip05"] as const) {
|
||||
const v = meta[key];
|
||||
if (typeof v === "string") {
|
||||
const s = v.trim();
|
||||
if (lightningAddressRe.test(s)) {
|
||||
lightning_address = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof meta.name === "string" && meta.name.trim()) name = meta.name.trim();
|
||||
else if (typeof meta.display_name === "string" && meta.display_name.trim()) name = meta.display_name.trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const nostrFirstSeenAt = earliestCreatedAt;
|
||||
const lastMetadataFetchAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
await db.upsertUser({
|
||||
pubkey,
|
||||
nostr_first_seen_at: nostrFirstSeenAt,
|
||||
notes_count: notesInLookback,
|
||||
followers_count: 0,
|
||||
following_count: followingCount,
|
||||
activity_score: score,
|
||||
last_metadata_fetch_at: lastMetadataFetchAt,
|
||||
lightning_address,
|
||||
name,
|
||||
});
|
||||
|
||||
return {
|
||||
nostrFirstSeenAt,
|
||||
notesCount: notesInLookback,
|
||||
followingCount,
|
||||
followersCount: 0,
|
||||
activityScore: score,
|
||||
};
|
||||
}
|
||||
69
backend/src/services/quote.ts
Normal file
69
backend/src/services/quote.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { randomInt } from "crypto";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { config } from "../config.js";
|
||||
import { getDb } from "../db/index.js";
|
||||
import { getWalletBalanceSats } from "./lnbits.js";
|
||||
|
||||
const QUOTE_TTL_SECONDS = 60;
|
||||
|
||||
interface PayoutBucket {
|
||||
sats: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
function getPayoutBuckets(): PayoutBucket[] {
|
||||
return [
|
||||
{ sats: config.payoutSmallSats, weight: config.payoutWeightSmall },
|
||||
{ sats: config.payoutMediumSats, weight: config.payoutWeightMedium },
|
||||
{ sats: config.payoutLargeSats, weight: config.payoutWeightLarge },
|
||||
{ sats: config.payoutJackpotSats, weight: config.payoutWeightJackpot },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted random selection. Returns sats amount.
|
||||
*/
|
||||
export function selectWeightedPayout(): number {
|
||||
const buckets = getPayoutBuckets();
|
||||
const totalWeight = buckets.reduce((s, b) => s + b.weight, 0);
|
||||
let r = randomInt(0, totalWeight);
|
||||
for (const b of buckets) {
|
||||
if (r < b.weight) return b.sats;
|
||||
r -= b.weight;
|
||||
}
|
||||
return config.payoutSmallSats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute payout for this claim: weighted selection, capped by daily budget remaining.
|
||||
*/
|
||||
export function computePayoutForClaim(todayPaidSats: number): number {
|
||||
const remaining = Math.max(0, config.dailyBudgetSats - todayPaidSats);
|
||||
if (remaining < config.faucetMinSats) return 0;
|
||||
const selected = selectWeightedPayout();
|
||||
return Math.min(selected, remaining, config.faucetMaxSats);
|
||||
}
|
||||
|
||||
export interface CreateQuoteResult {
|
||||
quoteId: string;
|
||||
payoutSats: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export async function createQuote(pubkey: string, lightningAddress: string): Promise<CreateQuoteResult | null> {
|
||||
const db = getDb();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const dayStart = now - (now % 86400);
|
||||
const todayPaid = await db.getPaidSatsSince(dayStart);
|
||||
let payout = computePayoutForClaim(todayPaid);
|
||||
if (payout <= 0) return null;
|
||||
|
||||
const walletBalance = await getWalletBalanceSats();
|
||||
payout = Math.min(payout, Math.max(0, walletBalance));
|
||||
if (payout < config.faucetMinSats) return null;
|
||||
|
||||
const quoteId = uuidv4();
|
||||
const expiresAt = now + QUOTE_TTL_SECONDS;
|
||||
await db.createQuote(quoteId, pubkey, payout, lightningAddress, expiresAt);
|
||||
return { quoteId, payoutSats: payout, expiresAt };
|
||||
}
|
||||
44
backend/src/services/syncLnbitsDeposits.ts
Normal file
44
backend/src/services/syncLnbitsDeposits.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getDb } from "../db/index.js";
|
||||
import { getIncomingPaymentsFromLnbits } from "./lnbits.js";
|
||||
|
||||
const SYNC_INTERVAL_MS = 2 * 60 * 1000;
|
||||
|
||||
const MIN_VALID_UNIX = 1e9;
|
||||
|
||||
export async function syncLnbitsDeposits(): Promise<void> {
|
||||
const db = getDb();
|
||||
try {
|
||||
const payments = await getIncomingPaymentsFromLnbits(100);
|
||||
let added = 0;
|
||||
let updated = 0;
|
||||
for (const p of payments) {
|
||||
const exists = await db.hasDepositWithPaymentHash(p.payment_hash);
|
||||
if (!exists) {
|
||||
await db.insertDeposit(
|
||||
p.amount_sats,
|
||||
"lightning",
|
||||
p.payment_hash,
|
||||
p.paid_at
|
||||
);
|
||||
added++;
|
||||
} else if (p.paid_at >= MIN_VALID_UNIX) {
|
||||
const didUpdate = await db.updateDepositCreatedAtIfMissing(p.payment_hash, p.paid_at);
|
||||
if (didUpdate) updated++;
|
||||
}
|
||||
}
|
||||
if (added > 0) {
|
||||
console.log(`[sync] LNbits deposits: ${added} new incoming payment(s) synced`);
|
||||
}
|
||||
if (updated > 0) {
|
||||
console.log(`[sync] LNbits deposits: ${updated} date(s) backfilled`);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("[sync] LNbits deposits failed:", msg);
|
||||
}
|
||||
}
|
||||
|
||||
export function startLnbitsDepositSync(): void {
|
||||
syncLnbitsDeposits();
|
||||
setInterval(syncLnbitsDeposits, SYNC_INTERVAL_MS);
|
||||
}
|
||||
17
backend/tsconfig.json
Normal file
17
backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
267
context/Frontend_overview.md
Normal file
267
context/Frontend_overview.md
Normal 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 30–60 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
359
context/backend_overview.md
Normal 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
386
context/overview.md
Normal 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 user’s 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
3
frontend/.env.example
Normal 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
|
||||
374
frontend/bitcoin-faucet_3.html
Normal file
374
frontend/bitcoin-faucet_3.html
Normal 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()">↻ 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
12
frontend/index.html
Normal 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
2297
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal 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
101
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
frontend/src/ErrorBoundary.tsx
Normal file
43
frontend/src/ErrorBoundary.tsx
Normal 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
310
frontend/src/api.ts
Normal 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);
|
||||
}
|
||||
24
frontend/src/components/ClaimDenialCard.tsx
Normal file
24
frontend/src/components/ClaimDenialCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/ClaimDenialPanel.tsx
Normal file
75
frontend/src/components/ClaimDenialPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
231
frontend/src/components/ClaimFlow.tsx
Normal file
231
frontend/src/components/ClaimFlow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
195
frontend/src/components/ClaimModal.tsx
Normal file
195
frontend/src/components/ClaimModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
frontend/src/components/ClaimQuoteModal.tsx
Normal file
92
frontend/src/components/ClaimQuoteModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
frontend/src/components/ClaimStepIndicator.tsx
Normal file
55
frontend/src/components/ClaimStepIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/ClaimSuccessModal.tsx
Normal file
42
frontend/src/components/ClaimSuccessModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
frontend/src/components/ClaimWizard.tsx
Normal file
184
frontend/src/components/ClaimWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
frontend/src/components/ConfirmStep.tsx
Normal file
114
frontend/src/components/ConfirmStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/ConnectNostr.tsx
Normal file
70
frontend/src/components/ConnectNostr.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/ConnectStep.tsx
Normal file
28
frontend/src/components/ConnectStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/CountUpNumber.tsx
Normal file
34
frontend/src/components/CountUpNumber.tsx
Normal 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>;
|
||||
}
|
||||
50
frontend/src/components/Countdown.tsx
Normal file
50
frontend/src/components/Countdown.tsx
Normal 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>;
|
||||
}
|
||||
86
frontend/src/components/DepositSection.tsx
Normal file
86
frontend/src/components/DepositSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
frontend/src/components/EligibilityStep.tsx
Normal file
103
frontend/src/components/EligibilityStep.tsx
Normal 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. We’ll 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/Footer.tsx
Normal file
22
frontend/src/components/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/Header.tsx
Normal file
29
frontend/src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
323
frontend/src/components/LoginModal.tsx
Normal file
323
frontend/src/components/LoginModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
frontend/src/components/Modal.tsx
Normal file
118
frontend/src/components/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
frontend/src/components/PayoutCard.tsx
Normal file
189
frontend/src/components/PayoutCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
frontend/src/components/RemoteSignerQR.tsx
Normal file
185
frontend/src/components/RemoteSignerQR.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
frontend/src/components/StatsSection.tsx
Normal file
123
frontend/src/components/StatsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
frontend/src/components/StepIndicator.tsx
Normal file
47
frontend/src/components/StepIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/SuccessStep.tsx
Normal file
63
frontend/src/components/SuccessStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/Toast.tsx
Normal file
24
frontend/src/components/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/src/contexts/ToastContext.tsx
Normal file
37
frontend/src/contexts/ToastContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
frontend/src/hooks/useClaimFlow.ts
Normal file
182
frontend/src/hooks/useClaimFlow.ts
Normal 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
21
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
frontend/src/pages/TransactionsPage.tsx
Normal file
142
frontend/src/pages/TransactionsPage.tsx
Normal 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
6
frontend/src/qrcode.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module "qrcode" {
|
||||
export function toDataURL(
|
||||
text: string,
|
||||
options?: { width?: number; margin?: number }
|
||||
): Promise<string>;
|
||||
}
|
||||
2219
frontend/src/styles/global.css
Normal file
2219
frontend/src/styles/global.css
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/src/utils/remoteSignerValidation.ts
Normal file
22
frontend/src/utils/remoteSignerValidation.ts
Normal 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
9
frontend/src/vite-env.d.ts
vendored
Normal 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
20
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
9
frontend/tsconfig.node.json
Normal file
9
frontend/tsconfig.node.json
Normal 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
22
frontend/vite.config.ts
Normal 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
12
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user