diff --git a/app/api/classic-liq/trustline/route.ts b/app/api/classic-liq/trustline/route.ts index 5d4c9c70..1e577b9c 100644 --- a/app/api/classic-liq/trustline/route.ts +++ b/app/api/classic-liq/trustline/route.ts @@ -37,6 +37,31 @@ export async function POST(req: NextRequest) { const parsed = TrustlinePostSchema.safeParse(rawBody) if (!parsed.success) { + // phase-144 (Module #44): quarantine the malformed payload for operator + // review instead of dropping it silently. No-op when the flag is off. + const quarantined = await import("@/lib/x402-dead-letter") + .then((m) => + m.quarantineInvoice({ + source: "classic-liq/trustline:POST", + raw: rawBody, + reasons: parsed.error.issues, + }), + ) + .catch((e) => { + logHorizonSubmitError("classic-liq/trustline dead-letter write", e) + return null + }) + if (quarantined?.quarantined) { + return NextResponse.json( + { + error: "Malformed request quarantined for review.", + code: "QUARANTINED", + deadLetterId: quarantined.id, + details: parsed.error.flatten(), + }, + { status: 422 }, + ) + } return NextResponse.json({ error: "signedXdr es requerido.", details: parsed.error.flatten() }, { status: 400 }) } @@ -48,36 +73,47 @@ export async function POST(req: NextRequest) { if (!isBatch && isPhase119Enabled() && parsed.data.cid) { const cidStr = parsed.data.cid.trim() const expected = parsed.data.expectedSha256 ?? null - // Validate CID format strictly - const { CidSchema, verifyBytesIntegrity, sha256Hex, getCachedCid } = await import("@/lib/cid-cache") - const cidCheck = CidSchema.safeParse(cidStr) - if (!cidCheck.success) { - return NextResponse.json({ error: `Invalid CID: ${cidStr.slice(0, 12)}…`, code: "CID_INVALID" }, { status: 400 }) - } - // If cidPath provided, fetch and verify tampering - if (parsed.data.cidPath) { - try { - const { fetchWithCidCache } = await import("@/lib/cid-cache") - const fetched = await fetchWithCidCache(parsed.data.cidPath, { expectedSha256: expected }) - if (!fetched.ok) { - return NextResponse.json({ error: fetched.error, code: fetched.code, cid: cidStr }, { status: 409 }) - } - // verified — continue to trustline submission - } catch (e) { - return NextResponse.json({ error: e instanceof Error ? e.message : String(e), code: "CID_VERIFY_FAILED" }, { status: 409 }) + try { + // Validate CID format strictly + const { CidSchema, verifyBytesIntegrity, sha256Hex, getCachedCid } = await import("@/lib/cid-cache") + const cidCheck = CidSchema.safeParse(cidStr) + if (!cidCheck.success) { + return NextResponse.json({ error: `Invalid CID: ${cidStr.slice(0, 12)}…`, code: "CID_INVALID" }, { status: 400 }) } - } else if (expected) { - // CID + expected hash supplied without bytes: check cache integrity - try { - const cached = await getCachedCid(cidStr, { expectedSha256: expected }) - if (cached && !verifyBytesIntegrity(cached.bytes, expected)) { - return NextResponse.json({ error: `Cached CID ${cidStr.slice(0, 8)}… fails integrity check`, code: "HASH_MISMATCH" }, { status: 409 }) + // If cidPath provided, fetch and verify tampering + if (parsed.data.cidPath) { + try { + const { fetchWithCidCache } = await import("@/lib/cid-cache") + const fetched = await fetchWithCidCache(parsed.data.cidPath, { expectedSha256: expected }) + if (!fetched.ok) { + return NextResponse.json({ error: fetched.error, code: fetched.code, cid: cidStr }, { status: 409 }) + } + // verified — continue to trustline submission + } catch (e) { + return NextResponse.json({ error: e instanceof Error ? e.message : String(e), code: "CID_VERIFY_FAILED" }, { status: 409 }) + } + } else if (expected) { + // CID + expected hash supplied without bytes: check cache integrity + try { + const cached = await getCachedCid(cidStr, { expectedSha256: expected }) + if (cached && !verifyBytesIntegrity(cached.bytes, expected)) { + return NextResponse.json({ error: `Cached CID ${cidStr.slice(0, 8)}… fails integrity check`, code: "HASH_MISMATCH" }, { status: 409 }) + } + // if not cached, we allow submission but warn via header later + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return NextResponse.json({ error: msg, code: "CID_TAMPERED" }, { status: 409 }) } - // if not cached, we allow submission but warn via header later - } catch (e) { - const msg = e instanceof Error ? e.message : String(e) - return NextResponse.json({ error: msg, code: "CID_TAMPERED" }, { status: 409 }) } + } catch (e) { + logHorizonSubmitError("classic-liq/trustline CID boundary", e) + await import("@/lib/x402-dead-letter") + .then((m) => m.quarantineInvoice({ source: "classic-liq/trustline:cid", raw: rawBody })) + .catch(() => {}) + return NextResponse.json( + { error: "CID verification failed unexpectedly.", code: "CID_BOUNDARY_ERROR" }, + { status: 409 }, + ) } } @@ -111,7 +147,27 @@ export async function POST(req: NextRequest) { } // GET exposes cache stats when flag enabled (observability, zero regression when off) -export async function GET() { +export async function GET(req: NextRequest) { + // phase-144 (Module #44): dead-letter review queue for operators. + if (new URL(req.url).searchParams.get("view") === "dead-letter") { + const { isX402DeadLetterEnabled, listDeadLetterQueue, getDeadLetterStats } = await import( + "@/lib/x402-dead-letter" + ) + if (!isX402DeadLetterEnabled()) { + return NextResponse.json({ enabled: false, error: "phase-144 flag disabled" }, { status: 404 }) + } + const statusParam = new URL(req.url).searchParams.get("status") + const status = + statusParam === "open" || statusParam === "resolved" || statusParam === "discarded" + ? statusParam + : undefined + const [queue, stats] = await Promise.all([ + listDeadLetterQueue({ status, limit: 100 }), + getDeadLetterStats(), + ]) + return NextResponse.json({ enabled: true, stats, queue }) + } + if (!isPhase119Enabled()) { return NextResponse.json({ enabled: false, error: "phase-119 flag disabled" }, { status: 404 }) } diff --git a/app/api/og/chamber/route.tsx b/app/api/og/chamber/route.tsx index 2c1aa73f..8bd5f44f 100644 --- a/app/api/og/chamber/route.tsx +++ b/app/api/og/chamber/route.tsx @@ -23,6 +23,7 @@ import { safeDisplayName, withOgErrorBoundary, } from "@/lib/og-render-utils"; +import { isSybilResistanceEnabled } from "@/lib/sybil-resistance"; export const runtime = "nodejs"; @@ -408,6 +409,30 @@ export async function GET(request: NextRequest) { return resp; }; + // phase-145 (Module #45): attach a sybil-resistance signal when a wallet is + // supplied (e.g. the profile that owns/shares this chamber). Header-only, + // best-effort, does not change pixels. + const walletParam = searchParams.get("wallet")?.trim() ?? ""; + const finalize = async (resp: NextResponse): Promise => { + const piped = await maybePin(resp); + if (isSybilResistanceEnabled()) { + piped.headers.set("X-Phase145", "enabled"); + if (/^G[A-Z2-7]{55}$/.test(walletParam)) { + try { + const { assessWalletSybilRisk } = await import("@/lib/sybil-resistance"); + const assessment = await assessWalletSybilRisk(walletParam); + if (assessment) { + piped.headers.set("X-Phase-Sybil-Band", assessment.band); + piped.headers.set("X-Phase-Sybil-Score", String(assessment.score)); + } + } catch { + // best-effort + } + } + } + return piped; + }; + // Token-level OG (individual NFT) const rawToken = searchParams.get("token_id") ?? searchParams.get("token"); const tokenId = rawToken ? parseInt(rawToken, 10) : NaN; @@ -421,7 +446,7 @@ export async function GET(request: NextRequest) { headers: { "Content-Type": "text/plain" }, }); } - return maybePin(pngResponse(result.value)); + return finalize(pngResponse(result.value)); } // Collection-level OG — monitor frame with best-effort token metadata @@ -440,7 +465,7 @@ export async function GET(request: NextRequest) { ) .png() .toBuffer(); - return maybePin(pngResponse(pngBuffer)); + return finalize(pngResponse(pngBuffer)); } const result = await withOgErrorBoundary(() => @@ -452,5 +477,5 @@ export async function GET(request: NextRequest) { headers: { "Content-Type": "text/plain" }, }); } - return maybePin(pngResponse(result.value)); + return finalize(pngResponse(result.value)); } diff --git a/app/api/og/profile/route.tsx b/app/api/og/profile/route.tsx index 8e6d05e5..157baad8 100644 --- a/app/api/og/profile/route.tsx +++ b/app/api/og/profile/route.tsx @@ -3,6 +3,10 @@ import path from "node:path"; import sharp from "sharp"; import { z } from "zod"; import { getProfile } from "@/lib/profile-store"; +import { + isSybilResistanceEnabled, + assessWalletSybilRisk, +} from "@/lib/sybil-resistance"; import { getOgTheme, resolvePinIntent, @@ -313,11 +317,31 @@ export async function GET(request: NextRequest) { const templatePath = resolveOgTemplatePath(); const usedTemplate = templateName(templatePath); + // phase-145 (Module #45): sybil-resistance signal for the resolved wallet. + // Observability only — does not change pixels. Best-effort; never throws. + let sybilBand: string | null = null; + let sybilScore: number | null = null; + if (wallet.length >= 10 && isSybilResistanceEnabled()) { + try { + const assessment = await assessWalletSybilRisk(wallet); + if (assessment) { + sybilBand = assessment.band; + sybilScore = assessment.score; + } + } catch { + // best-effort — leave headers unset + } + } + const headers: Record = { "Content-Type": "image/png", "Cache-Control": "public, max-age=300, s-maxage=300", "X-Phase-Og-Template": usedTemplate, ...(isPhase120Enabled() ? { "X-Phase120": "enabled" } : {}), + ...(isSybilResistanceEnabled() ? { "X-Phase145": "enabled" } : {}), + ...(sybilBand + ? { "X-Phase-Sybil-Band": sybilBand, "X-Phase-Sybil-Score": String(sybilScore) } + : {}), }; if (pinMeta?.pinned) { headers["X-Phase-Pin-URI"] = pinMeta.uri; diff --git a/app/api/profile/avatar/route.ts b/app/api/profile/avatar/route.ts index 2698f3b9..cbc90915 100644 --- a/app/api/profile/avatar/route.ts +++ b/app/api/profile/avatar/route.ts @@ -30,6 +30,42 @@ const AvatarQuerySchema = z.object({ export async function GET(request: NextRequest) { const api = createApiRequestContext(request, "/api/profile/avatar") + + // phase-157 (Module #57): batch avatar fetch for a virtualized grid window. + // `?wallets=G...,G...` returns many avatars in one round-trip so a 10k-token + // grid does not fan out thousands of requests. No-op unless the flag is on. + const rawWallets = request.nextUrl.searchParams.get("wallets") + if (rawWallets) { + if (!isNftGridVirtualizationEnabled()) { + return api.json( + { error: "Batch avatar fetch disabled (phase-157 flag off)" }, + { status: 404, event: "profile.avatar.batch_disabled" }, + ) + } + const parsedBatch = BatchAvatarQuerySchema.safeParse({ + wallets: rawWallets.split(",").map((w) => w.trim()).filter(Boolean), + }) + if (!parsedBatch.success) { + return api.json( + { error: "Invalid wallets list", details: parsedBatch.error.flatten() }, + { status: 400, event: "profile.avatar.batch_validation_failed" }, + ) + } + try { + const avatars = await getAvatarsForWallets(parsedBatch.data.wallets) + return api.json( + { avatars }, + { + event: "profile.avatar.batch_loaded", + metadata: { count: avatars.length }, + headers: { "Cache-Control": "private, max-age=30", "X-Phase157": "enabled" }, + }, + ) + } catch (error) { + return api.errorJson(error, 500, "profile.avatar.batch_failed") + } + } + const rawWallet = request.nextUrl.searchParams.get("wallet")?.trim() ?? "" const parsedQ = AvatarQuerySchema.safeParse({ wallet: rawWallet }) const wallet = parsedQ.success ? parsedQ.data.wallet : rawWallet @@ -97,6 +133,7 @@ export async function GET(request: NextRequest) { "Cache-Control": "private, max-age=30", "X-Phase-Locale": preferredLocale, ...(isProfilePinningRedundancyEnabled() ? { "X-Phase117": "enabled", ...(gatewayMeta ? { "X-Phase-Gateway": gatewayMeta } : {}) } : {}), + ...(isNftGridVirtualizationEnabled() ? { "X-Phase157": "enabled" } : {}), }, }, ) diff --git a/app/api/signals/[id]/replies/route.ts b/app/api/signals/[id]/replies/route.ts index 820fa83e..bf457048 100644 --- a/app/api/signals/[id]/replies/route.ts +++ b/app/api/signals/[id]/replies/route.ts @@ -82,6 +82,27 @@ export async function POST( ) } + // phase-156 (Module #56): reject posts from wallets on the governed deny-list. + // No-op when the flag is off. Wrapped so a store read failure never 500s the + // reply path. + if (isFaucetDenyListEnabled()) { + try { + if (await isWalletDenied(body.wallet)) { + const entry = await getWalletDenyEntry(body.wallet).catch(() => null) + return api.json( + { + error: "This wallet is excluded from posting.", + code: "WALLET_DENIED", + ...(entry ? { reason: entry.reason, entryId: entry.id } : {}), + }, + { status: 403, event: "signals.reply.wallet_denied", metadata: { wallet: body.wallet } }, + ) + } + } catch (e) { + api.log("warn", "signals.reply.deny_check_failed", { error: e instanceof Error ? e.message : String(e) }) + } + } + // phase-116: validate attribution if provided (optional, additive) let attributionParsed: z.infer | undefined if (isPhase116Enabled() && (body.attribution != null || body.contributors != null)) { diff --git a/app/signals/[id]/page.tsx b/app/signals/[id]/page.tsx index 363c123d..bf72acfb 100644 --- a/app/signals/[id]/page.tsx +++ b/app/signals/[id]/page.tsx @@ -62,6 +62,17 @@ export default async function SignalDetailPage({ params }: Props) { } } + // phase-156 (Module #56): flag whether this signal's author is on the governed + // deny-list, so the detail view can show it. Best-effort; false when flag off. + let authorRestricted = false + if (isFaucetDenyListEnabled()) { + try { + authorRestricted = await isWalletDenied(signal.author_wallet) + } catch { + // best-effort + } + } + return (
@@ -110,6 +121,12 @@ export default async function SignalDetailPage({ params }: Props) { {signal.title} + {authorRestricted && ( +

+ ⚠ AUTHOR ON DENY-LIST +

+ )} + {signal.nft_token_id !== undefined && (
{nftImageSrc && ( diff --git a/components/trustline-button.tsx b/components/trustline-button.tsx index 7b7d5175..f04b3c4c 100644 --- a/components/trustline-button.tsx +++ b/components/trustline-button.tsx @@ -211,9 +211,17 @@ export function TrustlineButton({ address, onRequestConnect, onReady, className, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ signedXdr }), }) - const payload = (await submitRes.json().catch(() => ({}))) as { error?: string; detail?: string; code?: string } + const payload = (await submitRes.json().catch(() => ({}))) as { error?: string; detail?: string; code?: string; deadLetterId?: string } if (!submitRes.ok) { // Mensajes de error más específicos basados en el código + if (payload.code === "QUARANTINED") { + // phase-144 (Module #44): malformed payload was filed in the review queue + throw new Error( + lang === "es" + ? `Solicitud con formato inválido: se archivó en la cola de revisión${payload.deadLetterId ? ` (ref ${payload.deadLetterId.slice(0, 8)})` : ""}. Un operador la revisará.` + : `Malformed request: filed in the review queue${payload.deadLetterId ? ` (ref ${payload.deadLetterId.slice(0, 8)})` : ""} for an operator to inspect.`, + ) + } if (payload.code === "ACCOUNT_NOT_FOUND" || payload.error?.includes("not found")) { throw new Error( `Cuenta no encontrada\n\n` + diff --git a/components/wallet-avatar.tsx b/components/wallet-avatar.tsx index 10d88cdf..5462e6e3 100644 --- a/components/wallet-avatar.tsx +++ b/components/wallet-avatar.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useEffect, useRef, useCallback } from "react" +import { nftGridOverscanPx } from "@/lib/nft-grid-virtualization" // ??? phase-117: multi-gateway fallback client wiring ???????????????????????? // Preserves original lazy-load + initials fallback. When an image fails, @@ -84,7 +85,10 @@ export function WalletAvatar({ const [errorCode, setErrorCode] = useState(null) const ref = useRef(null) - // IntersectionObserver for lazy loading + // IntersectionObserver for lazy loading. + // phase-157 (Module #57): widen the overscan margin when grid virtualization + // is enabled so avatars in a large scrolling grid mount just ahead of view + // instead of all at once (or too late). Falls back to the legacy 50px. useEffect(() => { if (!ref.current) return @@ -95,7 +99,7 @@ export function WalletAvatar({ observer.disconnect() } }, - { rootMargin: "50px" } + { rootMargin: `${nftGridOverscanPx()}px` } ) observer.observe(ref.current) diff --git a/lib/classic-liq.ts b/lib/classic-liq.ts index 31095a99..a416dfe3 100644 --- a/lib/classic-liq.ts +++ b/lib/classic-liq.ts @@ -262,6 +262,22 @@ import { z } from "zod" export type { CidCacheEntry, CidIntegrityCheck } from "@/lib/cid-cache" +// ─── phase-144 (Module #44): x402 dead-letter quarantine ──────────────────── +// Malformed x402 invoices / trustline payloads previously failed silently with +// no audit trail. When phase-144 is on, the trustline route quarantines the +// rejected payload into a review queue instead of dropping it. +// NOTE: type-only re-export — the store imports node:fs and must NOT be bundled +// into the client (trustline-button imports from this file). Server code uses +// dynamic import("@/lib/x402-dead-letter"). +export type { + X402Invoice, + X402DeadLetterEntry, + DeadLetterReason, + DeadLetterStatus, + InvoiceClassification, + QuarantineResult, +} from "@/lib/x402-dead-letter" + /** * Trustline + CID integrity contract: * When pinning metadata for the liq asset (e.g., NFT image refreshed after diff --git a/lib/faucet-deny-list.ts b/lib/faucet-deny-list.ts new file mode 100644 index 00000000..bce35f01 --- /dev/null +++ b/lib/faucet-deny-list.ts @@ -0,0 +1,276 @@ +/** + * Module #56 (Issue #78) — Faucet / participation deny-list with on-chain + * governance veto. + * + * AUDIT NOTE (execution flow, app/api/signals/[id]/replies/route.ts): + * Abusive wallets could not be cleanly excluded. The replies route validated + * wallet format and signature presence but had no notion of a wallet being + * barred from participation, and there was no governed way to add or remove + * such a bar — an operator edit to a JSON file with no checks and no appeal. + * + * This module is the isolated domain for that: + * - AddDenyRequestSchema / GovernanceVetoSchema — type-safe inputs + * - proposeDenyListEntry() — records a wallet as denied (deny-first posture: + * the entry is active immediately) + * - castGovernanceVeto() — a governance signer vetoes an entry; once a + * quorum of distinct governance signers have vetoed, the entry flips to + * "vetoed" and the wallet is no longer denied + * - isWalletDenied() — the predicate the routes call + * - liftDenyListEntry() / listDenyList() / getDenyListEntry() + * + * Governance signers come from PHASE_GOVERNANCE_SIGNERS (comma-separated G + * addresses). A veto from an address outside that set is rejected. + * + * Flag: phase-156 (NEXT_PUBLIC_FEATURE_PHASE_156 / FEATURE_PHASE_156). + * When the flag is off, isWalletDenied() always returns false and the routes + * keep their legacy behaviour (zero regression). + * + * Rollback: unset the flag. The faucet-deny-list.json sidecar can be deleted. + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises" +import path from "node:path" +import { randomUUID } from "node:crypto" +import { z } from "zod" +import { serverDataJsonPath } from "@/lib/server-data-paths" + +export function isFaucetDenyListEnabled(): boolean { + const v = ( + process.env.NEXT_PUBLIC_FEATURE_PHASE_156 ?? + process.env.FEATURE_PHASE_156 ?? + "" + ) + .trim() + .toLowerCase() + return v === "1" || v === "true" || v === "yes" || v === "on" +} + +export function flag156RollbackNote(): string { + return "Rollback phase-156: unset NEXT_PUBLIC_FEATURE_PHASE_156 / FEATURE_PHASE_156 or set 0/false and restart. The faucet-deny-list.json sidecar can be deleted." +} + +const G_ADDRESS_RE = /^G[A-Z2-7]{55}$/ +export const DEFAULT_VETO_QUORUM = 3 + +export const AddDenyRequestSchema = z.object({ + wallet: z.string().trim().length(56).regex(G_ADDRESS_RE, "wallet must be a Stellar public (G...) address"), + reason: z.string().trim().min(3).max(500), + proposedBy: z.string().trim().min(1).max(64), + vetoQuorum: z.number().int().min(1).max(21).optional(), + evidenceUrl: z.string().trim().url().max(1024).optional(), +}) + +export type AddDenyRequest = z.infer + +export const GovernanceVetoSchema = z.object({ + signer: z.string().trim().length(56).regex(G_ADDRESS_RE, "signer must be a Stellar public (G...) address"), + signature: z.string().trim().min(1).max(512).optional(), + note: z.string().trim().max(500).optional(), +}) + +export type GovernanceVeto = z.infer + +export type DenyListStatus = "active" | "vetoed" | "lifted" + +export type DenyListEntry = { + id: string + wallet: string + reason: string + proposed_by: string + proposed_at: number + status: DenyListStatus + veto_quorum: number + vetoes: Array<{ signer: string; signature?: string; note?: string; cast_at: number }> + evidence_url?: string + lifted_at?: number + lifted_by?: string +} + +export class FaucetDenyListError extends Error { + readonly code: + | "FLAG_DISABLED" + | "NOT_FOUND" + | "ALREADY_EXISTS" + | "NOT_GOVERNANCE_SIGNER" + | "DUPLICATE_VETO" + | "VALIDATION_FAILED" + | "STORE_WRITE_FAILED" + readonly details?: unknown + constructor(code: FaucetDenyListError["code"], message: string, details?: unknown) { + super(message) + this.name = "FaucetDenyListError" + this.code = code + this.details = details + } +} + +/** Governance signer allowlist from env. Pure — exported for tests. */ +export function governanceSigners(): string[] { + return (process.env.PHASE_GOVERNANCE_SIGNERS ?? "") + .split(/[,\s]+/) + .map((s) => s.trim()) + .filter((s) => G_ADDRESS_RE.test(s)) +} + +export function isGovernanceSigner(address: string): boolean { + return governanceSigners().includes(address.trim()) +} + +/** Pure — given an entry's vetoes and quorum, what status should it hold? */ +export function deriveDenyStatus( + entry: Pick, +): DenyListStatus { + if (entry.status === "lifted") return "lifted" + const distinctVetoers = new Set(entry.vetoes.map((v) => v.signer)).size + return distinctVetoers >= entry.veto_quorum ? "vetoed" : "active" +} + +// ─── Store ────────────────────────────────────────────────────────────────── + +type DenyListStore = Record + +async function readStore(): Promise { + try { + const raw = await readFile(serverDataJsonPath("faucetDenyList"), "utf8") + const parsed = JSON.parse(raw) as DenyListStore + return parsed && typeof parsed === "object" ? parsed : {} + } catch { + return {} + } +} + +async function writeStore(data: DenyListStore): Promise { + const filePath = serverDataJsonPath("faucetDenyList") + try { + await mkdir(path.dirname(filePath), { recursive: true }) + await writeFile(filePath, JSON.stringify(data, null, 2), "utf8") + } catch (e) { + throw new FaucetDenyListError("STORE_WRITE_FAILED", e instanceof Error ? e.message : String(e)) + } +} + +export async function proposeDenyListEntry(rawInput: unknown): Promise { + if (!isFaucetDenyListEnabled()) { + throw new FaucetDenyListError("FLAG_DISABLED", "Faucet deny-list disabled (phase-156 flag off)") + } + const parsed = AddDenyRequestSchema.safeParse(rawInput) + if (!parsed.success) { + throw new FaucetDenyListError( + "VALIDATION_FAILED", + "Deny-list request failed schema validation", + parsed.error.flatten(), + ) + } + const req = parsed.data + const store = await readStore() + const existing = Object.values(store).find( + (e) => e.wallet === req.wallet && deriveDenyStatus(e) === "active", + ) + if (existing) { + throw new FaucetDenyListError( + "ALREADY_EXISTS", + `Wallet ${req.wallet.slice(0, 6)}… already has an active deny-list entry (${existing.id})`, + ) + } + const id = randomUUID() + const entry: DenyListEntry = { + id, + wallet: req.wallet, + reason: req.reason, + proposed_by: req.proposedBy, + proposed_at: Date.now(), + status: "active", + veto_quorum: req.vetoQuorum ?? DEFAULT_VETO_QUORUM, + vetoes: [], + ...(req.evidenceUrl ? { evidence_url: req.evidenceUrl } : {}), + } + store[id] = entry + await writeStore(store) + return entry +} + +export async function castGovernanceVeto(entryId: string, rawVeto: unknown): Promise { + if (!isFaucetDenyListEnabled()) { + throw new FaucetDenyListError("FLAG_DISABLED", "Faucet deny-list disabled (phase-156 flag off)") + } + const parsed = GovernanceVetoSchema.safeParse(rawVeto) + if (!parsed.success) { + throw new FaucetDenyListError( + "VALIDATION_FAILED", + "Governance veto failed schema validation", + parsed.error.flatten(), + ) + } + const veto = parsed.data + if (!isGovernanceSigner(veto.signer)) { + throw new FaucetDenyListError( + "NOT_GOVERNANCE_SIGNER", + `${veto.signer.slice(0, 6)}… is not in PHASE_GOVERNANCE_SIGNERS`, + ) + } + const store = await readStore() + const entry = store[entryId] + if (!entry) throw new FaucetDenyListError("NOT_FOUND", `Deny-list entry ${entryId} not found`) + if (entry.vetoes.some((v) => v.signer === veto.signer)) { + throw new FaucetDenyListError("DUPLICATE_VETO", `${veto.signer.slice(0, 6)}… has already vetoed this entry`) + } + entry.vetoes.push({ + signer: veto.signer, + ...(veto.signature ? { signature: veto.signature } : {}), + ...(veto.note ? { note: veto.note } : {}), + cast_at: Date.now(), + }) + entry.status = deriveDenyStatus(entry) + await writeStore(store) + return entry +} + +export async function liftDenyListEntry(entryId: string, liftedBy: string): Promise { + const store = await readStore() + const entry = store[entryId] + if (!entry) throw new FaucetDenyListError("NOT_FOUND", `Deny-list entry ${entryId} not found`) + entry.status = "lifted" + entry.lifted_at = Date.now() + entry.lifted_by = liftedBy.slice(0, 64) + await writeStore(store) + return entry +} + +export async function isWalletDenied(wallet: string): Promise { + if (!isFaucetDenyListEnabled()) return false + if (!G_ADDRESS_RE.test(wallet.trim())) return false + const store = await readStore() + return Object.values(store).some( + (e) => e.wallet === wallet.trim() && deriveDenyStatus(e) === "active", + ) +} + +export async function getWalletDenyEntry(wallet: string): Promise { + const store = await readStore() + return ( + Object.values(store).find( + (e) => e.wallet === wallet.trim() && deriveDenyStatus(e) === "active", + ) ?? null + ) +} + +export async function listDenyList( + opts: { status?: DenyListStatus; limit?: number } = {}, +): Promise { + const store = await readStore() + let items = Object.values(store).map((e) => ({ ...e, status: deriveDenyStatus(e) })) + if (opts.status) items = items.filter((e) => e.status === opts.status) + items.sort((a, b) => b.proposed_at - a.proposed_at) + return typeof opts.limit === "number" ? items.slice(0, Math.max(0, opts.limit)) : items +} + +export async function getDenyListEntry(entryId: string): Promise { + const store = await readStore() + const entry = store[entryId] + return entry ? { ...entry, status: deriveDenyStatus(entry) } : null +} + +/** Test helper — empties the sidecar. */ +export async function clearDenyListForTests(): Promise { + await writeStore({}) +} diff --git a/lib/nft-grid-virtualization.ts b/lib/nft-grid-virtualization.ts new file mode 100644 index 00000000..ca41dabe --- /dev/null +++ b/lib/nft-grid-virtualization.ts @@ -0,0 +1,172 @@ +/** + * Module #57 (Issue #79) — Virtualize the Chamber NFT grid so 10k+ owned tokens + * render without jank on low-end devices. + * + * AUDIT NOTE (execution flow, grid + avatar rendering): + * Rendering every owned token mounts thousands of DOM nodes and fires thousands + * of avatar fetches at once, freezing low-end devices. There was no shared, + * testable windowing primitive — each surface (grid, avatar row) reimplemented + * ad-hoc lazy loading. + * + * This module is the isolated, dependency-free domain for windowed rendering: + * - VirtualGridParamsSchema — type-safe windowing inputs + * - computeGridWindow() — pure: which item indices are on screen (+ overscan), + * the spacer offset, and the total scroll height + * - computeColumnCount() — responsive column count from container width + * - clampScrollTop() / sliceVisible() — supporting pure helpers + * + * It contains ZERO React / DOM / network imports so it can be unit-tested in + * isolation with `npx tsx` and imported from both server and client code. + * + * Flag: phase-157 (NEXT_PUBLIC_FEATURE_PHASE_157 / FEATURE_PHASE_157). The math + * is always available; the flag only gates whether call sites opt into the + * wider overscan / batch behaviour (zero regression when off). + * + * Rollback: unset the flag. No persisted state. + */ + +import { z } from "zod" + +export function isNftGridVirtualizationEnabled(): boolean { + const v = ( + process.env.NEXT_PUBLIC_FEATURE_PHASE_157 ?? + process.env.FEATURE_PHASE_157 ?? + "" + ) + .trim() + .toLowerCase() + return v === "1" || v === "true" || v === "yes" || v === "on" +} + +export function flag157RollbackNote(): string { + return "Rollback phase-157: unset NEXT_PUBLIC_FEATURE_PHASE_157 / FEATURE_PHASE_157 or set 0/false and restart. No persisted state." +} + +export const DEFAULT_OVERSCAN_ROWS = 3 +/** IntersectionObserver rootMargin used by lazy-mounted avatars (wider when phase-157 is on). */ +export const DEFAULT_OVERSCAN_PX = 240 +export const LEGACY_OVERSCAN_PX = 50 + +export function nftGridOverscanPx(): number { + return isNftGridVirtualizationEnabled() ? DEFAULT_OVERSCAN_PX : LEGACY_OVERSCAN_PX +} + +export const VirtualGridParamsSchema = z.object({ + itemCount: z.number().int().min(0), + rowHeight: z.number().positive(), + columns: z.number().int().min(1), + viewportHeight: z.number().min(0), + scrollTop: z.number().min(0), + overscanRows: z.number().int().min(0).default(DEFAULT_OVERSCAN_ROWS), + gap: z.number().min(0).default(0), +}) + +export type VirtualGridParams = z.input + +export type VirtualGridWindow = { + startRow: number + endRow: number + totalRows: number + startIndex: number + /** exclusive */ + endIndex: number + visibleIndices: number[] + /** translateY / padding-top for the spacer, in px */ + offsetY: number + /** full scrollable height in px */ + totalHeight: number +} + +export class VirtualGridError extends Error { + readonly code: "VALIDATION_FAILED" + readonly details?: unknown + constructor(message: string, details?: unknown) { + super(message) + this.name = "VirtualGridError" + this.code = "VALIDATION_FAILED" + this.details = details + } +} + +/** + * Pure — computes the on-screen item window for a fixed-row-height grid. + * `rowHeight` is the stride between row tops (item height + gap is handled via + * the `gap` field: stride = rowHeight + gap). + */ +export function computeGridWindow(rawParams: VirtualGridParams): VirtualGridWindow { + const parsed = VirtualGridParamsSchema.safeParse(rawParams) + if (!parsed.success) { + throw new VirtualGridError("Virtual grid params failed schema validation", parsed.error.flatten()) + } + const { itemCount, rowHeight, columns, viewportHeight, scrollTop, overscanRows, gap } = parsed.data + + const totalRows = Math.ceil(itemCount / columns) + const stride = rowHeight + gap + const totalHeight = totalRows === 0 ? 0 : totalRows * stride - gap + + if (itemCount === 0) { + return { + startRow: 0, + endRow: 0, + totalRows: 0, + startIndex: 0, + endIndex: 0, + visibleIndices: [], + offsetY: 0, + totalHeight: 0, + } + } + + const clampedScrollTop = clampScrollTop(scrollTop, totalHeight, viewportHeight) + const firstVisibleRow = Math.floor(clampedScrollTop / stride) + const lastVisibleRow = Math.floor((clampedScrollTop + viewportHeight) / stride) + + const startRow = Math.max(0, firstVisibleRow - overscanRows) + const endRow = Math.min(totalRows - 1, lastVisibleRow + overscanRows) + + const startIndex = startRow * columns + const endIndex = Math.min(itemCount, (endRow + 1) * columns) + + const visibleIndices: number[] = [] + for (let i = startIndex; i < endIndex; i++) visibleIndices.push(i) + + return { + startRow, + endRow, + totalRows, + startIndex, + endIndex, + visibleIndices, + offsetY: startRow * stride, + totalHeight, + } +} + +export function clampScrollTop( + scrollTop: number, + totalHeight: number, + viewportHeight: number, +): number { + const maxScroll = Math.max(0, totalHeight - viewportHeight) + if (!Number.isFinite(scrollTop) || scrollTop < 0) return 0 + return Math.min(scrollTop, maxScroll) +} + +/** + * Pure — responsive column count from a container width. Mirrors a CSS + * `repeat(auto-fill, minmax(minItemWidth, 1fr))` grid. + */ +export function computeColumnCount( + containerWidth: number, + minItemWidth: number, + gap = 0, +): number { + if (containerWidth <= 0 || minItemWidth <= 0) return 1 + const cols = Math.floor((containerWidth + gap) / (minItemWidth + gap)) + return Math.max(1, cols) +} + +/** Generic — returns the slice of `items` covered by a computed window. */ +export function sliceVisible(items: readonly T[], win: VirtualGridWindow): T[] { + return items.slice(win.startIndex, win.endIndex) +} diff --git a/lib/profile-store.ts b/lib/profile-store.ts index 7caa7490..dc34a647 100644 --- a/lib/profile-store.ts +++ b/lib/profile-store.ts @@ -723,6 +723,61 @@ export function auditCrtWidgetWiring(): { ok: boolean; note: string } { export { isPhase117Enabled, flag117RollbackNote } from "@/lib/ipfs-pinning"; export type { MultiPinResult, PinResult } from "@/lib/ipfs-pinning"; +// ─── phase-157 (Module #57): NFT grid virtualization ──────────────────────── +// Rendering all owned tokens froze low-end devices. The pure windowing math +// lives in lib/nft-grid-virtualization (dependency-free, client + server). +// Re-exported here so the avatar route and profile surfaces share one source. +// Rollback: unset NEXT_PUBLIC_FEATURE_PHASE_157 / FEATURE_PHASE_157. +export { + isNftGridVirtualizationEnabled, + flag157RollbackNote, + computeGridWindow, + computeColumnCount, + clampScrollTop, + sliceVisible, + nftGridOverscanPx, + VirtualGridParamsSchema, + VirtualGridError, + DEFAULT_OVERSCAN_ROWS, + DEFAULT_OVERSCAN_PX, + LEGACY_OVERSCAN_PX, +} from "@/lib/nft-grid-virtualization"; +export type { VirtualGridParams, VirtualGridWindow } from "@/lib/nft-grid-virtualization"; + +/** + * phase-157: server helper — resolves a windowed page of avatar records for a + * batch of wallets in one call, so a grid of many owned-token owners avoids + * N round-trips. Best-effort per wallet; failures yield a null avatar. + */ +export const BatchAvatarQuerySchema = z.object({ + wallets: z.array(z.string().trim().length(56).regex(/^G[A-Z2-7]{55}$/)).min(1).max(50), +}); + +export async function getAvatarsForWallets( + wallets: string[], +): Promise> { + const unique = [...new Set(wallets.map((w) => w.trim()))].slice(0, 50); + return Promise.all( + unique.map(async (wallet) => { + try { + const profile = await getProfile(wallet); + if (!profile?.avatar_token_id) return { wallet, avatar: null }; + const locale = normalizeProfileLocale(profile.locale) ?? DEFAULT_PROFILE_LOCALE; + return { + wallet, + avatar: { + tokenId: profile.avatar_token_id, + image: profile.avatar_image_url ?? "", + name: localizeAvatarName(profile.avatar_token_id, locale), + }, + }; + } catch { + return { wallet, avatar: null }; + } + }), + ); +} + // ── Issue #105: Trending Signals Aggregator (phase-87) ─────────────────────── export function isPhase87Enabled(): boolean { diff --git a/lib/signal-store.ts b/lib/signal-store.ts index 14eb5894..5a5707e3 100644 --- a/lib/signal-store.ts +++ b/lib/signal-store.ts @@ -462,6 +462,37 @@ export type { AddContributorRequest, } from "@/lib/contributor-ledger"; +// ─── phase-156 (Module #56): faucet / participation deny-list with governance veto ─── +// Isolated, flag-gated. Abusive wallets previously could not be cleanly excluded. +// When enabled, the replies route rejects posts from denied wallets. When flag +// off, isWalletDenied() returns false (zero regression). +// Rollback: unset NEXT_PUBLIC_FEATURE_PHASE_156 / FEATURE_PHASE_156. +export { + isFaucetDenyListEnabled, + flag156RollbackNote, + proposeDenyListEntry, + castGovernanceVeto, + liftDenyListEntry, + isWalletDenied, + getWalletDenyEntry, + listDenyList, + getDenyListEntry, + deriveDenyStatus, + governanceSigners, + isGovernanceSigner, + clearDenyListForTests, + FaucetDenyListError, + AddDenyRequestSchema, + GovernanceVetoSchema, + DEFAULT_VETO_QUORUM, +} from "@/lib/faucet-deny-list"; +export type { + DenyListEntry, + DenyListStatus, + AddDenyRequest, + GovernanceVeto, +} from "@/lib/faucet-deny-list"; + import { z } from "zod"; export const AttributionInReplySchema = z.object({ diff --git a/lib/sybil-resistance.ts b/lib/sybil-resistance.ts new file mode 100644 index 00000000..85296371 --- /dev/null +++ b/lib/sybil-resistance.ts @@ -0,0 +1,335 @@ +/** + * Module #45 (Issue #69) — Sybil-resistance scoring for faucet / reward claims + * via on-chain account-history scoring. + * + * AUDIT NOTE (execution flow, app/api/og/* and reward paths): + * Bots farm rewards with freshly created wallets. The OG image routes resolve a + * wallet with zero trust signal, and the faucet path only rate-limits per + * wallet — trivially bypassed by minting new keypairs. There was no shared, + * testable notion of "how established is this account on chain". + * + * This module is the isolated domain for that signal: + * - SybilScoreInputSchema — type-safe feature vector for an account + * - scoreWalletHistory() — pure 0..100 trust score + band + reason list + * - isSybilSuspect() — threshold predicate + * - fetchAccountHistoryFeatures() — best-effort Horizon reader (network) + * - assessWalletSybilRisk() — flag-gated end-to-end assessment + * + * Flag: phase-145 (NEXT_PUBLIC_FEATURE_PHASE_145 / FEATURE_PHASE_145). + * When the flag is off, assessWalletSybilRisk() returns null and callers keep + * their legacy behaviour (zero regression). The pure scorer stays callable for + * isolated unit testing with `npx tsx`. + * + * Rollback: unset the flag. No persisted state — nothing to migrate. + */ + +import { z } from "zod" +import { HORIZON_URL } from "@/lib/phase-protocol" + +export function isSybilResistanceEnabled(): boolean { + const v = ( + process.env.NEXT_PUBLIC_FEATURE_PHASE_145 ?? + process.env.FEATURE_PHASE_145 ?? + "" + ) + .trim() + .toLowerCase() + return v === "1" || v === "true" || v === "yes" || v === "on" +} + +export function flag145RollbackNote(): string { + return "Rollback phase-145: unset NEXT_PUBLIC_FEATURE_PHASE_145 / FEATURE_PHASE_145 or set 0/false and restart. No persisted state." +} + +const G_ADDRESS_RE = /^G[A-Z2-7]{55}$/ + +export const SybilScoreInputSchema = z.object({ + accountAgeDays: z.number().min(0).max(100_000), + transactionCount: z.number().int().min(0), + paymentCount: z.number().int().min(0), + distinctCounterparties: z.number().int().min(0), + nativeBalance: z.number().min(0), + hasHomeDomain: z.boolean().default(false), + signerCount: z.number().int().min(1).default(1), + trustlineCount: z.number().int().min(0).default(0), + sponsoredReserves: z.number().int().min(0).default(0), +}) + +export type SybilScoreInput = z.infer + +export type SybilBand = "trusted" | "caution" | "suspect" + +export type SybilScore = { + score: number + band: SybilBand + suspect: boolean + signals: string[] +} + +export class SybilResistanceError extends Error { + readonly code: "VALIDATION_FAILED" | "FLAG_DISABLED" + readonly details?: unknown + constructor(code: SybilResistanceError["code"], message: string, details?: unknown) { + super(message) + this.name = "SybilResistanceError" + this.code = code + this.details = details + } +} + +const SUSPECT_MAX = 34 +const CAUTION_MAX = 64 + +function clampThreshold(value: number | undefined, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback + return Math.max(0, Math.min(100, value)) +} + +/** + * Pure — maps an account feature vector to a 0..100 trust score (higher = more + * established / less likely to be a throwaway sybil wallet). Throws + * SybilResistanceError on a malformed input rather than guessing. + */ +export function scoreWalletHistory( + rawInput: unknown, + opts: { suspectThreshold?: number } = {}, +): SybilScore { + const parsed = SybilScoreInputSchema.safeParse(rawInput) + if (!parsed.success) { + throw new SybilResistanceError( + "VALIDATION_FAILED", + "Sybil score input failed schema validation", + parsed.error.flatten(), + ) + } + const f = parsed.data + const signals: string[] = [] + let score = 0 + + // Account age — freshly created wallets are the primary sybil tell. + if (f.accountAgeDays >= 90) { + score += 30 + signals.push("account_age>=90d") + } else if (f.accountAgeDays >= 30) { + score += 22 + signals.push("account_age>=30d") + } else if (f.accountAgeDays >= 7) { + score += 12 + signals.push("account_age>=7d") + } else if (f.accountAgeDays >= 1) { + score += 4 + signals.push("account_age>=1d") + } else { + signals.push("account_age<1d") + } + + // Transaction-history depth. + if (f.transactionCount >= 50) { + score += 20 + signals.push("tx_count>=50") + } else if (f.transactionCount >= 10) { + score += 13 + signals.push("tx_count>=10") + } else if (f.transactionCount >= 3) { + score += 6 + signals.push("tx_count>=3") + } else { + signals.push("tx_count<3") + } + + // Economic diversity — a real user transacts with multiple counterparties. + if (f.distinctCounterparties >= 10) { + score += 18 + signals.push("counterparties>=10") + } else if (f.distinctCounterparties >= 3) { + score += 10 + signals.push("counterparties>=3") + } else if (f.distinctCounterparties >= 1) { + score += 4 + signals.push("counterparties>=1") + } else { + signals.push("counterparties=0") + } + + // Skin in the game. + if (f.nativeBalance >= 100) { + score += 12 + signals.push("balance>=100XLM") + } else if (f.nativeBalance >= 20) { + score += 8 + signals.push("balance>=20XLM") + } else if (f.nativeBalance >= 5) { + score += 4 + signals.push("balance>=5XLM") + } else { + signals.push("balance<5XLM") + } + + // Configuration effort. + if (f.hasHomeDomain) { + score += 5 + signals.push("home_domain") + } + if (f.signerCount > 1) { + score += 3 + signals.push("multi_signer") + } + if (f.trustlineCount >= 2) { + score += 5 + signals.push("trustlines>=2") + } else if (f.trustlineCount === 1) { + score += 2 + } + + // Penalty — a fully sponsored, dormant, brand-new account looks farmed. + if (f.sponsoredReserves > 0 && f.transactionCount <= 1 && f.accountAgeDays < 7) { + score -= 10 + signals.push("sponsored_dormant") + } + + score = Math.max(0, Math.min(100, Math.round(score))) + const suspectThreshold = clampThreshold(opts.suspectThreshold, SUSPECT_MAX) + const band: SybilBand = + score <= suspectThreshold ? "suspect" : score <= CAUTION_MAX ? "caution" : "trusted" + + return { score, band, suspect: band === "suspect", signals } +} + +export function isSybilSuspect(score: number, threshold = SUSPECT_MAX): boolean { + return score <= clampThreshold(threshold, SUSPECT_MAX) +} + +// ─── Horizon feature extraction (network, best-effort) ─────────────────────── + +type HorizonBalance = { balance?: string; asset_type?: string } +type HorizonAccount = { + balances?: HorizonBalance[] + signers?: unknown[] + home_domain?: string + num_sponsored?: number | string +} +type HorizonRecords = { _embedded?: { records?: Array> } } + +async function getJson(url: string, timeoutMs: number): Promise { + try { + const res = await fetch(url, { + headers: { Accept: "application/json" }, + cache: "no-store", + signal: AbortSignal.timeout(timeoutMs), + }) + if (!res.ok) return null + return await res.json() + } catch { + return null + } +} + +/** + * Reads an account's on-chain history from Horizon and reduces it to the + * feature vector scoreWalletHistory() expects. Returns null on any network + * failure (caller treats as "unknown"); a not-found account yields a + * zeroed vector (max sybil signal). `transactionCount` is capped at 200 by the + * Horizon page size and is treated as a lower bound. + */ +export async function fetchAccountHistoryFeatures( + address: string, + opts: { horizonUrl?: string; timeoutMs?: number } = {}, +): Promise { + const addr = address.trim() + if (!G_ADDRESS_RE.test(addr)) return null + const base = (opts.horizonUrl ?? HORIZON_URL).replace(/\/+$/, "") + const timeoutMs = opts.timeoutMs ?? 6000 + const enc = encodeURIComponent(addr) + + const account = (await getJson(`${base}/accounts/${enc}`, timeoutMs)) as HorizonAccount | null + if (account === null) { + // Distinguish "no account" (404 → getJson null too). Probe once more cheaply: + const probe = await fetch(`${base}/accounts/${enc}`, { + method: "HEAD", + signal: AbortSignal.timeout(timeoutMs), + }).catch(() => null) + if (probe && probe.status === 404) { + return SybilScoreInputSchema.parse({ + accountAgeDays: 0, + transactionCount: 0, + paymentCount: 0, + distinctCounterparties: 0, + nativeBalance: 0, + hasHomeDomain: false, + signerCount: 1, + trustlineCount: 0, + sponsoredReserves: 0, + }) + } + return null + } + + const balances = Array.isArray(account.balances) ? account.balances : [] + const nativeBalance = + Number.parseFloat(balances.find((b) => b.asset_type === "native")?.balance ?? "0") || 0 + const trustlineCount = balances.filter((b) => b.asset_type !== "native").length + const signerCount = Array.isArray(account.signers) ? Math.max(1, account.signers.length) : 1 + const hasHomeDomain = + typeof account.home_domain === "string" && account.home_domain.trim().length > 0 + const sponsoredReserves = Number(account.num_sponsored ?? 0) || 0 + + const [txPage, payPage] = await Promise.all([ + getJson( + `${base}/accounts/${enc}/transactions?order=desc&limit=200&include_failed=false`, + timeoutMs, + ) as Promise, + getJson(`${base}/accounts/${enc}/payments?order=desc&limit=200`, timeoutMs) as Promise< + HorizonRecords | null + >, + ]) + + const txRecords = txPage?._embedded?.records ?? [] + const transactionCount = txRecords.length + let accountAgeDays = 0 + const oldest = txRecords[txRecords.length - 1] + const oldestCreatedAt = oldest?.["created_at"] + if (typeof oldestCreatedAt === "string") { + const ms = Date.now() - new Date(oldestCreatedAt).getTime() + if (Number.isFinite(ms) && ms > 0) accountAgeDays = ms / 86_400_000 + } + + const payRecords = payPage?._embedded?.records ?? [] + const paymentCount = payRecords.length + const counterparties = new Set() + for (const p of payRecords) { + for (const key of ["from", "to", "source_account", "account", "funder"]) { + const v = p[key] + if (typeof v === "string" && v !== addr && G_ADDRESS_RE.test(v)) counterparties.add(v) + } + } + + return SybilScoreInputSchema.parse({ + accountAgeDays, + transactionCount, + paymentCount, + distinctCounterparties: counterparties.size, + nativeBalance, + hasHomeDomain, + signerCount, + trustlineCount, + sponsoredReserves, + }) +} + +/** + * Flag-gated end-to-end assessment. Returns null when phase-145 is off or when + * on-chain history could not be read. + */ +export async function assessWalletSybilRisk( + address: string, + opts: { horizonUrl?: string; timeoutMs?: number; suspectThreshold?: number } = {}, +): Promise { + if (!isSybilResistanceEnabled()) return null + const features = await fetchAccountHistoryFeatures(address, opts) + if (!features) return null + try { + return scoreWalletHistory(features, { suspectThreshold: opts.suspectThreshold }) + } catch { + return null + } +} diff --git a/lib/x402-dead-letter.ts b/lib/x402-dead-letter.ts new file mode 100644 index 00000000..9f7dcc14 --- /dev/null +++ b/lib/x402-dead-letter.ts @@ -0,0 +1,291 @@ +/** + * Module #44 (Issue #68) — Quarantine malformed x402 invoices in a dead-letter review queue. + * + * AUDIT NOTE (execution flow, app/api/classic-liq/trustline/route.ts): + * The trustline POST handler parses the request body with a zod schema and, on + * failure, returned a bare 400 carrying `parsed.error.flatten()`. The malformed + * payload itself was dropped — no persistence, no audit trail, no operator + * visibility into WHAT the caller sent or how often a bad shape recurs. The + * x402 settlement-invoice envelope attached to that call had the same hole: a + * bad `cid` / `expectedSha256` / `cidPath` combination produced a 400/409 and + * vanished. + * + * This module is the isolated domain for malformed-invoice quarantine: + * - X402InvoiceSchema — type-safe schema for the x402 invoice envelope + * - classifyInvoice() — pure validation → typed reasons, zero I/O + * - quarantineInvoice() — appends the rejected payload + reasons to a + * dead-letter JSON sidecar, redacting obviously-secret fields first + * - listDeadLetterQueue() / getDeadLetterEntry() / resolveDeadLetterEntry() + * — operator review surface + * + * Flag: phase-144 (NEXT_PUBLIC_FEATURE_PHASE_144 / FEATURE_PHASE_144). + * When the flag is off, quarantineInvoice() is a no-op returning + * { quarantined: false, reason: "flag-disabled" } and the route keeps its + * legacy bare-400 behaviour (zero regression). The pure helpers stay callable + * so they can be unit-tested in isolation with `npx tsx`. + * + * Rollback: unset the flag. The x402-dead-letter.json sidecar can be deleted; + * nothing else references it. + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises" +import path from "node:path" +import { createHash, randomUUID } from "node:crypto" +import { z } from "zod" +import { serverDataJsonPath } from "@/lib/server-data-paths" + +export function isX402DeadLetterEnabled(): boolean { + const v = ( + process.env.NEXT_PUBLIC_FEATURE_PHASE_144 ?? + process.env.FEATURE_PHASE_144 ?? + "" + ) + .trim() + .toLowerCase() + return v === "1" || v === "true" || v === "yes" || v === "on" +} + +export function flag144RollbackNote(): string { + return "Rollback phase-144: unset NEXT_PUBLIC_FEATURE_PHASE_144 / FEATURE_PHASE_144 or set 0/false and restart. The x402-dead-letter.json sidecar can be deleted; nothing else reads it." +} + +// ─── x402 invoice envelope schema ─────────────────────────────────────────── + +export const X402InvoiceSchema = z.object({ + invoiceId: z.string().trim().min(1).max(128), + amount: z + .string() + .trim() + .regex(/^\d+(\.\d{1,7})?$/, "amount must be a non-negative decimal with <= 7 dp"), + asset: z.string().trim().min(1).max(64), + payTo: z + .string() + .trim() + .length(56) + .regex(/^G[A-Z2-7]{55}$/, "payTo must be a Stellar public (G...) address"), + network: z.enum(["testnet", "mainnet", "pubnet"]).default("testnet"), + nonce: z.string().trim().min(1).max(128).optional(), + expiresAt: z.number().int().positive().optional(), + memo: z.string().trim().max(256).optional(), +}) + +export type X402Invoice = z.infer + +export type DeadLetterReason = { path: string; code: string; message: string } + +export type DeadLetterStatus = "open" | "resolved" | "discarded" + +export type X402DeadLetterEntry = { + id: string + source: string + received_at: number + status: DeadLetterStatus + fingerprint: string + reasons: DeadLetterReason[] + raw_payload: unknown + resolved_at?: number + resolved_by?: string + resolution_note?: string +} + +export class X402DeadLetterError extends Error { + readonly code: "FLAG_DISABLED" | "STORE_WRITE_FAILED" | "NOT_FOUND" + constructor(code: X402DeadLetterError["code"], message: string) { + super(message) + this.name = "X402DeadLetterError" + this.code = code + } +} + +// ─── Pure classification ──────────────────────────────────────────────────── + +export type InvoiceClassification = + | { ok: true; invoice: X402Invoice } + | { ok: false; reasons: DeadLetterReason[] } + +export function zodIssuesToReasons(issues: z.ZodIssue[]): DeadLetterReason[] { + return issues.map((issue) => ({ + path: issue.path.join(".") || "(root)", + code: issue.code, + message: issue.message, + })) +} + +/** Pure — validates an x402 invoice envelope and returns typed rejection reasons. */ +export function classifyInvoice(raw: unknown): InvoiceClassification { + const parsed = X402InvoiceSchema.safeParse(raw) + if (parsed.success) return { ok: true, invoice: parsed.data } + return { ok: false, reasons: zodIssuesToReasons(parsed.error.issues) } +} + +function normalizeReasons( + reasons: DeadLetterReason[] | z.ZodIssue[] | undefined, +): DeadLetterReason[] { + if (!reasons || reasons.length === 0) { + return [{ path: "(root)", code: "unknown", message: "unspecified validation failure" }] + } + return reasons.map((reason) => { + if (Array.isArray((reason as z.ZodIssue).path)) { + const issue = reason as z.ZodIssue + return { path: issue.path.join(".") || "(root)", code: issue.code, message: issue.message } + } + const dr = reason as DeadLetterReason + return { + path: dr.path ?? "(root)", + code: dr.code ?? "invalid", + message: dr.message ?? "invalid", + } + }) +} + +const SECRET_KEY_RE = /(secret|seed|priv|passphrase|password|token|jwt|mnemonic|api[_-]?key)/i +const MAX_STRING_LEN = 4096 +const MAX_DEPTH = 6 + +/** Deep-copies `value`, replacing secret-looking keys with "[redacted]" and clamping huge strings. */ +export function redactSecrets(value: unknown, depth = 0): unknown { + if (depth > MAX_DEPTH) return "[truncated:max-depth]" + if (Array.isArray(value)) { + return value.slice(0, 100).map((entry) => redactSecrets(entry, depth + 1)) + } + if (value && typeof value === "object") { + const out: Record = {} + for (const [key, entry] of Object.entries(value as Record)) { + out[key] = SECRET_KEY_RE.test(key) ? "[redacted]" : redactSecrets(entry, depth + 1) + } + return out + } + if (typeof value === "string" && value.length > MAX_STRING_LEN) { + return `${value.slice(0, MAX_STRING_LEN)}…[truncated]` + } + return value +} + +export function fingerprintPayload(raw: unknown): string { + let serialized: string + try { + serialized = JSON.stringify(raw) ?? "null" + } catch { + serialized = String(raw) + } + return createHash("sha256").update(serialized).digest("hex").slice(0, 32) +} + +// ─── Dead-letter store ────────────────────────────────────────────────────── + +type DeadLetterStore = Record + +async function readStore(): Promise { + try { + const raw = await readFile(serverDataJsonPath("x402DeadLetter"), "utf8") + const parsed = JSON.parse(raw) as DeadLetterStore + return parsed && typeof parsed === "object" ? parsed : {} + } catch { + return {} + } +} + +async function writeStore(data: DeadLetterStore): Promise { + const filePath = serverDataJsonPath("x402DeadLetter") + try { + await mkdir(path.dirname(filePath), { recursive: true }) + await writeFile(filePath, JSON.stringify(data, null, 2), "utf8") + } catch (e) { + throw new X402DeadLetterError( + "STORE_WRITE_FAILED", + e instanceof Error ? e.message : String(e), + ) + } +} + +export type QuarantineInput = { + source: string + raw: unknown + reasons?: DeadLetterReason[] | z.ZodIssue[] +} + +export type QuarantineResult = + | { quarantined: true; id: string; fingerprint: string; duplicateOf?: string } + | { quarantined: false; reason: "flag-disabled" } + +/** + * Persists a rejected payload into the dead-letter review queue. No-op (returns + * flag-disabled) when phase-144 is off. Never throws for the caller's benefit — + * a store-write failure is surfaced as a rejected promise the route can swallow. + */ +export async function quarantineInvoice(input: QuarantineInput): Promise { + if (!isX402DeadLetterEnabled()) return { quarantined: false, reason: "flag-disabled" } + + const reasons = normalizeReasons(input.reasons).slice(0, 50) + const fingerprint = fingerprintPayload(input.raw) + const store = await readStore() + const priorOpen = Object.values(store).find( + (entry) => entry.fingerprint === fingerprint && entry.status === "open", + ) + + const id = randomUUID() + store[id] = { + id, + source: String(input.source).slice(0, 128), + received_at: Date.now(), + status: "open", + fingerprint, + reasons, + raw_payload: redactSecrets(input.raw), + } + await writeStore(store) + + return priorOpen + ? { quarantined: true, id, fingerprint, duplicateOf: priorOpen.id } + : { quarantined: true, id, fingerprint } +} + +export async function listDeadLetterQueue( + opts: { status?: DeadLetterStatus; limit?: number } = {}, +): Promise { + const store = await readStore() + let items = Object.values(store) + if (opts.status) items = items.filter((entry) => entry.status === opts.status) + items.sort((a, b) => b.received_at - a.received_at) + return typeof opts.limit === "number" ? items.slice(0, Math.max(0, opts.limit)) : items +} + +export async function getDeadLetterEntry(id: string): Promise { + const store = await readStore() + return store[id] ?? null +} + +export async function resolveDeadLetterEntry( + id: string, + opts: { status?: "resolved" | "discarded"; by?: string; note?: string } = {}, +): Promise { + const store = await readStore() + const entry = store[id] + if (!entry) throw new X402DeadLetterError("NOT_FOUND", `Dead-letter entry ${id} not found`) + entry.status = opts.status ?? "resolved" + entry.resolved_at = Date.now() + if (opts.by) entry.resolved_by = opts.by.slice(0, 64) + if (opts.note) entry.resolution_note = opts.note.slice(0, 512) + await writeStore(store) + return entry +} + +export async function getDeadLetterStats(): Promise<{ + total: number + open: number + resolved: number + discarded: number +}> { + const items = Object.values(await readStore()) + return { + total: items.length, + open: items.filter((e) => e.status === "open").length, + resolved: items.filter((e) => e.status === "resolved").length, + discarded: items.filter((e) => e.status === "discarded").length, + } +} + +/** Test helper — empties the sidecar. */ +export async function clearDeadLetterForTests(): Promise { + await writeStore({}) +} diff --git a/tests/faucet-deny-list.test.ts b/tests/faucet-deny-list.test.ts new file mode 100644 index 00000000..abb81ea2 --- /dev/null +++ b/tests/faucet-deny-list.test.ts @@ -0,0 +1,177 @@ +/** + * Module #56 (Issue #78) — Faucet deny-list with on-chain governance veto. + * Run: npx tsx tests/faucet-deny-list.test.ts + */ +import { describe, it, beforeEach } from "node:test" +import * as assert from "node:assert/strict" +import os from "node:os" +import path from "node:path" + +process.env.PHASE_SERVER_DATA_DIR = path.join( + os.tmpdir(), + `phase-denylist-${Date.now()}-${Math.random().toString(36).slice(2)}`, +) + +const base32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" +function makeG(seed: number): string { + let s = "G" + for (let i = 0; i < 55; i++) s += base32[(seed * 7 + i * 13) % 32] + return s +} + +const BAD_WALLET = makeG(1) +const GOV_A = makeG(10) +const GOV_B = makeG(11) +const GOV_C = makeG(12) +const NON_GOV = makeG(99) + +process.env.NEXT_PUBLIC_FEATURE_PHASE_156 = "1" +process.env.FEATURE_PHASE_156 = "1" +process.env.PHASE_GOVERNANCE_SIGNERS = [GOV_A, GOV_B, GOV_C].join(",") + +import { + isFaucetDenyListEnabled, + proposeDenyListEntry, + castGovernanceVeto, + liftDenyListEntry, + isWalletDenied, + getWalletDenyEntry, + listDenyList, + deriveDenyStatus, + governanceSigners, + isGovernanceSigner, + clearDenyListForTests, + FaucetDenyListError, + AddDenyRequestSchema, +} from "@/lib/faucet-deny-list" + +beforeEach(async () => { + await clearDenyListForTests() +}) + +describe("schema + pure helpers", () => { + it("validates an add request", () => { + const ok = AddDenyRequestSchema.safeParse({ wallet: BAD_WALLET, reason: "spam farm", proposedBy: "mod1" }) + assert.equal(ok.success, true) + const bad = AddDenyRequestSchema.safeParse({ wallet: "nope", reason: "abuse", proposedBy: "" }) + assert.equal(bad.success, false) + }) + + it("reads governance signers from env", () => { + assert.deepEqual(governanceSigners().sort(), [GOV_A, GOV_B, GOV_C].sort()) + assert.equal(isGovernanceSigner(GOV_A), true) + assert.equal(isGovernanceSigner(NON_GOV), false) + }) + + it("deriveDenyStatus flips to vetoed at quorum", () => { + assert.equal( + deriveDenyStatus({ status: "active", veto_quorum: 2, vetoes: [{ signer: GOV_A, cast_at: 1 }] }), + "active", + ) + assert.equal( + deriveDenyStatus({ + status: "active", + veto_quorum: 2, + vetoes: [ + { signer: GOV_A, cast_at: 1 }, + { signer: GOV_B, cast_at: 2 }, + ], + }), + "vetoed", + ) + }) +}) + +describe("propose + isWalletDenied", () => { + it("denies a wallet immediately on propose (deny-first)", async () => { + assert.equal(isFaucetDenyListEnabled(), true) + assert.equal(await isWalletDenied(BAD_WALLET), false) + const entry = await proposeDenyListEntry({ wallet: BAD_WALLET, reason: "sybil farm", proposedBy: "mod1" }) + assert.equal(entry.status, "active") + assert.equal(await isWalletDenied(BAD_WALLET), true) + const active = await getWalletDenyEntry(BAD_WALLET) + assert.equal(active?.id, entry.id) + }) + + it("rejects a duplicate active proposal", async () => { + await proposeDenyListEntry({ wallet: BAD_WALLET, reason: "sybil farm", proposedBy: "mod1" }) + await assert.rejects( + () => proposeDenyListEntry({ wallet: BAD_WALLET, reason: "again", proposedBy: "mod2" }), + (e: unknown) => e instanceof FaucetDenyListError && e.code === "ALREADY_EXISTS", + ) + }) + + it("throws VALIDATION_FAILED on a malformed proposal", async () => { + await assert.rejects( + () => proposeDenyListEntry({ wallet: "bad", reason: "abuse", proposedBy: "" }), + (e: unknown) => e instanceof FaucetDenyListError && e.code === "VALIDATION_FAILED", + ) + }) +}) + +describe("governance veto", () => { + it("lifts the deny once a quorum of distinct governance signers veto", async () => { + const entry = await proposeDenyListEntry({ + wallet: BAD_WALLET, + reason: "disputed", + proposedBy: "mod1", + vetoQuorum: 3, + }) + await castGovernanceVeto(entry.id, { signer: GOV_A, note: "false positive" }) + await castGovernanceVeto(entry.id, { signer: GOV_B }) + assert.equal(await isWalletDenied(BAD_WALLET), true) + const final = await castGovernanceVeto(entry.id, { signer: GOV_C }) + assert.equal(final.status, "vetoed") + assert.equal(await isWalletDenied(BAD_WALLET), false) + }) + + it("rejects a veto from a non-governance signer", async () => { + const entry = await proposeDenyListEntry({ wallet: BAD_WALLET, reason: "abuse", proposedBy: "mod1" }) + await assert.rejects( + () => castGovernanceVeto(entry.id, { signer: NON_GOV }), + (e: unknown) => e instanceof FaucetDenyListError && e.code === "NOT_GOVERNANCE_SIGNER", + ) + }) + + it("rejects a duplicate veto from the same signer", async () => { + const entry = await proposeDenyListEntry({ wallet: BAD_WALLET, reason: "abuse", proposedBy: "mod1" }) + await castGovernanceVeto(entry.id, { signer: GOV_A }) + await assert.rejects( + () => castGovernanceVeto(entry.id, { signer: GOV_A }), + (e: unknown) => e instanceof FaucetDenyListError && e.code === "DUPLICATE_VETO", + ) + }) + + it("throws NOT_FOUND for an unknown entry", async () => { + await assert.rejects( + () => castGovernanceVeto("missing", { signer: GOV_A }), + (e: unknown) => e instanceof FaucetDenyListError && e.code === "NOT_FOUND", + ) + }) +}) + +describe("lift + listing", () => { + it("manual lift removes the deny and is reflected in listings", async () => { + const entry = await proposeDenyListEntry({ wallet: BAD_WALLET, reason: "abuse", proposedBy: "mod1" }) + await liftDenyListEntry(entry.id, "admin1") + assert.equal(await isWalletDenied(BAD_WALLET), false) + const lifted = await listDenyList({ status: "lifted" }) + assert.equal(lifted.length, 1) + assert.equal(lifted[0].lifted_by, "admin1") + }) +}) + +describe("flag off", () => { + it("isWalletDenied returns false and propose throws FLAG_DISABLED", async () => { + delete process.env.NEXT_PUBLIC_FEATURE_PHASE_156 + delete process.env.FEATURE_PHASE_156 + assert.equal(isFaucetDenyListEnabled(), false) + assert.equal(await isWalletDenied(BAD_WALLET), false) + await assert.rejects( + () => proposeDenyListEntry({ wallet: BAD_WALLET, reason: "abuse", proposedBy: "mod1" }), + (e: unknown) => e instanceof FaucetDenyListError && e.code === "FLAG_DISABLED", + ) + process.env.NEXT_PUBLIC_FEATURE_PHASE_156 = "1" + process.env.FEATURE_PHASE_156 = "1" + }) +}) diff --git a/tests/nft-grid-virtualization.test.ts b/tests/nft-grid-virtualization.test.ts new file mode 100644 index 00000000..d77766fe --- /dev/null +++ b/tests/nft-grid-virtualization.test.ts @@ -0,0 +1,153 @@ +/** + * Module #57 (Issue #79) — NFT grid virtualization windowing math. + * Run: npx tsx tests/nft-grid-virtualization.test.ts + */ +import { describe, it } from "node:test" +import * as assert from "node:assert/strict" + +import { + computeGridWindow, + computeColumnCount, + clampScrollTop, + sliceVisible, + nftGridOverscanPx, + isNftGridVirtualizationEnabled, + VirtualGridError, + DEFAULT_OVERSCAN_PX, + LEGACY_OVERSCAN_PX, +} from "@/lib/nft-grid-virtualization" + +describe("computeColumnCount", () => { + it("mirrors auto-fill minmax behaviour", () => { + assert.equal(computeColumnCount(1000, 200, 0), 5) + assert.equal(computeColumnCount(1000, 200, 16), 4) // (1000+16)/(216) = 4.7 -> 4 + assert.equal(computeColumnCount(100, 200, 0), 1) + assert.equal(computeColumnCount(0, 200, 0), 1) + }) +}) + +describe("clampScrollTop", () => { + it("clamps to [0, totalHeight - viewportHeight]", () => { + assert.equal(clampScrollTop(-50, 1000, 300), 0) + assert.equal(clampScrollTop(50_000, 1000, 300), 700) + assert.equal(clampScrollTop(400, 1000, 300), 400) + assert.equal(clampScrollTop(Number.NaN, 1000, 300), 0) + }) +}) + +describe("computeGridWindow", () => { + it("returns an empty window for zero items", () => { + const win = computeGridWindow({ + itemCount: 0, + rowHeight: 100, + columns: 4, + viewportHeight: 600, + scrollTop: 0, + }) + assert.deepEqual(win.visibleIndices, []) + assert.equal(win.totalHeight, 0) + assert.equal(win.endIndex, 0) + }) + + it("windows a large grid to a small visible slice near the top", () => { + const win = computeGridWindow({ + itemCount: 10_000, + rowHeight: 200, + columns: 5, + viewportHeight: 800, + scrollTop: 0, + overscanRows: 2, + gap: 0, + }) + // 10000 / 5 = 2000 rows; row stride 200; total height 400000 + assert.equal(win.totalRows, 2000) + assert.equal(win.totalHeight, 400_000) + assert.equal(win.startIndex, 0) + // visible rows 0..4 (800/200) + 2 overscan => rows 0..6 => 7*5 = 35 items + assert.equal(win.endIndex, 35) + assert.equal(win.visibleIndices.length, 35) + assert.ok(win.visibleIndices.length < 100, "must not materialize the whole grid") + }) + + it("advances the window and spacer offset when scrolled", () => { + const win = computeGridWindow({ + itemCount: 10_000, + rowHeight: 200, + columns: 5, + viewportHeight: 800, + scrollTop: 100_000, // row 500 + overscanRows: 2, + }) + assert.equal(win.startRow, 498) + assert.equal(win.startIndex, 498 * 5) + assert.equal(win.offsetY, 498 * 200) + assert.ok(win.visibleIndices[0] === 2490) + }) + + it("does not run past the last row when scrolled to the bottom", () => { + const win = computeGridWindow({ + itemCount: 103, // 26 rows at 4 cols (last row has 3) + rowHeight: 100, + columns: 4, + viewportHeight: 500, + scrollTop: 999_999, + overscanRows: 1, + }) + assert.equal(win.totalRows, 26) + assert.equal(win.endRow, 25) + assert.equal(win.endIndex, 103) + assert.equal(win.visibleIndices[win.visibleIndices.length - 1], 102) + }) + + it("accounts for row gap in stride and total height", () => { + const win = computeGridWindow({ + itemCount: 12, + rowHeight: 100, + columns: 3, + viewportHeight: 250, + scrollTop: 0, + gap: 20, + }) + // 4 rows, stride 120, totalHeight = 4*120 - 20 = 460 + assert.equal(win.totalRows, 4) + assert.equal(win.totalHeight, 460) + }) + + it("throws a typed error on malformed params", () => { + assert.throws( + () => computeGridWindow({ itemCount: -1, rowHeight: 0, columns: 0, viewportHeight: -5, scrollTop: -1 }), + (e: unknown) => e instanceof VirtualGridError && e.code === "VALIDATION_FAILED", + ) + }) +}) + +describe("sliceVisible", () => { + it("returns only the windowed items", () => { + const items = Array.from({ length: 1000 }, (_, i) => i) + const win = computeGridWindow({ + itemCount: items.length, + rowHeight: 100, + columns: 4, + viewportHeight: 400, + scrollTop: 0, + overscanRows: 1, + }) + const slice = sliceVisible(items, win) + assert.equal(slice[0], win.startIndex) + assert.equal(slice.length, win.endIndex - win.startIndex) + }) +}) + +describe("overscan flag gate", () => { + it("widens overscan only when phase-157 is enabled", () => { + delete process.env.NEXT_PUBLIC_FEATURE_PHASE_157 + delete process.env.FEATURE_PHASE_157 + assert.equal(isNftGridVirtualizationEnabled(), false) + assert.equal(nftGridOverscanPx(), LEGACY_OVERSCAN_PX) + + process.env.FEATURE_PHASE_157 = "1" + assert.equal(isNftGridVirtualizationEnabled(), true) + assert.equal(nftGridOverscanPx(), DEFAULT_OVERSCAN_PX) + delete process.env.FEATURE_PHASE_157 + }) +}) diff --git a/tests/sybil-resistance.test.ts b/tests/sybil-resistance.test.ts new file mode 100644 index 00000000..f5f84845 --- /dev/null +++ b/tests/sybil-resistance.test.ts @@ -0,0 +1,160 @@ +/** + * Module #45 (Issue #69) — Sybil-resistance on-chain history scoring. + * Run: npx tsx tests/sybil-resistance.test.ts + */ +import { describe, it } from "node:test" +import * as assert from "node:assert/strict" + +import { + SybilScoreInputSchema, + scoreWalletHistory, + isSybilSuspect, + SybilResistanceError, + isSybilResistanceEnabled, + assessWalletSybilRisk, +} from "@/lib/sybil-resistance" + +function features(over: Partial> = {}): Record { + return { + accountAgeDays: 120, + transactionCount: 80, + paymentCount: 40, + distinctCounterparties: 15, + nativeBalance: 250, + hasHomeDomain: true, + signerCount: 2, + trustlineCount: 4, + sponsoredReserves: 0, + ...over, + } +} + +// ~55/100 — established enough to clear the suspect band, not enough for trusted. +function cautionFeatures(): Record { + return features({ + accountAgeDays: 40, + transactionCount: 12, + paymentCount: 6, + distinctCounterparties: 4, + nativeBalance: 25, + hasHomeDomain: false, + signerCount: 1, + trustlineCount: 1, + }) +} + +describe("schema", () => { + it("applies defaults for optional config fields", () => { + const parsed = SybilScoreInputSchema.parse({ + accountAgeDays: 1, + transactionCount: 0, + paymentCount: 0, + distinctCounterparties: 0, + nativeBalance: 0, + }) + assert.equal(parsed.hasHomeDomain, false) + assert.equal(parsed.signerCount, 1) + assert.equal(parsed.trustlineCount, 0) + }) + + it("rejects negative counts", () => { + assert.equal(SybilScoreInputSchema.safeParse(features({ transactionCount: -1 })).success, false) + }) +}) + +describe("scoreWalletHistory (pure)", () => { + it("scores an established account as trusted", () => { + const s = scoreWalletHistory(features()) + assert.ok(s.score >= 80, `expected high score, got ${s.score}`) + assert.equal(s.band, "trusted") + assert.equal(s.suspect, false) + assert.ok(s.signals.includes("account_age>=90d")) + }) + + it("scores a freshly created empty wallet as suspect", () => { + const s = scoreWalletHistory( + features({ + accountAgeDays: 0.2, + transactionCount: 0, + paymentCount: 0, + distinctCounterparties: 0, + nativeBalance: 1, + hasHomeDomain: false, + signerCount: 1, + trustlineCount: 0, + }), + ) + assert.ok(s.score <= 20, `expected low score, got ${s.score}`) + assert.equal(s.band, "suspect") + assert.equal(s.suspect, true) + assert.ok(s.signals.includes("account_age<1d")) + }) + + it("puts a partially-established account in the caution band", () => { + const s = scoreWalletHistory(cautionFeatures()) + assert.equal(s.band, "caution", `score was ${s.score}`) + }) + + it("applies the sponsored-dormant penalty", () => { + const withPenalty = scoreWalletHistory( + features({ + accountAgeDays: 2, + transactionCount: 1, + paymentCount: 0, + distinctCounterparties: 0, + nativeBalance: 0, + hasHomeDomain: false, + signerCount: 1, + trustlineCount: 0, + sponsoredReserves: 3, + }), + ) + assert.ok(withPenalty.signals.includes("sponsored_dormant")) + assert.equal(withPenalty.score, 0) + }) + + it("clamps score into 0..100", () => { + const s = scoreWalletHistory( + features({ accountAgeDays: 100000, transactionCount: 1e6, distinctCounterparties: 1e6, nativeBalance: 1e9 }), + ) + assert.ok(s.score >= 0 && s.score <= 100) + }) + + it("honours a custom suspect threshold", () => { + const base = cautionFeatures() + const lenient = scoreWalletHistory(base) + const strict = scoreWalletHistory(base, { suspectThreshold: 90 }) + assert.equal(lenient.band, "caution") + assert.equal(strict.band, "suspect") + }) + + it("throws a typed error on malformed input", () => { + assert.throws( + () => scoreWalletHistory({ nope: true }), + (e: unknown) => e instanceof SybilResistanceError && e.code === "VALIDATION_FAILED", + ) + }) +}) + +describe("isSybilSuspect", () => { + it("treats low scores as suspect at the default threshold", () => { + assert.equal(isSybilSuspect(10), true) + assert.equal(isSybilSuspect(70), false) + }) +}) + +describe("assessWalletSybilRisk (flag gate)", () => { + it("returns null when phase-145 is disabled", async () => { + delete process.env.NEXT_PUBLIC_FEATURE_PHASE_145 + delete process.env.FEATURE_PHASE_145 + assert.equal(isSybilResistanceEnabled(), false) + assert.equal(await assessWalletSybilRisk("GA" + "A".repeat(54)), null) + }) + + it("returns null for a malformed address even when enabled", async () => { + process.env.FEATURE_PHASE_145 = "1" + assert.equal(isSybilResistanceEnabled(), true) + assert.equal(await assessWalletSybilRisk("not-an-address"), null) + delete process.env.FEATURE_PHASE_145 + }) +}) diff --git a/tests/x402-dead-letter.test.ts b/tests/x402-dead-letter.test.ts new file mode 100644 index 00000000..1ed89faf --- /dev/null +++ b/tests/x402-dead-letter.test.ts @@ -0,0 +1,186 @@ +/** + * Module #44 (Issue #68) — x402 malformed-invoice dead-letter quarantine. + * Run: npx tsx tests/x402-dead-letter.test.ts + */ +import { describe, it } from "node:test" +import * as assert from "node:assert/strict" +import os from "node:os" +import path from "node:path" + +process.env.PHASE_SERVER_DATA_DIR = path.join( + os.tmpdir(), + `phase-x402dl-${Date.now()}-${Math.random().toString(36).slice(2)}`, +) + +import { + X402InvoiceSchema, + classifyInvoice, + zodIssuesToReasons, + redactSecrets, + fingerprintPayload, + quarantineInvoice, + listDeadLetterQueue, + getDeadLetterEntry, + resolveDeadLetterEntry, + getDeadLetterStats, + clearDeadLetterForTests, + isX402DeadLetterEnabled, + X402DeadLetterError, +} from "@/lib/x402-dead-letter" + +const VALID_G = "GA" + "A".repeat(54) + +function validInvoice(over: Record = {}): unknown { + return { + invoiceId: "inv_123", + amount: "10.5000000", + asset: "USDC", + payTo: VALID_G, + network: "testnet", + ...over, + } +} + +describe("x402 invoice schema + pure classification", () => { + it("accepts a well-formed invoice envelope", () => { + const res = classifyInvoice(validInvoice()) + assert.equal(res.ok, true) + if (res.ok) assert.equal(res.invoice.invoiceId, "inv_123") + }) + + it("defaults network to testnet", () => { + const parsed = X402InvoiceSchema.parse(validInvoice({ network: undefined })) + assert.equal(parsed.network, "testnet") + }) + + it("rejects bad amount / bad payTo with typed reasons", () => { + const res = classifyInvoice(validInvoice({ amount: "-1", payTo: "not-an-address" })) + assert.equal(res.ok, false) + if (!res.ok) { + const paths = [...new Set(res.reasons.map((r) => r.path))].sort() + assert.deepEqual(paths, ["amount", "payTo"]) + assert.ok(res.reasons.every((r) => typeof r.message === "string" && r.message.length > 0)) + } + }) + + it("rejects a non-object payload without throwing", () => { + const res = classifyInvoice("garbage") + assert.equal(res.ok, false) + }) + + it("zodIssuesToReasons flattens nested paths", () => { + const parsed = X402InvoiceSchema.safeParse(validInvoice({ invoiceId: "" })) + assert.equal(parsed.success, false) + if (!parsed.success) { + const reasons = zodIssuesToReasons(parsed.error.issues) + assert.equal(reasons[0].path, "invoiceId") + } + }) +}) + +describe("redaction + fingerprinting", () => { + it("redacts secret-looking keys at any depth", () => { + const out = redactSecrets({ + signedXdr: "AAAA", + wallet: { secretSeed: "SXXX", apiKey: "k", nested: { jwt: "j" } }, + list: [{ password: "p" }], + }) as any + assert.equal(out.wallet.secretSeed, "[redacted]") + assert.equal(out.wallet.apiKey, "[redacted]") + assert.equal(out.wallet.nested.jwt, "[redacted]") + assert.equal(out.list[0].password, "[redacted]") + assert.equal(out.signedXdr, "AAAA") + }) + + it("clamps very long strings", () => { + const out = redactSecrets({ blob: "x".repeat(9000) }) as any + assert.ok(out.blob.length < 5000) + assert.ok(out.blob.endsWith("…[truncated]")) + }) + + it("fingerprint is stable and deterministic", () => { + const a = fingerprintPayload({ a: 1, b: 2 }) + const b = fingerprintPayload({ a: 1, b: 2 }) + assert.equal(a, b) + assert.notEqual(a, fingerprintPayload({ a: 1, b: 3 })) + }) +}) + +describe("dead-letter store (flag on)", () => { + process.env.NEXT_PUBLIC_FEATURE_PHASE_144 = "1" + process.env.FEATURE_PHASE_144 = "1" + + it("flag helper reflects env", () => { + assert.equal(isX402DeadLetterEnabled(), true) + }) + + it("quarantines a malformed payload with reasons and redacted body", async () => { + await clearDeadLetterForTests() + const bad = validInvoice({ amount: "nope", secretSeed: "SABC" }) + const parsed = X402InvoiceSchema.safeParse(bad) + assert.equal(parsed.success, false) + const result = await quarantineInvoice({ + source: "unit-test", + raw: bad, + reasons: parsed.success ? [] : parsed.error.issues, + }) + assert.equal(result.quarantined, true) + if (result.quarantined) { + const entry = await getDeadLetterEntry(result.id) + assert.ok(entry) + assert.equal(entry!.status, "open") + assert.equal(entry!.source, "unit-test") + assert.ok(entry!.reasons.length >= 1) + assert.equal((entry!.raw_payload as any).secretSeed, "[redacted]") + } + }) + + it("flags a repeat of the same payload as duplicateOf", async () => { + await clearDeadLetterForTests() + const bad = validInvoice({ amount: "nope" }) + const first = await quarantineInvoice({ source: "t", raw: bad }) + const second = await quarantineInvoice({ source: "t", raw: bad }) + assert.equal(first.quarantined && second.quarantined, true) + if (first.quarantined && second.quarantined) { + assert.equal(second.duplicateOf, first.id) + } + }) + + it("lists queue newest-first and filters by status; resolve updates stats", async () => { + await clearDeadLetterForTests() + const a = await quarantineInvoice({ source: "t", raw: validInvoice({ amount: "x1" }) }) + const b = await quarantineInvoice({ source: "t", raw: validInvoice({ amount: "x2" }) }) + assert.ok(a.quarantined && b.quarantined) + + const open = await listDeadLetterQueue({ status: "open" }) + assert.equal(open.length, 2) + assert.ok(open[0].received_at >= open[1].received_at) + + if (a.quarantined) { + const resolved = await resolveDeadLetterEntry(a.id, { status: "resolved", by: "op1", note: "fixed client" }) + assert.equal(resolved.status, "resolved") + assert.equal(resolved.resolved_by, "op1") + } + const stats = await getDeadLetterStats() + assert.equal(stats.total, 2) + assert.equal(stats.open, 1) + assert.equal(stats.resolved, 1) + }) + + it("resolveDeadLetterEntry throws a typed error for an unknown id", async () => { + await assert.rejects( + () => resolveDeadLetterEntry("does-not-exist"), + (e: unknown) => e instanceof X402DeadLetterError && e.code === "NOT_FOUND", + ) + }) +}) + +describe("dead-letter store (flag off)", () => { + it("quarantineInvoice is a no-op when phase-144 is disabled", async () => { + delete process.env.NEXT_PUBLIC_FEATURE_PHASE_144 + delete process.env.FEATURE_PHASE_144 + assert.equal(isX402DeadLetterEnabled(), false) + const result = await quarantineInvoice({ source: "t", raw: validInvoice({ amount: "bad" }) }) + assert.deepEqual(result, { quarantined: false, reason: "flag-disabled" }) + }) +})