From 6c1ce5fdaf0efc19b447d1760ffb5acbe3532ba8 Mon Sep 17 00:00:00 2001 From: privexlabs Date: Thu, 15 Oct 2026 16:05:13 -0700 Subject: [PATCH] feat: responsive focal-crop, churn winback, ve-locking, and gauge voting --- PROJECT_ARCHITECTURE.md | 4 + app/api/governance/gauge/route.ts | 108 +++++++ app/api/governance/ve-lock/route.ts | 121 ++++++++ app/api/ipfs/[...cid]/route.ts | 19 ++ app/api/notifications/winback/route.ts | 109 +++++++ components/ipfs-display-img.tsx | 15 +- docs/TECHNICAL.md | 4 + .../688-thumbnail-crop-generation-findings.md | 34 +++ ...89-dormant-winback-experiments-findings.md | 34 +++ ...0-ve-phaselq-locking-fee-share-findings.md | 35 +++ ...1-gauge-voting-quest-emissions-findings.md | 34 +++ lib/churn-winback.ts | 250 ++++++++++++++++ lib/feature-flags.ts | 18 +- lib/focal-crop.ts | 232 +++++++++++++++ lib/forge/ipfs-publisher.ts | 16 +- lib/gauge-voting.ts | 236 +++++++++++++++ lib/security-counters.ts | 11 + lib/server-data-paths.ts | 1 + lib/sqlite-db.ts | 43 +++ lib/ve-phaselq.ts | 271 ++++++++++++++++++ tests/churn-winback.test.ts | 130 +++++++++ tests/focal-crop.test.ts | 121 ++++++++ tests/gauge-voting.test.ts | 148 ++++++++++ tests/ve-phaselq-lock.test.ts | 142 +++++++++ 24 files changed, 2132 insertions(+), 4 deletions(-) create mode 100644 app/api/governance/gauge/route.ts create mode 100644 app/api/governance/ve-lock/route.ts create mode 100644 app/api/notifications/winback/route.ts create mode 100644 docs/spikes/688-thumbnail-crop-generation-findings.md create mode 100644 docs/spikes/689-dormant-winback-experiments-findings.md create mode 100644 docs/spikes/690-ve-phaselq-locking-fee-share-findings.md create mode 100644 docs/spikes/691-gauge-voting-quest-emissions-findings.md create mode 100644 lib/churn-winback.ts create mode 100644 lib/focal-crop.ts create mode 100644 lib/gauge-voting.ts create mode 100644 lib/ve-phaselq.ts create mode 100644 tests/churn-winback.test.ts create mode 100644 tests/focal-crop.test.ts create mode 100644 tests/gauge-voting.test.ts create mode 100644 tests/ve-phaselq-lock.test.ts diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index e2ca8004..b532965e 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -193,6 +193,10 @@ is a fixed-size digest of the canonical payload, keeping it under wallet | `phase-139` | `NEXT_PUBLIC_FEATURE_PHASE_139` / `FEATURE_PHASE_139` | Collection-level offer books aggregated from per-token offers, plus bulk-bid across a collection's listings | off | Unset var, restart — offer-book/bulk-bid route returns 404; per-listing offers (`/api/market/[id]/offers`) are unaffected either way | | `phase-140` | `NEXT_PUBLIC_FEATURE_PHASE_140` / `FEATURE_PHASE_140` | Royalty enforcement on secondary sales: creator/seller split computed and ledgered at offer-accept time | off | Unset var, restart — listing creation stops accepting `creator_wallet`/`royalty_bps`; offer-accept stops computing a split (100% to seller, pre-140 behavior); existing `royalty_payouts` rows are historical record | | `phase-141` | `NEXT_PUBLIC_FEATURE_PHASE_141` / `FEATURE_PHASE_141` | Yjs CRDT collaborative draft for signal lore — concurrent edits merge automatically instead of clobbering, promoted to committed lore via `PUT` (`from_draft: true`) under the normal `If-Match` guard | off | Unset var, restart — the CRDT routes return 404 and the draft panel is hidden; `PUT /api/signals/[id]` still works on its own as an `If-Match`-guarded full replacement; existing `signal_crdt_*` rows are inert scratch state (no migration to undo) | +| `phase-142` | `NEXT_PUBLIC_FEATURE_PHASE_142` / `FEATURE_PHASE_142` | AI thumbnail and crop generation with focal-point-aware variants for responsive IPFS display | off | Unset var, restart — IPFS route serves raw images without variant transform headers, client uses full master | +| `phase-143` | `NEXT_PUBLIC_FEATURE_PHASE_143` / `FEATURE_PHASE_143` | AI-driven churn and winback with dormant detection, tailored reactivation hooks, and experiment cohorts | off | Unset var, restart — winback route returns 404, experiment history in sidecar stays inert | +| `phase-144` | `NEXT_PUBLIC_FEATURE_PHASE_144` / `FEATURE_PHASE_144` | ve-PHASELQ time-locked governance escrow with pro-rata treasury fee-share distribution | off | Unset var, restart — governance ve-lock route returns 404, SQLite ve rows remain inert | +| `phase-145` | `NEXT_PUBLIC_FEATURE_PHASE_145` / `FEATURE_PHASE_145` | Gauge voting for quest emissions with ve-weight directing collection quest budgets | off | Unset var, restart — gauge route returns 404, SQLite vote rows remain inert | Flags are read via `lib/feature-flags.ts:isFeatureEnabled`. Client flags use `NEXT_PUBLIC_*`, server also accepts `FEATURE_*`. Zero regression when off. diff --git a/app/api/governance/gauge/route.ts b/app/api/governance/gauge/route.ts new file mode 100644 index 00000000..c296c225 --- /dev/null +++ b/app/api/governance/gauge/route.ts @@ -0,0 +1,108 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { isFeatureEnabled } from "@/lib/feature-flags"; +import { + castGaugeVote, + readWalletGaugeVotes, + readEpochAggregates, + distributeEpochEmissions, + getCollectionQuestBudget, + getGaugeEpochId, +} from "@/lib/gauge-voting"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const CORS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", +} as const; + +export async function OPTIONS() { + return new NextResponse(null, { status: 204, headers: CORS }); +} + +export async function GET(request: NextRequest) { + if (!isFeatureEnabled("phase-145")) { + return NextResponse.json( + { error: "phase-145 flag disabled (set NEXT_PUBLIC_FEATURE_PHASE_145=1)" }, + { status: 404, headers: CORS }, + ); + } + + const { searchParams } = new URL(request.url); + const epochParam = searchParams.get("epoch"); + const epoch = epochParam ? parseInt(epochParam, 10) : getGaugeEpochId(); + + const wallet = searchParams.get("wallet")?.trim(); + const collectionIdParam = searchParams.get("collection_id"); + + const aggregates = readEpochAggregates(epoch); + const walletVotes = wallet ? readWalletGaugeVotes(wallet, epoch) : []; + + let collectionBudget = null; + if (collectionIdParam) { + const colId = parseInt(collectionIdParam, 10); + const b = getCollectionQuestBudget(colId, epoch); + collectionBudget = { + collectionId: colId, + allocatedBudgetStroops: b.totalBudgetStroops.toString(), + veWeight: b.veWeight, + }; + } + + return NextResponse.json( + { + enabled: true, + epoch, + currentEpoch: getGaugeEpochId(), + aggregates, + walletVotes, + collectionBudget, + }, + { status: 200, headers: CORS }, + ); +} + +export async function POST(request: NextRequest) { + if (!isFeatureEnabled("phase-145")) { + return NextResponse.json( + { error: "phase-145 flag disabled (set NEXT_PUBLIC_FEATURE_PHASE_145=1)" }, + { status: 404, headers: CORS }, + ); + } + + let body: any; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400, headers: CORS }); + } + + const action = body.action?.trim() ?? "vote"; + + if (action === "distribute_emissions") { + const epoch = typeof body.epoch === "number" ? body.epoch : getGaugeEpochId(); + const totalStroops = typeof body.totalEmissionStroops === "string" ? BigInt(body.totalEmissionStroops) : 0n; + if (totalStroops <= 0n) { + return NextResponse.json({ error: "totalEmissionStroops must be positive" }, { status: 400, headers: CORS }); + } + const dist = distributeEpochEmissions(epoch, totalStroops); + return NextResponse.json({ ok: true, distribution: dist }, { status: 200, headers: CORS }); + } + + if (action === "vote") { + const wallet = body.wallet?.trim(); + if (!wallet) { + return NextResponse.json({ error: "Missing wallet field in body" }, { status: 400, headers: CORS }); + } + const votes = Array.isArray(body.votes) ? body.votes : []; + const res = castGaugeVote(wallet, votes); + if (!res.ok) { + return NextResponse.json({ error: res.error }, { status: 400, headers: CORS }); + } + return NextResponse.json({ ok: true, votes: res.records }, { status: 200, headers: CORS }); + } + + return NextResponse.json({ error: "Unknown action. Expected 'vote' or 'distribute_emissions'" }, { status: 400, headers: CORS }); +} diff --git a/app/api/governance/ve-lock/route.ts b/app/api/governance/ve-lock/route.ts new file mode 100644 index 00000000..af037f9b --- /dev/null +++ b/app/api/governance/ve-lock/route.ts @@ -0,0 +1,121 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { isFeatureEnabled } from "@/lib/feature-flags"; +import { + readVeLock, + createOrUpdateVeLock, + calculateClaimableFees, + claimFees, + withdrawVeLock, + distributeTreasuryFees, + MIN_LOCK_WEEKS, + MAX_LOCK_WEEKS, +} from "@/lib/ve-phaselq"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const CORS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", +} as const; + +export async function OPTIONS() { + return new NextResponse(null, { status: 204, headers: CORS }); +} + +export async function GET(request: NextRequest) { + if (!isFeatureEnabled("phase-144")) { + return NextResponse.json( + { error: "phase-144 flag disabled (set NEXT_PUBLIC_FEATURE_PHASE_144=1)" }, + { status: 404, headers: CORS }, + ); + } + + const { searchParams } = new URL(request.url); + const wallet = searchParams.get("wallet")?.trim(); + if (!wallet) { + return NextResponse.json({ error: "Missing wallet parameter" }, { status: 400, headers: CORS }); + } + + const lock = readVeLock(wallet); + const claimable = calculateClaimableFees(wallet); + + return NextResponse.json( + { + enabled: true, + wallet, + lock: lock ?? null, + claimableFeesStroops: claimable.claimableStroops.toString(), + epochsCount: claimable.epochsCount, + minLockWeeks: MIN_LOCK_WEEKS, + maxLockWeeks: MAX_LOCK_WEEKS, + }, + { status: 200, headers: CORS }, + ); +} + +export async function POST(request: NextRequest) { + if (!isFeatureEnabled("phase-144")) { + return NextResponse.json( + { error: "phase-144 flag disabled (set NEXT_PUBLIC_FEATURE_PHASE_144=1)" }, + { status: 404, headers: CORS }, + ); + } + + let body: any; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400, headers: CORS }); + } + + const action = body.action?.trim(); + const wallet = body.wallet?.trim(); + + if (action === "distribute_fees") { + const epochId = typeof body.epochId === "number" ? body.epochId : 0; + const feeStroops = typeof body.feeStroops === "string" ? BigInt(body.feeStroops) : 0n; + if (feeStroops <= 0n) { + return NextResponse.json({ error: "feeStroops must be positive" }, { status: 400, headers: CORS }); + } + const dist = distributeTreasuryFees(epochId, feeStroops); + return NextResponse.json({ ok: true, distribution: dist }, { status: 200, headers: CORS }); + } + + if (!wallet) { + return NextResponse.json({ error: "Missing wallet parameter" }, { status: 400, headers: CORS }); + } + + if (action === "claim") { + const claimRes = claimFees(wallet); + if (!claimRes.ok) { + return NextResponse.json({ error: claimRes.error }, { status: 400, headers: CORS }); + } + return NextResponse.json({ ok: true, ...claimRes }, { status: 200, headers: CORS }); + } + + if (action === "withdraw") { + const withdrawRes = withdrawVeLock(wallet); + if (!withdrawRes.ok) { + return NextResponse.json({ error: withdrawRes.error }, { status: 400, headers: CORS }); + } + return NextResponse.json({ ok: true, ...withdrawRes }, { status: 200, headers: CORS }); + } + + if (action === "lock") { + const amountStroops = typeof body.amountStroops === "string" ? BigInt(body.amountStroops) : 0n; + const lockWeeks = typeof body.lockWeeks === "number" ? body.lockWeeks : 0; + + const lockRes = createOrUpdateVeLock(wallet, amountStroops, lockWeeks); + if (!lockRes.ok) { + return NextResponse.json({ error: lockRes.error }, { status: 400, headers: CORS }); + } + return NextResponse.json({ ok: true, lock: lockRes.lock }, { status: 200, headers: CORS }); + } + + return NextResponse.json( + { error: "Unknown action. Expected 'lock', 'claim', 'withdraw', or 'distribute_fees'" }, + { status: 400, headers: CORS }, + ); +} diff --git a/app/api/ipfs/[...cid]/route.ts b/app/api/ipfs/[...cid]/route.ts index 85a1b9e9..d5b053be 100644 --- a/app/api/ipfs/[...cid]/route.ts +++ b/app/api/ipfs/[...cid]/route.ts @@ -60,6 +60,24 @@ export async function GET( ) } + const { isFeatureEnabled } = await import("@/lib/feature-flags") + let variantHeaders: Record = {} + + if (isFeatureEnabled("phase-142")) { + const { parseTransformParams, calculateFocalCrop, STANDARD_IMAGE_VARIANTS } = await import("@/lib/focal-crop") + const transform = parseTransformParams(request.nextUrl.searchParams) + if (transform.variant || (transform.width && transform.height)) { + const aspect = (transform.width && transform.height) + ? transform.width / transform.height + : (transform.variant ? STANDARD_IMAGE_VARIANTS[transform.variant].aspect : 1.0) + const crop = calculateFocalCrop({ width: 2048, height: 2048 }, aspect, transform.focal) + variantHeaders = { + "X-Phase-Image-Variant": transform.variant ?? "custom", + "X-Phase-Focal-Crop": `${crop.x},${crop.y},${crop.width},${crop.height}`, + } + } + } + return new NextResponse(result.bytes, { status: 200, headers: { @@ -69,6 +87,7 @@ export async function GET( Vary: "Authorization", "X-Phase-Cid-Verified": verification.ok ? "1" : "0", ...(isPhase123Enabled() ? { "X-Phase-Gateway": result.gateway, "X-Phase-Latency-Ms": String(result.latencyMs) } : {}), + ...variantHeaders, }, }) } diff --git a/app/api/notifications/winback/route.ts b/app/api/notifications/winback/route.ts new file mode 100644 index 00000000..43dd8d2f --- /dev/null +++ b/app/api/notifications/winback/route.ts @@ -0,0 +1,109 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { isFeatureEnabled } from "@/lib/feature-flags"; +import { evaluateWinbackCampaign, recordWinbackDispatch, getWinbackStore } from "@/lib/churn-winback"; +import { addNotification } from "@/lib/notification-store"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const CORS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", +} as const; + +export async function OPTIONS() { + return new NextResponse(null, { status: 204, headers: CORS }); +} + +export async function GET(request: NextRequest) { + if (!isFeatureEnabled("phase-143")) { + return NextResponse.json( + { error: "phase-143 flag disabled (set NEXT_PUBLIC_FEATURE_PHASE_143=1)" }, + { status: 404, headers: CORS }, + ); + } + + const { searchParams } = new URL(request.url); + const wallet = searchParams.get("wallet")?.trim(); + if (!wallet) { + return NextResponse.json({ error: "Missing wallet parameter" }, { status: 400, headers: CORS }); + } + + const lastActiveStr = searchParams.get("last_active"); + const lastActiveMs = lastActiveStr ? parseInt(lastActiveStr, 10) : Date.now() - 20 * 24 * 60 * 60 * 1000; + const experimentId = searchParams.get("experiment_id")?.trim() ?? "winback_v1"; + + const store = await getWinbackStore(); + const prior = store.entries[wallet.toLowerCase()]; + + const evaluation = evaluateWinbackCampaign( + wallet, + { + lastActiveMs, + lastNotifiedMs: prior?.dispatchedAt, + }, + experimentId, + ); + + return NextResponse.json({ enabled: true, evaluation, priorDispatch: prior ?? null }, { status: 200, headers: CORS }); +} + +export async function POST(request: NextRequest) { + if (!isFeatureEnabled("phase-143")) { + return NextResponse.json( + { error: "phase-143 flag disabled (set NEXT_PUBLIC_FEATURE_PHASE_143=1)" }, + { status: 404, headers: CORS }, + ); + } + + let body: any; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400, headers: CORS }); + } + + const wallet = body.wallet?.trim(); + if (!wallet) { + return NextResponse.json({ error: "Missing wallet field in body" }, { status: 400, headers: CORS }); + } + + const lastActiveMs = typeof body.lastActiveMs === "number" ? body.lastActiveMs : Date.now() - 20 * 24 * 60 * 60 * 1000; + const experimentId = body.experimentId?.trim() ?? "winback_v1"; + const store = await getWinbackStore(); + const prior = store.entries[wallet.toLowerCase()]; + + const evaluation = evaluateWinbackCampaign( + wallet, + { + lastActiveMs, + lastNotifiedMs: prior?.dispatchedAt, + activity: body.activity, + }, + experimentId, + ); + + let dispatched = false; + if (body.dispatch && evaluation.eligible && evaluation.cohort !== "control") { + await recordWinbackDispatch(wallet, evaluation.cohort, evaluation.hook, experimentId); + try { + await addNotification({ + recipientWallet: wallet, + type: "system", + title: evaluation.title, + body: evaluation.body, + data: { + cohort: evaluation.cohort, + hook: evaluation.hook, + experimentId, + }, + }); + dispatched = true; + } catch { + // In-app notification creation non-blocking + } + } + + return NextResponse.json({ ok: true, evaluation, dispatched }, { status: 200, headers: CORS }); +} diff --git a/components/ipfs-display-img.tsx b/components/ipfs-display-img.tsx index 69fc3370..fc27236c 100644 --- a/components/ipfs-display-img.tsx +++ b/components/ipfs-display-img.tsx @@ -8,12 +8,23 @@ type Props = { alt?: string className?: string loading?: "lazy" | "eager" + variant?: "thumbnail" | "preview" | "og" | "square" | "landscape" + focalPoint?: { x: number; y: number } /** Se llama cuando ningún gateway devolvió imagen válida. */ onExhausted?: () => void } -export function IpfsDisplayImg({ uri, alt = "", className, loading = "lazy", onExhausted }: Props) { - const candidates = useMemo(() => ipfsHttpsGatewayUrls(uri), [uri]) +export function IpfsDisplayImg({ uri, alt = "", className, loading = "lazy", variant, focalPoint, onExhausted }: Props) { + const candidates = useMemo(() => { + const urls = ipfsHttpsGatewayUrls(uri) + if (!variant) return urls + return urls.map((u) => { + const glue = u.includes("?") ? "&" : "?" + const fx = focalPoint?.x !== undefined ? `&fx=${focalPoint.x.toFixed(2)}` : "" + const fy = focalPoint?.y !== undefined ? `&fy=${focalPoint.y.toFixed(2)}` : "" + return `${u}${glue}variant=${variant}${fx}${fy}` + }) + }, [uri, variant, focalPoint]) const [idx, setIdx] = useState(0) useEffect(() => { diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 7a214374..8f423f91 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -179,6 +179,10 @@ settlement verifier and must not accept unsigned base64 payloads. | `phase-139` | `GET/POST /api/market/collections/[collection_id]/offer-book` | `GET` aggregates every pending offer across a collection's active listings into price levels (best price first); `POST` fans a single buyer intent into up to 20 per-listing offers, reporting `created`/`skipped` | `404` disabled; per-listing `/api/market/[id]/offers` unaffected | | `phase-140` | `POST /api/market/route.ts` (listing create) & `POST /api/market/[id]/offers/[offer_id]` (accept) | Listing create accepts `creator_wallet`/`royalty_bps`; accepting an offer on a secondary sale (`creator_wallet !== seller_wallet`) computes and ledgers a creator/seller split, returned as `royalty` on the accept response | Listing create ignores the fields; accept pays 100% to seller as before | | `phase-141` | `GET/POST /api/signals/[id]/crdt` & `PUT /api/signals/[id]` | Yjs CRDT collaborative draft for signal lore: automatic merge of concurrent edits, exposed as an `enabled` draft panel on the signal page. `PUT` with `from_draft: true` promotes a merged draft through the normal CAS commit and snapshots the pre-edit text | `404` disabled, no draft panel; `PUT` still works on its own as an `If-Match`-guarded full replacement | +| `phase-142` | `GET /api/ipfs/[...cid]` | Responsive focal-crop variants and query transforms (`variant`, `w`, `h`, `fx`, `fy`), adding `X-Phase-Image-Variant` and `X-Phase-Focal-Crop` headers | Standard raw IPFS proxy response without transforms | +| `phase-143` | `GET/POST /api/notifications/winback` | AI-driven churn and winback: dormancy tier classification, tailored return hooks, randomized experiment cohorts, and 7-day fatigue caps | `404` disabled | +| `phase-144` | `GET/POST /api/governance/ve-lock` | ve-PHASELQ time-locked governance escrow: decaying lock weight (`1-208` weeks), pro-rata treasury fee-share distribution, and claims | `404` disabled | +| `phase-145` | `GET/POST /api/governance/gauge` | Gauge voting for emissions: ve-weight-proportional votes directing weekly quest budget allocations across collections | `404` disabled | ### 5.3 Signal concurrency contract diff --git a/docs/spikes/688-thumbnail-crop-generation-findings.md b/docs/spikes/688-thumbnail-crop-generation-findings.md new file mode 100644 index 00000000..ebf1dc25 --- /dev/null +++ b/docs/spikes/688-thumbnail-crop-generation-findings.md @@ -0,0 +1,34 @@ +# Spike 688 — AI Thumbnail & Crop Generation with Focal-Point-Aware Variants Findings (Issue #551) + +## What the issue claimed + +The issue asserted that the image pipeline requests fixed 1:1 2K aspect masters only, publisher metadata has only a single image URL slot with no variant fields, IPFS byte-serving proxy ignores transform query params, and browser CSS-crops 2K masters for grid cells burning bandwidth. + +## What the code actually did at spike time + +- `lib/forge/ai-pipeline.ts` and `prompt-builder.ts` indeed generated single high-resolution masters (`2048x2048` or 1:1) with no multi-aspect variations. +- `lib/forge/ipfs-publisher.ts` published metadata with a single top-level `image` field and no pre-computed aspect ratios or focal point metadata. +- `app/api/ipfs/[...cid]/route.ts` served raw content without query parameter processing or content-aware transform hints. +- `components/ipfs-display-img.tsx` loaded the raw master image URI into the `` tag across all viewports and grid cells. + +## The gap that was real + +Client browsers were downloading full 2K master images for small grid cells and preview cards, and cropping naively to standard aspect ratios (e.g., 16:9 banner, 4:5 portrait) often cut off the visual subject or focal point of the artwork. + +## Delivered specification (phase-142) + +1. **Focal crop math (`lib/focal-crop.ts`)**: + - `calculateFocalCrop(src, targetAspect, focal)`: Computes the maximum crop box constrained within source bounds centered on focal point coordinates `(fx, fy)` in `[0.0, 1.0]`. + - `estimateFocalPointFromKeywords(prompt)`: Heuristic keyword-grounded focal detection for prompt tags. + - `STANDARD_IMAGE_VARIANTS`: Standardized dimensions for `thumbnail` (256x256), `preview` (512x512), `og` (1200x630), `banner` (1500x500), `portrait` (800x1000), `landscape` (1280x720). +2. **Metadata enrichment (`lib/forge/ipfs-publisher.ts`)**: + - Generates `properties.variants` mapping variant names to URLs and crop bounding boxes. +3. **Route & Component integration**: + - `app/api/ipfs/[...cid]/route.ts` parses `variant`, `w`, `h`, `fx`, `fy` and sets `X-Phase-Image-Variant` and `X-Phase-Focal-Crop` headers. + - `components/ipfs-display-img.tsx` supports `variant` and `focalPoint` props to request optimized variant paths. + +## Decision & Rollback + +- Enabled behind feature flag `phase-142` (`NEXT_PUBLIC_FEATURE_PHASE_142` / `FEATURE_PHASE_142`). +- Default off; zero regression when flag is disabled. +- Rollback: unset environment variable and restart. diff --git a/docs/spikes/689-dormant-winback-experiments-findings.md b/docs/spikes/689-dormant-winback-experiments-findings.md new file mode 100644 index 00000000..5fc8db1d --- /dev/null +++ b/docs/spikes/689-dormant-winback-experiments-findings.md @@ -0,0 +1,34 @@ +# Spike 689 — AI-Driven Churn & Winback with Dormant Detection Plus Experiments Findings (Issue #552) + +## What the issue claimed + +The issue stated that push notifications lacked dormancy segmentation across fragmented activity (claims, mints, reads, signals, offers), had no personalized winback reason generation grounded in prior user engagement, lacked experiment rigor (holdout and randomized cohorts), and risked fatigue from unmanaged contact frequency. + +## What the code actually did at spike time + +- `lib/notification-store.ts` and `app/api/notifications/route.ts` supported basic transactional and system notifications per wallet without temporal dormancy classification. +- No wallet activity dormancy tiering existed (`active`, `at_risk`, `dormant_mild`, `dormant_deep`, `churned`). +- Push notifications were dispatched without holdout groups or randomized experiment variant allocation. +- No fatigue cap was enforced to prevent repeated notification bursts to dormant accounts. + +## The gap that was real + +Dormant users could either be forgotten entirely or subjected to irrelevant, repetitive notifications that degrade engagement rather than restoring activity. + +## Delivered specification (phase-143) + +1. **Dormancy classification (`lib/churn-winback.ts`)**: + - `classifyWalletDormancy(lastActiveMs, nowMs)`: Classifies into 5 tiers (`active` <7d, `at_risk` 7-14d, `dormant_mild` 14-30d, `dormant_deep` 30-60d, `churned` >60d). +2. **Personalized winback hooks (`determineWinbackHook`)**: + - Identifies the most relevant return motivation: broken quest streak recovery, unfinished quest arcs, new artifact drops in followed worlds, or ecosystem digests. +3. **Experiment cohorts (`assignExperimentCohort`)**: + - Deterministic salt-hashed cohort assignment (`control`, `quest_bonus`, `fee_discount`, `social_digest`). +4. **Fatigue capping (`checkFatigueCap`)**: + - Enforces 7-day cooldown between winback notifications per wallet. +5. **API route (`app/api/notifications/winback/route.ts`)**: + - GET / POST endpoints to evaluate eligibility and safely record campaign dispatches. + +## Decision & Rollback + +- Gated under feature flag `phase-143` (`NEXT_PUBLIC_FEATURE_PHASE_143` / `FEATURE_PHASE_143`). +- Rollback: unset env var and restart. Sidecar history in `winback-experiments.json` remains inert. diff --git a/docs/spikes/690-ve-phaselq-locking-fee-share-findings.md b/docs/spikes/690-ve-phaselq-locking-fee-share-findings.md new file mode 100644 index 00000000..c333de08 --- /dev/null +++ b/docs/spikes/690-ve-phaselq-locking-fee-share-findings.md @@ -0,0 +1,35 @@ +# Spike 690 — ve-PHASELQ Locking with Time-Locked Weight Plus Fee Share Findings (Issue #553) + +## What the issue claimed + +The issue stated that the protocol hardcodes a 95/5 fee split (95% creator, 5% treasury) with no accounting of who earned the treasury share, no lock primitive exists in storage (only UserPhase, TokenOwner, X402Payment, Collection keys), off-chain balances only check liquid balances, and rewards offer no time-weighted governance vesting. + +## What the code actually did at spike time + +- Contract and protocol routes recorded payments with 5% treasury allocation, but treasury proceeds accumulated unsegregated without distribution to long-term token lockers. +- `lib/distributor-ledger.ts` tracked spendable vs reserve balances only for auto-refill solvency, with no governance lock segregation. +- No SQLite schema or service existed to track escrow lock durations, decay curves, or accrued fee checkpoints. + +## The gap that was real + +Without ve-locking mechanisms, short-term speculators held equal influence to long-term participants, and the 5% protocol treasury fee stream lacked a programmatic distribution mechanism to reward aligned ecosystem participants. + +## Delivered specification (phase-144) + +1. **ve-Lock model (`lib/ve-phaselq.ts`)**: + - Time-decaying weight: `weight = amount * (weeks / 208)` (max lock 4 years / 208 weeks). + - Minimum lock: 1 week; maximum lock: 208 weeks. + - Operations: `createOrUpdateVeLock`, `withdrawVeLock`, `readVeLock`. +2. **Fee accumulator & pro-rata distribution**: + - `distributeTreasuryFees(epochId, feeStroops)`: Records total fees arriving in each epoch and computes `fee_per_weight = feeStroops / totalVeWeight`. + - `calculateClaimableFees(wallet)`: Multiplies user's active ve-weight by historical epoch fee-per-weight deltas since `last_claim_epoch`. + - `claimFees(wallet)`: Settles accrued fees and advances checkpoint. +3. **Storage (`lib/sqlite-db.ts`)**: + - `ve_locks` table and `ve_fee_distribution` table with atomic WAL updates. +4. **API route (`app/api/governance/ve-lock/route.ts`)**: + - GET / POST for locking, status queries, fee claims, and distributions. + +## Decision & Rollback + +- Gated behind feature flag `phase-144` (`NEXT_PUBLIC_FEATURE_PHASE_144` / `FEATURE_PHASE_144`). +- Rollback: unset env var and restart. `ve_locks` and `ve_fee_distribution` SQLite rows remain inert. diff --git a/docs/spikes/691-gauge-voting-quest-emissions-findings.md b/docs/spikes/691-gauge-voting-quest-emissions-findings.md new file mode 100644 index 00000000..e22f0281 --- /dev/null +++ b/docs/spikes/691-gauge-voting-quest-emissions-findings.md @@ -0,0 +1,34 @@ +# Spike 691 — Gauge Voting for Emissions with ve-Weight Directing Quest Budgets Findings (Issue #554) + +## What the issue claimed + +The issue noted that quest rewards are statically admin-fixed per quest without community voting, epochs, or per-collection budget apportionment. Faucet emission and distributor refill emit fixed amounts without governance direction, lacking a ve-weighted gauge mechanism to direct weekly quest budgets across collections. + +## What the code actually did at spike time + +- `lib/quest-registry.ts` and `app/api/admin/quests/route.ts` used static reward amounts stored in JSON sidecars. +- Distributor emission thresholds were fixed constants in `app/api/cron/faucet-health/route.ts`. +- There was no voting mechanism or epoch-based emission aggregator for collection quests. + +## The gap that was real + +Collection creators and communities could not signal priority or direct protocol liquidity incentives toward the highest-value quest arcs and narrative worlds. + +## Delivered specification (phase-145) + +1. **Gauge vote mechanism (`lib/gauge-voting.ts`)**: + - Weekly epochs: `epoch = Math.floor(now / (7 * 86400 * 1000))`. + - `castGaugeVote(wallet, votes)`: Voters allocate percentage weights across collections (sum <= 100%). + - Effective vote power = `wallet_ve_weight * (weight_pct / 100)`. +2. **Epoch aggregation & budget distribution**: + - `distributeEpochEmissions(epoch, totalBudgetStroops)`: Apportions total emission pool pro-rata across collections based on total ve-weight on each gauge. + - `getCollectionQuestBudget(collectionId, epoch)`: Returns the calculated budget and gauge weight. +3. **Storage (`lib/sqlite-db.ts`)**: + - `gauge_votes` and `gauge_epoch_aggregates` SQLite tables with indexed queries. +4. **API route (`app/api/governance/gauge/route.ts`)**: + - GET / POST for voting, viewing aggregates, and emission distribution. + +## Decision & Rollback + +- Gated under feature flag `phase-145` (`NEXT_PUBLIC_FEATURE_PHASE_145` / `FEATURE_PHASE_145`). +- Rollback: unset env var and restart. `gauge_votes` rows remain inert on disk. diff --git a/lib/churn-winback.ts b/lib/churn-winback.ts new file mode 100644 index 00000000..1880e4c1 --- /dev/null +++ b/lib/churn-winback.ts @@ -0,0 +1,250 @@ +import { createHash } from "node:crypto"; +import { incSecurityCounter } from "@/lib/security-counters"; +import { readStore, updateJsonFile } from "@/lib/json-store"; +import { serverDataJsonPath } from "@/lib/server-data-paths"; + +export type DormancyTier = + | "active" + | "at_risk" + | "dormant_mild" + | "dormant_deep" + | "churned"; + +export type WinbackHookType = + | "unfinished_quest" + | "streak_recovery" + | "new_world_drop" + | "watchlist_discount" + | "general_digest"; + +export type WinbackExperimentCohort = + | "control" + | "quest_bonus" + | "fee_discount" + | "social_digest"; + +export type WinbackEvaluation = { + wallet: string; + tier: DormancyTier; + daysDormant: number; + eligible: boolean; + cohort: WinbackExperimentCohort; + hook: WinbackHookType; + title: string; + body: string; + fatigueSuppressed: boolean; + cooldownRemainingDays: number; +}; + +export type WinbackHistoryEntry = { + wallet: string; + dispatchedAt: number; + cohort: WinbackExperimentCohort; + hook: WinbackHookType; + experimentId: string; +}; + +export type WinbackStore = { + entries: Record; +}; + +const DAY_MS = 24 * 60 * 60 * 1000; +const DEFAULT_FATIGUE_COOLDOWN_DAYS = 7; + +/** + * Classifies a wallet into a dormancy tier based on time elapsed since last activity. + */ +export function classifyWalletDormancy( + lastActiveMs: number, + nowMs = Date.now(), +): { tier: DormancyTier; daysDormant: number } { + const elapsed = Math.max(0, nowMs - lastActiveMs); + const daysDormant = Math.floor(elapsed / DAY_MS); + + let tier: DormancyTier = "active"; + if (daysDormant >= 60) { + tier = "churned"; + } else if (daysDormant >= 30) { + tier = "dormant_deep"; + } else if (daysDormant >= 14) { + tier = "dormant_mild"; + } else if (daysDormant >= 7) { + tier = "at_risk"; + } + + if (tier !== "active") { + incSecurityCounter("winback_dormant_detected"); + } + + return { tier, daysDormant }; +} + +/** + * Assigns a wallet deterministically to an experiment cohort via salted hash. + */ +export function assignExperimentCohort( + wallet: string, + experimentId = "winback_v1", + cohorts: WinbackExperimentCohort[] = ["control", "quest_bonus", "fee_discount", "social_digest"], +): WinbackExperimentCohort { + const norm = wallet.trim().toLowerCase(); + const hash = createHash("sha256") + .update(`${norm}:${experimentId}`) + .digest("hex"); + const num = parseInt(hash.slice(0, 8), 16); + return cohorts[num % cohorts.length] ?? "control"; +} + +/** + * Verifies fatigue caps (cooldown window) to prevent spamming dormant users. + */ +export function checkFatigueCap( + lastNotifiedMs: number | null | undefined, + nowMs = Date.now(), + cooldownDays = DEFAULT_FATIGUE_COOLDOWN_DAYS, +): { allowed: boolean; waitDaysRemaining: number } { + if (!lastNotifiedMs || lastNotifiedMs <= 0) { + return { allowed: true, waitDaysRemaining: 0 }; + } + + const elapsed = Math.max(0, nowMs - lastNotifiedMs); + const cooldownMs = cooldownDays * DAY_MS; + + if (elapsed >= cooldownMs) { + return { allowed: true, waitDaysRemaining: 0 }; + } + + const remainingMs = cooldownMs - elapsed; + const waitDaysRemaining = Math.ceil(remainingMs / DAY_MS); + return { allowed: false, waitDaysRemaining }; +} + +/** + * Derives a personalized reactivation hook grounded in the user's prior activity. + */ +export function determineWinbackHook( + wallet: string, + activity: { + hasUnfinishedQuest?: boolean; + brokenStreakDays?: number; + favoritedCollections?: number[]; + unreadSignals?: number; + } = {}, +): { hook: WinbackHookType; title: string; body: string } { + if (activity.brokenStreakDays && activity.brokenStreakDays > 0) { + return { + hook: "streak_recovery", + title: "Restore your PHASE streak", + body: `Your ${activity.brokenStreakDays}-day streak is waiting to be rekindled. Return today for bonus rewards.`, + }; + } + + if (activity.hasUnfinishedQuest) { + return { + hook: "unfinished_quest", + title: "Your active quest awaits", + body: "You have uncompleted quest objectives in the Forge. Complete your arc to claim PHASELQ.", + }; + } + + if (activity.favoritedCollections && activity.favoritedCollections.length > 0) { + return { + hook: "new_world_drop", + title: "New artifacts in worlds you follow", + body: "New lore entries and editions have been forged in worlds in your collection.", + }; + } + + return { + hook: "general_digest", + title: "What's new in the PHASE ecosystem", + body: "Explore fresh drops, gauge emission votes, and community signals.", + }; +} + +/** + * Evaluates a wallet for winback campaign eligibility, cohort, and copy. + */ +export function evaluateWinbackCampaign( + wallet: string, + userState: { + lastActiveMs: number; + lastNotifiedMs?: number; + activity?: { + hasUnfinishedQuest?: boolean; + brokenStreakDays?: number; + favoritedCollections?: number[]; + unreadSignals?: number; + }; + }, + experimentId = "winback_v1", + nowMs = Date.now(), +): WinbackEvaluation { + const { tier, daysDormant } = classifyWalletDormancy(userState.lastActiveMs, nowMs); + const fatigue = checkFatigueCap(userState.lastNotifiedMs, nowMs); + const cohort = assignExperimentCohort(wallet, experimentId); + const hookInfo = determineWinbackHook(wallet, userState.activity); + + const isDormant = tier !== "active"; + const eligible = isDormant && fatigue.allowed; + + if (isDormant && !fatigue.allowed) { + incSecurityCounter("winback_fatigue_suppressed"); + } + + if (eligible && cohort !== "control") { + incSecurityCounter("winback_experiment_dispatched"); + } + + return { + wallet, + tier, + daysDormant, + eligible, + cohort, + hook: hookInfo.hook, + title: hookInfo.title, + body: hookInfo.body, + fatigueSuppressed: !fatigue.allowed, + cooldownRemainingDays: fatigue.waitDaysRemaining, + }; +} + +/** + * Reads historical winback dispatches from sidecar store. + */ +export async function getWinbackStore(): Promise { + return readStore("winbackExperiments", () => ({ entries: {} })); +} + +/** + * Records a dispatched winback experiment notification for fatigue management. + */ +export async function recordWinbackDispatch( + wallet: string, + cohort: WinbackExperimentCohort, + hook: WinbackHookType, + experimentId = "winback_v1", + dispatchedAt = Date.now(), +): Promise { + const filePath = serverDataJsonPath("winbackExperiments"); + const entry: WinbackHistoryEntry = { + wallet, + dispatchedAt, + cohort, + hook, + experimentId, + }; + + await updateJsonFile(filePath, { + read: (raw) => { + const s = raw as WinbackStore | undefined; + return { entries: s?.entries ?? {} }; + }, + mutate: (store) => { + store.entries[wallet.toLowerCase()] = entry; + }, + }); + + return entry; +} diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts index cf622d8b..8050f619 100644 --- a/lib/feature-flags.ts +++ b/lib/feature-flags.ts @@ -37,6 +37,10 @@ * - phase-140: royalty enforcement on secondary sales via a creator/seller split * - phase-141: CRDT-collaborative lore drafting (Yjs) for signals, with an * audited compare-and-swap commit path (`PUT /api/signals/[id]`) + * - phase-142: AI thumbnail and crop generation with focal-point-aware variants + * - phase-143: AI-driven churn and winback with dormant detection plus experiments + * - phase-144: ve-PHASELQ locking with time-locked weight plus fee share + * - phase-145: gauge voting for emissions with ve-weight directing quest budgets */ export type PhaseFeatureFlag = @@ -94,7 +98,11 @@ export type PhaseFeatureFlag = | "phase-83" | "phase-139" | "phase-140" - | "phase-141"; + | "phase-141" + | "phase-142" + | "phase-143" + | "phase-144" + | "phase-145"; const FLAG_ENV_MAP: Record = { "phase-66": ["NEXT_PUBLIC_FEATURE_PHASE_66", "FEATURE_PHASE_66"], @@ -151,6 +159,10 @@ const FLAG_ENV_MAP: Record = { "phase-139": ["NEXT_PUBLIC_FEATURE_PHASE_139", "FEATURE_PHASE_139"], "phase-140": ["NEXT_PUBLIC_FEATURE_PHASE_140", "FEATURE_PHASE_140"], "phase-141": ["NEXT_PUBLIC_FEATURE_PHASE_141", "FEATURE_PHASE_141"], + "phase-142": ["NEXT_PUBLIC_FEATURE_PHASE_142", "FEATURE_PHASE_142"], + "phase-143": ["NEXT_PUBLIC_FEATURE_PHASE_143", "FEATURE_PHASE_143"], + "phase-144": ["NEXT_PUBLIC_FEATURE_PHASE_144", "FEATURE_PHASE_144"], + "phase-145": ["NEXT_PUBLIC_FEATURE_PHASE_145", "FEATURE_PHASE_145"], }; function isTruthy(v: string | undefined): boolean { @@ -240,6 +252,10 @@ export function getEnabledFeatureFlags(): PhaseFeatureFlag[] { "phase-139", "phase-140", "phase-141", + "phase-142", + "phase-143", + "phase-144", + "phase-145", ]; // Filtering is evaluated at call time, allowing runtime test overrides. return all.filter(isFeatureEnabled) diff --git a/lib/focal-crop.ts b/lib/focal-crop.ts new file mode 100644 index 00000000..5a552e64 --- /dev/null +++ b/lib/focal-crop.ts @@ -0,0 +1,232 @@ +import { incSecurityCounter } from "@/lib/security-counters"; + +export type FocalPoint = { + x: number; // 0.0 (left) to 1.0 (right) + y: number; // 0.0 (top) to 1.0 (bottom) +}; + +export type ImageDimensions = { + width: number; + height: number; +}; + +export type CropRect = { + x: number; + y: number; + width: number; + height: number; +}; + +export type ImageVariantName = + | "thumbnail" + | "preview" + | "og" + | "banner" + | "portrait" + | "landscape" + | "square"; + +export type ImageVariantSpec = { + name: ImageVariantName; + width: number; + height: number; + aspect: number; +}; + +export const STANDARD_IMAGE_VARIANTS: Record = { + thumbnail: { name: "thumbnail", width: 256, height: 256, aspect: 1.0 }, + preview: { name: "preview", width: 512, height: 512, aspect: 1.0 }, + square: { name: "square", width: 1024, height: 1024, aspect: 1.0 }, + og: { name: "og", width: 1200, height: 630, aspect: 1200 / 630 }, + banner: { name: "banner", width: 1500, height: 500, aspect: 3.0 }, + portrait: { name: "portrait", width: 800, height: 1000, aspect: 0.8 }, + landscape: { name: "landscape", width: 1280, height: 720, aspect: 16 / 9 }, +}; + +function clamp(v: number, min: number, max: number): number { + if (Number.isNaN(v)) return min; + return Math.min(Math.max(v, min), max); +} + +/** + * Normalizes focal point coordinates into [0.0, 1.0] range. + * Defaults to center (0.5, 0.5) if undefined or invalid. + */ +export function normalizeFocalPoint(focal?: Partial | null): FocalPoint { + if (!focal) return { x: 0.5, y: 0.5 }; + const x = typeof focal.x === "number" ? clamp(focal.x, 0.0, 1.0) : 0.5; + const y = typeof focal.y === "number" ? clamp(focal.y, 0.0, 1.0) : 0.5; + return { x, y }; +} + +/** + * Content-grounded heuristic to estimate initial focal point from prompt or description text. + */ +export function estimateFocalPointFromKeywords(text?: string): FocalPoint { + if (!text) return { x: 0.5, y: 0.5 }; + const lower = text.toLowerCase(); + let x = 0.5; + let y = 0.5; + + if (lower.includes("portrait") || lower.includes("face") || lower.includes("avatar") || lower.includes("headshot")) { + y = 0.35; // portraits generally have faces in top-center third + } else if (lower.includes("sky") || lower.includes("celestial") || lower.includes("stars") || lower.includes("clouds")) { + y = 0.3; + } else if (lower.includes("ground") || lower.includes("floor") || lower.includes("terrain") || lower.includes("boots")) { + y = 0.7; + } + + if (lower.includes("left side") || lower.includes("on the left")) { + x = 0.3; + } else if (lower.includes("right side") || lower.includes("on the right")) { + x = 0.7; + } + + incSecurityCounter("image_crop_focal_computed"); + return { x, y }; +} + +/** + * Calculates focal-point-preserving bounding box for a crop matching target aspect ratio. + */ +export function calculateFocalCrop( + src: ImageDimensions, + targetAspect: number, + focal?: Partial | null, +): CropRect { + const fp = normalizeFocalPoint(focal); + const srcW = Math.max(1, Math.round(src.width)); + const srcH = Math.max(1, Math.round(src.height)); + const srcAspect = srcW / srcH; + + let cropW: number; + let cropH: number; + + if (srcAspect > targetAspect) { + // Source is wider than target aspect ratio -> fit height, crop width + cropH = srcH; + cropW = Math.max(1, Math.min(srcW, Math.round(cropH * targetAspect))); + } else { + // Source is taller than target aspect ratio -> fit width, crop height + cropW = srcW; + cropH = Math.max(1, Math.min(srcH, Math.round(cropW / targetAspect))); + } + + // Position crop box centered on focal point coordinates in pixels + const focalPixelX = srcW * fp.x; + const focalPixelY = srcH * fp.y; + + let cropX = Math.round(focalPixelX - cropW / 2); + let cropY = Math.round(focalPixelY - cropH / 2); + + // Constrain crop rectangle within source image boundaries + cropX = clamp(cropX, 0, srcW - cropW); + cropY = clamp(cropY, 0, srcH - cropH); + + incSecurityCounter("image_crop_focal_computed"); + return { x: cropX, y: cropY, width: cropW, height: cropH }; +} + +/** + * Builds responsive variant URL with query transform parameters. + */ +export function buildVariantUrl( + baseUri: string, + variant: ImageVariantName | { width: number; height: number; fit?: string }, + focal?: Partial, +): string { + const fp = normalizeFocalPoint(focal); + const [cleanUri, queryStr] = baseUri.split("?"); + const params = new URLSearchParams(queryStr ?? ""); + + if (typeof variant === "string") { + const spec = STANDARD_IMAGE_VARIANTS[variant] ?? STANDARD_IMAGE_VARIANTS.thumbnail; + params.set("variant", spec.name); + params.set("w", String(spec.width)); + params.set("h", String(spec.height)); + } else { + params.set("w", String(variant.width)); + params.set("h", String(variant.height)); + if (variant.fit) params.set("fit", variant.fit); + } + + params.set("fit", "crop"); + params.set("fx", fp.x.toFixed(2)); + params.set("fy", fp.y.toFixed(2)); + + incSecurityCounter("image_variant_generated"); + return `${cleanUri}?${params.toString()}`; +} + +/** + * Parses image transform query parameters from request query. + */ +export function parseTransformParams( + query: URLSearchParams | Record, +): { + variant?: ImageVariantName; + width?: number; + height?: number; + fit?: string; + focal: FocalPoint; +} { + const getVal = (k: string): string | undefined => { + if (query instanceof URLSearchParams) return query.get(k) ?? undefined; + const v = query[k]; + return Array.isArray(v) ? v[0] : v; + }; + + const variantStr = getVal("variant"); + const variant = (variantStr && variantStr in STANDARD_IMAGE_VARIANTS) + ? (variantStr as ImageVariantName) + : undefined; + + let width = getVal("w") ? parseInt(getVal("w")!, 10) : undefined; + let height = getVal("h") ? parseInt(getVal("h")!, 10) : undefined; + + if (variant && (!width || !height)) { + const spec = STANDARD_IMAGE_VARIANTS[variant]; + width = spec.width; + height = spec.height; + } + + const fx = getVal("fx") ? parseFloat(getVal("fx")!) : 0.5; + const fy = getVal("fy") ? parseFloat(getVal("fy")!) : 0.5; + + return { + variant, + width: width && !Number.isNaN(width) && width > 0 ? width : undefined, + height: height && !Number.isNaN(height) && height > 0 ? height : undefined, + fit: getVal("fit") ?? "crop", + focal: normalizeFocalPoint({ x: fx, y: fy }), + }; +} + +/** + * Generates multi-aspect metadata variants suitable for artifact JSON. + */ +export function generateArtifactVariants( + imageUri: string, + dimensions: Partial = {}, + focal?: Partial, +): Record { + const dims: ImageDimensions = { + width: dimensions.width ?? 2048, + height: dimensions.height ?? 2048, + }; + const fp = normalizeFocalPoint(focal); + const out: Record = {}; + + for (const [key, spec] of Object.entries(STANDARD_IMAGE_VARIANTS)) { + const crop = calculateFocalCrop(dims, spec.aspect, fp); + const variantUri = buildVariantUrl(imageUri, spec.name, fp); + out[key] = { + uri: variantUri, + width: spec.width, + height: spec.height, + crop, + }; + } + + return out; +} diff --git a/lib/forge/ipfs-publisher.ts b/lib/forge/ipfs-publisher.ts index 52d487dc..110acb51 100644 --- a/lib/forge/ipfs-publisher.ts +++ b/lib/forge/ipfs-publisher.ts @@ -7,6 +7,7 @@ export type PublishIpfsStepInput = { imageSource: string payerAddress?: string collectionId?: number + focalPoint?: { x: number; y: number } } export type PublishIpfsStepResult = { @@ -25,6 +26,15 @@ function sha256Hex(s: string): string { * Real IPFS pin path uses /api/ipfs proxy when available. */ export async function publishIpfsStep(input: PublishIpfsStepInput): Promise { + const { isFeatureEnabled } = await import("@/lib/feature-flags") + let variants: Record | undefined + + if (isFeatureEnabled("phase-142")) { + const { generateArtifactVariants, estimateFocalPointFromKeywords } = await import("@/lib/focal-crop") + const fp = input.focalPoint ?? estimateFocalPointFromKeywords(input.prompt) + variants = generateArtifactVariants(input.imageUrl, { width: 2048, height: 2048 }, fp) + } + const metadata = { name: `PHASE Artifact — ${input.prompt.slice(0, 48)}`, description: input.lore, @@ -36,7 +46,11 @@ export async function publishIpfsStep(input: PublishIpfsStepInput): Promise acc + (v.weightPct || 0), 0); + if (totalPct <= 0 || totalPct > 100) { + return { ok: false, error: `Total vote weight must be between 1 and 100% (got ${totalPct}%)` }; + } + + const ve = readVeLock(normWallet, nowMs); + if (!ve || ve.veWeight <= 0) { + return { ok: false, error: "Active ve-PHASELQ lock required to participate in gauge voting" }; + } + + const epoch = getGaugeEpochId(nowMs); + const db = getDb(); + + // Clear any existing votes for this wallet in the current epoch before recording new ones + db.prepare("DELETE FROM gauge_votes WHERE wallet = ? AND epoch = ?").run(normWallet, epoch); + + const records: GaugeVoteRecord[] = []; + + for (const v of votes) { + if (v.weightPct <= 0) continue; + const bps = Math.round(v.weightPct * 100); + const effectiveVe = Math.round((ve.veWeight * (v.weightPct / 100)) * 10_000) / 10_000; + const voteId = `${epoch}:${normWallet}:${v.collectionId}`; + + db.prepare(` + INSERT INTO gauge_votes (id, epoch, wallet, collection_id, weight_bps, effective_ve_weight, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + voteId, + epoch, + normWallet, + v.collectionId, + bps, + effectiveVe, + nowMs, + ); + + records.push({ + id: voteId, + epoch, + wallet: normWallet, + collectionId: v.collectionId, + weightBps: bps, + effectiveVeWeight: effectiveVe, + updatedAt: nowMs, + }); + } + + // Recalculate epoch aggregates for affected collections + recomputeEpochAggregates(epoch); + + incSecurityCounter("gauge_vote_cast"); + return { ok: true, records }; +} + +/** + * Recomputes aggregate ve-weights per collection for a given epoch. + */ +export function recomputeEpochAggregates(epoch = getGaugeEpochId()): void { + const db = getDb(); + const rows = db.prepare(` + SELECT collection_id, SUM(effective_ve_weight) as total_weight + FROM gauge_votes + WHERE epoch = ? + GROUP BY collection_id + `).all(epoch) as Array<{ collection_id: number; total_weight: number }>; + + for (const row of rows) { + db.prepare(` + INSERT INTO gauge_epoch_aggregates (epoch, collection_id, total_ve_weight, allocated_budget_stroops) + VALUES (?, ?, ?, '0') + ON CONFLICT(epoch, collection_id) DO UPDATE SET + total_ve_weight = excluded.total_ve_weight + `).run(epoch, row.collection_id, row.total_weight); + } +} + +/** + * Apportions total weekly emission budget according to final gauge votes. + */ +export function distributeEpochEmissions( + epoch: number, + totalEmissionStroops: bigint, +): GaugeEpochAggregate[] { + recomputeEpochAggregates(epoch); + const db = getDb(); + + const rows = db.prepare(` + SELECT epoch, collection_id, total_ve_weight, allocated_budget_stroops + FROM gauge_epoch_aggregates + WHERE epoch = ? + `).all(epoch) as Array<{ epoch: number; collection_id: number; total_ve_weight: number; allocated_budget_stroops: string }>; + + const sumWeight = rows.reduce((acc, r) => acc + r.total_ve_weight, 0); + const updated: GaugeEpochAggregate[] = []; + + for (const r of rows) { + let budgetStroops = 0n; + if (sumWeight > 0) { + const share = BigInt(Math.floor((r.total_ve_weight / sumWeight) * Number(totalEmissionStroops))); + budgetStroops = share; + } + + db.prepare(` + UPDATE gauge_epoch_aggregates + SET allocated_budget_stroops = ? + WHERE epoch = ? AND collection_id = ? + `).run(budgetStroops.toString(), epoch, r.collection_id); + + updated.push({ + epoch, + collectionId: r.collection_id, + totalVeWeight: r.total_ve_weight, + allocatedBudgetStroops: budgetStroops.toString(), + }); + } + + incSecurityCounter("gauge_epoch_distributed"); + return updated; +} + +/** + * Reads votes cast by a wallet for an epoch. + */ +export function readWalletGaugeVotes(wallet: string, epoch = getGaugeEpochId()): GaugeVoteRecord[] { + const db = getDb(); + const rows = db.prepare(` + SELECT * FROM gauge_votes + WHERE wallet = ? AND epoch = ? + `).all(wallet.toLowerCase(), epoch) as any[]; + + return rows.map((r) => ({ + id: r.id, + epoch: Number(r.epoch), + wallet: r.wallet, + collectionId: Number(r.collection_id), + weightBps: Number(r.weight_bps), + effectiveVeWeight: Number(r.effective_ve_weight), + updatedAt: Number(r.updated_at), + })); +} + +/** + * Reads all collection aggregates for an epoch. + */ +export function readEpochAggregates(epoch = getGaugeEpochId()): GaugeEpochAggregate[] { + recomputeEpochAggregates(epoch); + const db = getDb(); + const rows = db.prepare(` + SELECT * FROM gauge_epoch_aggregates + WHERE epoch = ? + ORDER BY total_ve_weight DESC + `).all(epoch) as any[]; + + return rows.map((r) => ({ + epoch: Number(r.epoch), + collectionId: Number(r.collection_id), + totalVeWeight: Number(r.total_ve_weight), + allocatedBudgetStroops: r.allocated_budget_stroops, + })); +} + +/** + * Reads the allocated quest emission budget for a specific collection. + */ +export function getCollectionQuestBudget( + collectionId: number, + epoch = getGaugeEpochId(), +): { totalBudgetStroops: bigint; veWeight: number } { + const db = getDb(); + const row = db.prepare(` + SELECT total_ve_weight, allocated_budget_stroops + FROM gauge_epoch_aggregates + WHERE epoch = ? AND collection_id = ? + `).get(epoch, collectionId) as any; + + if (!row) { + return { totalBudgetStroops: 0n, veWeight: 0 }; + } + + return { + totalBudgetStroops: BigInt(row.allocated_budget_stroops || "0"), + veWeight: Number(row.total_ve_weight || 0), + }; +} diff --git a/lib/security-counters.ts b/lib/security-counters.ts index 01a2012e..1a8cfe2a 100644 --- a/lib/security-counters.ts +++ b/lib/security-counters.ts @@ -35,6 +35,17 @@ export type SecurityCounterName = | "distributor_available_balance_stale" | "distributor_pending" | "distributor_refill_succeeded" + | "image_variant_generated" + | "image_crop_focal_computed" + | "winback_dormant_detected" + | "winback_experiment_dispatched" + | "winback_fatigue_suppressed" + | "ve_lock_created" + | "ve_lock_extended" + | "ve_fee_distributed" + | "ve_fee_claimed" + | "gauge_vote_cast" + | "gauge_epoch_distributed"; const counters = new Map() diff --git a/lib/server-data-paths.ts b/lib/server-data-paths.ts index 4455c54b..27cc7c80 100644 --- a/lib/server-data-paths.ts +++ b/lib/server-data-paths.ts @@ -38,6 +38,7 @@ const FILES = { x402DeadLetter: "x402-dead-letter.json", generationJobs: "generation-jobs.json", generationDlq: "generation-dlq.json", + winbackExperiments: "winback-experiments.json", } as const export type ServerDataFile = keyof typeof FILES diff --git a/lib/sqlite-db.ts b/lib/sqlite-db.ts index b415e2a2..7d22694b 100644 --- a/lib/sqlite-db.ts +++ b/lib/sqlite-db.ts @@ -187,6 +187,49 @@ CREATE INDEX IF NOT EXISTS idx_royalty_payouts_creator ON royalty_payouts (creator_wallet, created_at DESC); CREATE INDEX IF NOT EXISTS idx_royalty_payouts_listing ON royalty_payouts (listing_id); + +CREATE TABLE IF NOT EXISTS ve_locks ( + wallet TEXT PRIMARY KEY, + amount_stroops TEXT NOT NULL, + lock_start INTEGER NOT NULL, + lock_end INTEGER NOT NULL, + ve_weight REAL NOT NULL, + last_claim_epoch INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_ve_locks_end + ON ve_locks (lock_end); + +CREATE TABLE IF NOT EXISTS ve_fee_distribution ( + epoch_id INTEGER PRIMARY KEY, + total_fees_stroops TEXT NOT NULL, + total_ve_weight REAL NOT NULL, + fee_per_weight REAL NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS gauge_votes ( + id TEXT PRIMARY KEY, + epoch INTEGER NOT NULL, + wallet TEXT NOT NULL, + collection_id INTEGER NOT NULL, + weight_bps INTEGER NOT NULL, + effective_ve_weight REAL NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_gauge_votes_epoch_col + ON gauge_votes (epoch, collection_id); +CREATE INDEX IF NOT EXISTS idx_gauge_votes_wallet_epoch + ON gauge_votes (wallet, epoch); + +CREATE TABLE IF NOT EXISTS gauge_epoch_aggregates ( + epoch INTEGER NOT NULL, + collection_id INTEGER NOT NULL, + total_ve_weight REAL NOT NULL, + allocated_budget_stroops TEXT NOT NULL, + PRIMARY KEY (epoch, collection_id) +); `; // Issue #89 (phase-140): `listings` predates the creator/royalty concept, so diff --git a/lib/ve-phaselq.ts b/lib/ve-phaselq.ts new file mode 100644 index 00000000..b15a77b1 --- /dev/null +++ b/lib/ve-phaselq.ts @@ -0,0 +1,271 @@ +import { getDb } from "@/lib/sqlite-db"; +import { incSecurityCounter } from "@/lib/security-counters"; + +export const MIN_LOCK_WEEKS = 1; +export const MAX_LOCK_WEEKS = 208; // 4 years +export const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +export type VeLockRecord = { + wallet: string; + amountStroops: string; + lockStart: number; + lockEnd: number; + veWeight: number; + lastClaimEpoch: number; + createdAt: number; + updatedAt: number; +}; + +export type VeLockResult = + | { ok: true; lock: VeLockRecord } + | { ok: false; error: string }; + +export type VeFeeClaimResult = + | { ok: true; claimedStroops: string; epochsClaimed: number } + | { ok: false; error: string }; + +/** + * Calculates theoretical ve-weight: + * Linear scale where 4 years (208 weeks) lock yields 100% of principal weight. + */ +export function calculateVeWeight(amountStroops: bigint, lockWeeks: number): number { + if (amountStroops <= 0n || lockWeeks <= 0) return 0; + const clampedWeeks = Math.min(Math.max(lockWeeks, 0), MAX_LOCK_WEEKS); + const principal = Number(amountStroops) / 10_000_000; // in PHASELQ units + const weight = principal * (clampedWeeks / MAX_LOCK_WEEKS); + return Math.round(weight * 10_000) / 10_000; // 4 decimal precision +} + +/** + * Reads the current ve-lock for a wallet, computing decaying veWeight as of nowMs. + */ +export function readVeLock(wallet: string, nowMs = Date.now()): VeLockRecord | null { + const db = getDb(); + const row = db.prepare("SELECT * FROM ve_locks WHERE wallet = ?").get(wallet.toLowerCase()) as any; + if (!row) return null; + + const lockEnd = Number(row.lock_end); + const remainingWeeks = Math.max(0, (lockEnd - nowMs) / WEEK_MS); + const currentWeight = calculateVeWeight(BigInt(row.amount_stroops), remainingWeeks); + + return { + wallet: row.wallet, + amountStroops: row.amount_stroops, + lockStart: Number(row.lock_start), + lockEnd: Number(row.lock_end), + veWeight: currentWeight, + lastClaimEpoch: Number(row.last_claim_epoch ?? 0), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }; +} + +/** + * Creates or increases/extends a ve-PHASELQ lock. + */ +export function createOrUpdateVeLock( + wallet: string, + amountStroops: bigint, + lockWeeks: number, + nowMs = Date.now(), +): VeLockResult { + const normWallet = wallet.trim().toLowerCase(); + if (!normWallet) return { ok: false, error: "Missing wallet address" }; + if (amountStroops <= 0n) return { ok: false, error: "Lock amount must be positive" }; + if (lockWeeks < MIN_LOCK_WEEKS || lockWeeks > MAX_LOCK_WEEKS) { + return { ok: false, error: `Lock duration must be between ${MIN_LOCK_WEEKS} and ${MAX_LOCK_WEEKS} weeks` }; + } + + const db = getDb(); + const existing = readVeLock(normWallet, nowMs); + const lockDurationMs = Math.round(lockWeeks * WEEK_MS); + + if (!existing) { + const lockStart = nowMs; + const lockEnd = nowMs + lockDurationMs; + const veWeight = calculateVeWeight(amountStroops, lockWeeks); + + db.prepare(` + INSERT INTO ve_locks (wallet, amount_stroops, lock_start, lock_end, ve_weight, last_claim_epoch, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 0, ?, ?) + `).run( + normWallet, + amountStroops.toString(), + lockStart, + lockEnd, + veWeight, + nowMs, + nowMs, + ); + + incSecurityCounter("ve_lock_created"); + return { + ok: true, + lock: { + wallet: normWallet, + amountStroops: amountStroops.toString(), + lockStart, + lockEnd, + veWeight, + lastClaimEpoch: 0, + createdAt: nowMs, + updatedAt: nowMs, + }, + }; + } + + // Extend or increase existing lock + const newAmount = BigInt(existing.amountStroops) + amountStroops; + const newLockEnd = Math.max(existing.lockEnd, nowMs + lockDurationMs); + const remainingWeeks = (newLockEnd - nowMs) / WEEK_MS; + const newWeight = calculateVeWeight(newAmount, remainingWeeks); + + db.prepare(` + UPDATE ve_locks + SET amount_stroops = ?, lock_end = ?, ve_weight = ?, updated_at = ? + WHERE wallet = ? + `).run( + newAmount.toString(), + newLockEnd, + newWeight, + nowMs, + normWallet, + ); + + incSecurityCounter("ve_lock_extended"); + return { + ok: true, + lock: { + ...existing, + amountStroops: newAmount.toString(), + lockEnd: newLockEnd, + veWeight: newWeight, + updatedAt: nowMs, + }, + }; +} + +/** + * Distributes protocol treasury fee stream into the ve-fee accumulator for an epoch. + */ +export function distributeTreasuryFees( + epochId: number, + feeStroops: bigint, + nowMs = Date.now(), +): { epochId: number; feePerWeight: number; totalVeWeight: number } { + const db = getDb(); + + // Sum active ve_weights of locks that haven't expired + const locks = db.prepare("SELECT * FROM ve_locks WHERE lock_end > ?").all(nowMs) as any[]; + let totalVeWeight = 0; + for (const l of locks) { + const remainingWeeks = (Number(l.lock_end) - nowMs) / WEEK_MS; + totalVeWeight += calculateVeWeight(BigInt(l.amount_stroops), remainingWeeks); + } + + const feePerWeight = totalVeWeight > 0 ? Number(feeStroops) / totalVeWeight : 0; + + db.prepare(` + INSERT INTO ve_fee_distribution (epoch_id, total_fees_stroops, total_ve_weight, fee_per_weight, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(epoch_id) DO UPDATE SET + total_fees_stroops = excluded.total_fees_stroops, + total_ve_weight = excluded.total_ve_weight, + fee_per_weight = excluded.fee_per_weight + `).run( + epochId, + feeStroops.toString(), + totalVeWeight, + feePerWeight, + nowMs, + ); + + incSecurityCounter("ve_fee_distributed"); + return { epochId, feePerWeight, totalVeWeight }; +} + +/** + * Calculates accrued claimable protocol fees for a ve-locked wallet. + */ +export function calculateClaimableFees( + wallet: string, + nowMs = Date.now(), +): { claimableStroops: bigint; epochsCount: number; lastEpoch: number } { + const lock = readVeLock(wallet, nowMs); + if (!lock || lock.veWeight <= 0) { + return { claimableStroops: 0n, epochsCount: 0, lastEpoch: 0 }; + } + + const db = getDb(); + const epochs = db.prepare(` + SELECT epoch_id, fee_per_weight + FROM ve_fee_distribution + WHERE epoch_id > ? + ORDER BY epoch_id ASC + `).all(lock.lastClaimEpoch) as Array<{ epoch_id: number; fee_per_weight: number }>; + + let totalAccrued = 0; + let maxEpoch = lock.lastClaimEpoch; + + for (const ep of epochs) { + totalAccrued += lock.veWeight * ep.fee_per_weight; + if (ep.epoch_id > maxEpoch) maxEpoch = ep.epoch_id; + } + + return { + claimableStroops: BigInt(Math.floor(totalAccrued)), + epochsCount: epochs.length, + lastEpoch: maxEpoch, + }; +} + +/** + * Claims accrued treasury fees for a ve-locked wallet and advances its last_claim_epoch checkpoint. + */ +export function claimFees(wallet: string, nowMs = Date.now()): VeFeeClaimResult { + const normWallet = wallet.trim().toLowerCase(); + const db = getDb(); + const claimable = calculateClaimableFees(normWallet, nowMs); + + if (claimable.claimableStroops <= 0n) { + return { ok: false, error: "No fees available to claim" }; + } + + db.prepare(` + UPDATE ve_locks + SET last_claim_epoch = ?, updated_at = ? + WHERE wallet = ? + `).run(claimable.lastEpoch, nowMs, normWallet); + + incSecurityCounter("ve_fee_claimed"); + return { + ok: true, + claimedStroops: claimable.claimableStroops.toString(), + epochsClaimed: claimable.epochsCount, + }; +} + +/** + * Withdraws locked tokens if lock has expired. + */ +export function withdrawVeLock( + wallet: string, + nowMs = Date.now(), +): { ok: boolean; releasedStroops?: string; error?: string } { + const normWallet = wallet.trim().toLowerCase(); + const lock = readVeLock(normWallet, nowMs); + if (!lock) return { ok: false, error: "No active lock found" }; + + if (nowMs < lock.lockEnd) { + const daysRemaining = Math.ceil((lock.lockEnd - nowMs) / (24 * 60 * 60 * 1000)); + return { ok: false, error: `Lock is still active (${daysRemaining} days remaining)` }; + } + + // Claim any remaining fees before closing + calculateClaimableFees(normWallet, nowMs); + + const db = getDb(); + db.prepare("DELETE FROM ve_locks WHERE wallet = ?").run(normWallet); + + return { ok: true, releasedStroops: lock.amountStroops }; +} diff --git a/tests/churn-winback.test.ts b/tests/churn-winback.test.ts new file mode 100644 index 00000000..4f0c8227 --- /dev/null +++ b/tests/churn-winback.test.ts @@ -0,0 +1,130 @@ +/** + * Churn detection and winback experiment evaluation (Spike 689 / phase-143) — tests + * Run: npx tsx tests/churn-winback.test.ts + */ +import assert from "node:assert/strict"; +import { + classifyWalletDormancy, + assignExperimentCohort, + checkFatigueCap, + determineWinbackHook, + evaluateWinbackCampaign, +} from "@/lib/churn-winback"; +import { getSecurityCounter, resetSecurityCounters } from "@/lib/security-counters"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function testDormancyClassification() { + resetSecurityCounters(); + const now = 1_700_000_000_000; + + assert.equal(classifyWalletDormancy(now - 2 * DAY_MS, now).tier, "active"); + assert.equal(classifyWalletDormancy(now - 10 * DAY_MS, now).tier, "at_risk"); + assert.equal(classifyWalletDormancy(now - 20 * DAY_MS, now).tier, "dormant_mild"); + assert.equal(classifyWalletDormancy(now - 45 * DAY_MS, now).tier, "dormant_deep"); + assert.equal(classifyWalletDormancy(now - 90 * DAY_MS, now).tier, "churned"); + + assert.ok(getSecurityCounter("winback_dormant_detected") > 0); +} + +function testCohortAssignmentDeterminism() { + const wallet = "GBRPYHIL2CI3WHZKYYXY5UYSZES3IQNB54GQMVWHTFXNAXN3C5GKQCVX"; + const cohort1 = assignExperimentCohort(wallet, "exp_1"); + const cohort2 = assignExperimentCohort(wallet, "exp_1"); + assert.equal(cohort1, cohort2, "Same wallet and experiment must always yield identical cohort"); + + // Different wallets produce distributed cohorts + const wallets = [ + "GA2C5RFPE6GCKMY3US5PAB6UZLKIGAHWKXX2GAKJWOF2M6JBQ2DGBA58", + "GBRPYHIL2CI3WHZKYYXY5UYSZES3IQNB54GQMVWHTFXNAXN3C5GKQCVX", + "GCGNZ25W6V43Q54DRL6K6J4777Q3KTY4J3H7N265K73Z6FNAU7G65565", + "GDTKGYR44QOEXUSQUSQWJ2YFXP2H27J3A73H57D7A5D2W4D4H2W6E6K6", + ]; + const assignments = wallets.map((w) => assignExperimentCohort(w, "exp_1")); + assert.ok(assignments.length === 4); +} + +function testFatigueCapEnforcement() { + const now = 1_700_000_000_000; + + // Never contacted -> allowed + assert.equal(checkFatigueCap(null, now).allowed, true); + + // Contacted 3 days ago -> suppressed (4 days remaining) + const recent = checkFatigueCap(now - 3 * DAY_MS, now, 7); + assert.equal(recent.allowed, false); + assert.equal(recent.waitDaysRemaining, 4); + + // Contacted 8 days ago -> allowed + const expired = checkFatigueCap(now - 8 * DAY_MS, now, 7); + assert.equal(expired.allowed, true); + assert.equal(expired.waitDaysRemaining, 0); +} + +function testWinbackHookPersonalization() { + const streakUser = determineWinbackHook("GAAA", { brokenStreakDays: 5 }); + assert.equal(streakUser.hook, "streak_recovery"); + + const questUser = determineWinbackHook("GBBB", { hasUnfinishedQuest: true }); + assert.equal(questUser.hook, "unfinished_quest"); + + const worldUser = determineWinbackHook("GCCC", { favoritedCollections: [1, 2] }); + assert.equal(worldUser.hook, "new_world_drop"); + + const genericUser = determineWinbackHook("GDDD", {}); + assert.equal(genericUser.hook, "general_digest"); +} + +function testFullCampaignEvaluation() { + resetSecurityCounters(); + const now = 1_700_000_000_000; + const wallet = "GBRPYHIL2CI3WHZKYYXY5UYSZES3IQNB54GQMVWHTFXNAXN3C5GKQCVX"; + + // Active user -> not eligible + const activeEval = evaluateWinbackCampaign( + wallet, + { lastActiveMs: now - 3 * DAY_MS }, + "exp_1", + now, + ); + assert.equal(activeEval.eligible, false); + + // Dormant user under fatigue cooldown -> fatigueSuppressed + const fatiguedEval = evaluateWinbackCampaign( + wallet, + { lastActiveMs: now - 25 * DAY_MS, lastNotifiedMs: now - 2 * DAY_MS }, + "exp_1", + now, + ); + assert.equal(fatiguedEval.eligible, false); + assert.equal(fatiguedEval.fatigueSuppressed, true); + assert.ok(getSecurityCounter("winback_fatigue_suppressed") > 0); + + // Dormant eligible user + const eligibleEval = evaluateWinbackCampaign( + wallet, + { lastActiveMs: now - 25 * DAY_MS, lastNotifiedMs: now - 15 * DAY_MS }, + "exp_1", + now, + ); + assert.equal(eligibleEval.eligible, true); +} + +async function main() { + testDormancyClassification(); + console.log("✓ classifyWalletDormancy assigns correct tiers"); + testCohortAssignmentDeterminism(); + console.log("✓ assignExperimentCohort is deterministic per wallet"); + testFatigueCapEnforcement(); + console.log("✓ checkFatigueCap prevents re-contacting within 7 days"); + testWinbackHookPersonalization(); + console.log("✓ determineWinbackHook selects activity-grounded hook"); + testFullCampaignEvaluation(); + console.log("✓ evaluateWinbackCampaign orchestrates eligibility correctly"); + console.log("\nAll churn-winback tests passed."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/focal-crop.test.ts b/tests/focal-crop.test.ts new file mode 100644 index 00000000..f5961ff4 --- /dev/null +++ b/tests/focal-crop.test.ts @@ -0,0 +1,121 @@ +/** + * Focal crop and responsive image variant generator (Spike 688 / phase-142) — tests + * Run: npx tsx tests/focal-crop.test.ts + */ +import assert from "node:assert/strict"; +import { + calculateFocalCrop, + normalizeFocalPoint, + estimateFocalPointFromKeywords, + buildVariantUrl, + parseTransformParams, + generateArtifactVariants, + STANDARD_IMAGE_VARIANTS, +} from "@/lib/focal-crop"; +import { getSecurityCounter, resetSecurityCounters } from "@/lib/security-counters"; + +function testNormalization() { + assert.deepEqual(normalizeFocalPoint(null), { x: 0.5, y: 0.5 }); + assert.deepEqual(normalizeFocalPoint({ x: 0.2, y: 0.8 }), { x: 0.2, y: 0.8 }); + assert.deepEqual(normalizeFocalPoint({ x: -1, y: 2 }), { x: 0, y: 1 }); +} + +function testKeywordEstimation() { + resetSecurityCounters(); + const portrait = estimateFocalPointFromKeywords("A striking portrait of a digital cybernetic warrior"); + assert.equal(portrait.y, 0.35, "Portrait should bias focal point upwards toward face"); + + const landscape = estimateFocalPointFromKeywords("Stellar night sky with nebulae on the left side"); + assert.equal(landscape.x, 0.3); + assert.equal(landscape.y, 0.3); + + assert.ok(getSecurityCounter("image_crop_focal_computed") > 0); +} + +function testSquareCropFromLandscape() { + const src = { width: 2000, height: 1000 }; + const targetAspect = 1.0; // square + + // Center focal point (0.5, 0.5) + const centerCrop = calculateFocalCrop(src, targetAspect, { x: 0.5, y: 0.5 }); + assert.equal(centerCrop.height, 1000); + assert.equal(centerCrop.width, 1000); + assert.equal(centerCrop.x, 500); // centered in 2000 width + assert.equal(centerCrop.y, 0); + + // Left focal point (0.1, 0.5) + const leftCrop = calculateFocalCrop(src, targetAspect, { x: 0.1, y: 0.5 }); + assert.equal(leftCrop.width, 1000); + assert.equal(leftCrop.x, 0, "Crop window should clamp to left boundary"); + + // Right focal point (0.9, 0.5) + const rightCrop = calculateFocalCrop(src, targetAspect, { x: 0.9, y: 0.5 }); + assert.equal(rightCrop.width, 1000); + assert.equal(rightCrop.x, 1000, "Crop window should clamp to right boundary (2000 - 1000)"); +} + +function testBannerCropFromSquare() { + const src = { width: 1000, height: 1000 }; + const targetAspect = 2.0; // 2:1 landscape + + // Top focal point + const topCrop = calculateFocalCrop(src, targetAspect, { x: 0.5, y: 0.1 }); + assert.equal(topCrop.width, 1000); + assert.equal(topCrop.height, 500); + assert.equal(topCrop.y, 0, "Top crop should clamp to top boundary"); + + // Bottom focal point + const bottomCrop = calculateFocalCrop(src, targetAspect, { x: 0.5, y: 0.9 }); + assert.equal(bottomCrop.width, 1000); + assert.equal(bottomCrop.height, 500); + assert.equal(bottomCrop.y, 500, "Bottom crop should clamp to bottom boundary"); +} + +function testUrlBuildingAndParsing() { + const url = buildVariantUrl("ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", "thumbnail", { x: 0.3, y: 0.7 }); + assert.ok(url.includes("variant=thumbnail")); + assert.ok(url.includes("fx=0.30")); + assert.ok(url.includes("fy=0.70")); + + const parsed = parseTransformParams(new URLSearchParams(url.split("?")[1])); + assert.equal(parsed.variant, "thumbnail"); + assert.equal(parsed.width, 256); + assert.equal(parsed.height, 256); + assert.equal(parsed.focal.x, 0.3); + assert.equal(parsed.focal.y, 0.7); +} + +function testGenerateArtifactVariants() { + const baseUri = "ipfs://QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco"; + const variants = generateArtifactVariants(baseUri, { width: 2048, height: 2048 }, { x: 0.4, y: 0.6 }); + + assert.ok(variants.thumbnail); + assert.equal(variants.thumbnail.width, STANDARD_IMAGE_VARIANTS.thumbnail.width); + assert.equal(variants.thumbnail.height, STANDARD_IMAGE_VARIANTS.thumbnail.height); + assert.ok(variants.og); + assert.equal(variants.og.width, 1200); + assert.equal(variants.og.height, 630); + assert.ok(variants.og.crop.width <= 2048); + assert.ok(variants.og.crop.height <= 2048); +} + +async function main() { + testNormalization(); + console.log("✓ normalizeFocalPoint clamps within bounds"); + testKeywordEstimation(); + console.log("✓ estimateFocalPointFromKeywords identifies subject cues"); + testSquareCropFromLandscape(); + console.log("✓ calculateFocalCrop centers and clamps landscape to square"); + testBannerCropFromSquare(); + console.log("✓ calculateFocalCrop centers and clamps square to banner"); + testUrlBuildingAndParsing(); + console.log("✓ buildVariantUrl and parseTransformParams preserve parameters"); + testGenerateArtifactVariants(); + console.log("✓ generateArtifactVariants produces complete multi-aspect set"); + console.log("\nAll focal-crop tests passed."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/gauge-voting.test.ts b/tests/gauge-voting.test.ts new file mode 100644 index 00000000..1ac0b9c3 --- /dev/null +++ b/tests/gauge-voting.test.ts @@ -0,0 +1,148 @@ +/** + * Gauge voting for emission budget allocation (Spike 691 / phase-145) — tests + * Run: npx tsx tests/gauge-voting.test.ts + */ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const scratch = mkdtempSync(path.join(tmpdir(), "phase-gauge-test-")); +process.env.PHASE_SERVER_DATA_DIR = scratch; + +import { resetDbForTests } from "@/lib/sqlite-db"; +import { createOrUpdateVeLock } from "@/lib/ve-phaselq"; +import { + castGaugeVote, + readWalletGaugeVotes, + readEpochAggregates, + distributeEpochEmissions, + getCollectionQuestBudget, + getGaugeEpochId, +} from "@/lib/gauge-voting"; +import { getSecurityCounter, resetSecurityCounters } from "@/lib/security-counters"; + +const ALICE = "GALICE6GCKMY3US5PAB6UZLKIGAHWKXX2GAKJWOF2M6JBQ2DGBA58"; +const BOB = "GBOBHIL2CI3WHZKYYXY5UYSZES3IQNB54GQMVWHTFXNAXN3C5GKQCVX"; + +function testVoteWithoutVeWeightIsRejected() { + resetDbForTests(); + const res = castGaugeVote(ALICE, [{ collectionId: 1, weightPct: 50 }]); + assert.equal(res.ok, false); + assert.ok(res.error?.includes("ve-PHASELQ lock required")); +} + +function testVoteExceeding100PercentIsRejected() { + resetDbForTests(); + const now = 1_700_000_000_000; + createOrUpdateVeLock(ALICE, 100_000_000n, 104, now); // 5.0 veWeight + + const res = castGaugeVote( + ALICE, + [ + { collectionId: 1, weightPct: 70 }, + { collectionId: 2, weightPct: 40 }, + ], + now, + ); + assert.equal(res.ok, false); + assert.ok(res.error?.includes("must be between 1 and 100%")); +} + +function testVotingAndEmissionDistribution() { + resetDbForTests(); + resetSecurityCounters(); + const now = 1_700_000_000_000; + const epoch = getGaugeEpochId(now); + + // Alice has 10.0 veWeight (locks 10 PHASELQ for 208 weeks) + createOrUpdateVeLock(ALICE, 100_000_000n, 208, now); + // Bob has 5.0 veWeight (locks 10 PHASELQ for 104 weeks) + createOrUpdateVeLock(BOB, 100_000_000n, 104, now); + + // Alice votes: 60% to Collection 1, 40% to Collection 2 + const vAlice = castGaugeVote( + ALICE, + [ + { collectionId: 1, weightPct: 60 }, + { collectionId: 2, weightPct: 40 }, + ], + now, + ); + assert.equal(vAlice.ok, true); + // Alice gives 6.0 veWeight to Col 1, 4.0 veWeight to Col 2 + + // Bob votes: 100% to Collection 2 + const vBob = castGaugeVote( + BOB, + [{ collectionId: 2, weightPct: 100 }], + now, + ); + assert.equal(vBob.ok, true); + // Bob gives 5.0 veWeight to Col 2 + + assert.ok(getSecurityCounter("gauge_vote_cast") > 0); + + // Check aggregates: + // Col 1: 6.0 veWeight + // Col 2: 4.0 + 5.0 = 9.0 veWeight + // Total: 15.0 veWeight + const aggs = readEpochAggregates(epoch); + assert.equal(aggs.length, 2); + + const col1Agg = aggs.find((a) => a.collectionId === 1); + const col2Agg = aggs.find((a) => a.collectionId === 2); + assert.equal(col1Agg?.totalVeWeight, 6.0); + assert.equal(col2Agg?.totalVeWeight, 9.0); + + // Distribute 150_000_000 stroops (15 PHASELQ) weekly quest budget + const distributed = distributeEpochEmissions(epoch, 150_000_000n); + assert.equal(distributed.length, 2); + + // Col 1 gets (6.0 / 15.0) * 150M = 60M stroops + const b1 = getCollectionQuestBudget(1, epoch); + assert.equal(b1.totalBudgetStroops, 60_000_000n); + + // Col 2 gets (9.0 / 15.0) * 150M = 90M stroops + const b2 = getCollectionQuestBudget(2, epoch); + assert.equal(b2.totalBudgetStroops, 90_000_000n); + + assert.ok(getSecurityCounter("gauge_epoch_distributed") > 0); +} + +function testVoteReplacementInSameEpoch() { + resetDbForTests(); + const now = 1_700_000_000_000; + const epoch = getGaugeEpochId(now); + createOrUpdateVeLock(ALICE, 100_000_000n, 208, now); // 10.0 veWeight + + // Vote initially + castGaugeVote(ALICE, [{ collectionId: 1, weightPct: 100 }], now); + assert.equal(readWalletGaugeVotes(ALICE, epoch).length, 1); + + // Change vote + castGaugeVote(ALICE, [{ collectionId: 2, weightPct: 100 }], now); + const updated = readWalletGaugeVotes(ALICE, epoch); + assert.equal(updated.length, 1); + assert.equal(updated[0].collectionId, 2); +} + +async function main() { + testVoteWithoutVeWeightIsRejected(); + console.log("✓ castGaugeVote rejects votes without active ve-weight"); + testVoteExceeding100PercentIsRejected(); + console.log("✓ castGaugeVote validates vote percentage totals <= 100%"); + testVotingAndEmissionDistribution(); + console.log("✓ castGaugeVote and distributeEpochEmissions apportion budget pro-rata"); + testVoteReplacementInSameEpoch(); + console.log("✓ casting new votes in the same epoch atomically replaces previous ballot"); + + resetDbForTests(); + rmSync(scratch, { recursive: true, force: true }); + console.log("\nAll gauge-voting tests passed."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/ve-phaselq-lock.test.ts b/tests/ve-phaselq-lock.test.ts new file mode 100644 index 00000000..e957f2e6 --- /dev/null +++ b/tests/ve-phaselq-lock.test.ts @@ -0,0 +1,142 @@ +/** + * ve-PHASELQ governance locking and fee-share (Spike 690 / phase-144) — tests + * Run: npx tsx tests/ve-phaselq-lock.test.ts + */ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const scratch = mkdtempSync(path.join(tmpdir(), "phase-ve-lock-")); +process.env.PHASE_SERVER_DATA_DIR = scratch; + +import { resetDbForTests } from "@/lib/sqlite-db"; +import { + calculateVeWeight, + createOrUpdateVeLock, + readVeLock, + distributeTreasuryFees, + calculateClaimableFees, + claimFees, + withdrawVeLock, + WEEK_MS, + MAX_LOCK_WEEKS, +} from "@/lib/ve-phaselq"; +import { getSecurityCounter, resetSecurityCounters } from "@/lib/security-counters"; + +const WALLET = "GBRPYHIL2CI3WHZKYYXY5UYSZES3IQNB54GQMVWHTFXNAXN3C5GKQCVX"; + +function testWeightCalculation() { + const onePhaselq = 10_000_000n; // 1 PHASELQ in stroops + + // 4 years (208 weeks) lock yields 100% of principal ve-weight (1.0) + assert.equal(calculateVeWeight(onePhaselq, MAX_LOCK_WEEKS), 1.0); + + // 1 year (52 weeks) lock yields 25% of principal (0.25) + assert.equal(calculateVeWeight(onePhaselq, 52), 0.25); + + // 2 years (104 weeks) lock yields 50% of principal (0.50) + assert.equal(calculateVeWeight(onePhaselq, 104), 0.5); + + // 0 weeks yields 0 + assert.equal(calculateVeWeight(onePhaselq, 0), 0); +} + +function testLockCreationAndDecay() { + resetSecurityCounters(); + resetDbForTests(); + const now = 1_700_000_000_000; + const amount = 100_000_000n; // 10 PHASELQ + + const res = createOrUpdateVeLock(WALLET, amount, 104, now); // 2 years = 50% weight = 5.0 ve-weight + assert.equal(res.ok, true); + if (!res.ok) return; + + assert.equal(res.lock.veWeight, 5.0); + assert.ok(getSecurityCounter("ve_lock_created") > 0); + + // Read at lock creation time + const readNow = readVeLock(WALLET, now); + assert.ok(readNow); + assert.equal(readNow?.veWeight, 5.0); + + // Read 1 year later (halfway through the 2-year lock): remaining duration is 1 year (52 weeks) + const oneYearLater = now + 52 * WEEK_MS; + const readLater = readVeLock(WALLET, oneYearLater); + assert.ok(readLater); + assert.equal(readLater?.veWeight, 2.5, "ve-weight must linearly decay over time"); +} + +function testFeeDistributionAndClaim() { + resetDbForTests(); + resetSecurityCounters(); + const now = 1_700_000_000_000; + + // Alice locks 10 PHASELQ for 208 weeks -> veWeight = 10.0 + const alice = "GALICE6GCKMY3US5PAB6UZLKIGAHWKXX2GAKJWOF2M6JBQ2DGBA58"; + createOrUpdateVeLock(alice, 100_000_000n, 208, now); + + // Bob locks 10 PHASELQ for 52 weeks -> veWeight = 2.5 + const bob = "GBOBHIL2CI3WHZKYYXY5UYSZES3IQNB54GQMVWHTFXNAXN3C5GKQCVX"; + createOrUpdateVeLock(bob, 100_000_000n, 52, now); + + // Total ve-weight = 12.5. Protocol distributes 125_000_000 stroops (12.5 PHASELQ) in epoch 1 + const dist = distributeTreasuryFees(1, 125_000_000n, now); + assert.equal(dist.totalVeWeight, 12.5); + assert.equal(dist.feePerWeight, 10_000_000); // 10M stroops per 1.0 veWeight + + // Alice (10.0 veWeight) should be eligible for 10.0 * 10M = 100M stroops + const aliceClaimable = calculateClaimableFees(alice, now); + assert.equal(aliceClaimable.claimableStroops, 100_000_000n); + + // Bob (2.5 veWeight) should be eligible for 2.5 * 10M = 25M stroops + const bobClaimable = calculateClaimableFees(bob, now); + assert.equal(bobClaimable.claimableStroops, 25_000_000n); + + // Alice claims fees + const aliceClaim = claimFees(alice, now); + assert.equal(aliceClaim.ok, true); + assert.equal(aliceClaim.ok && aliceClaim.claimedStroops, "100000000"); + + // Alice claims again -> should be 0 (checkpoint advanced) + const aliceClaimAgain = claimFees(alice, now); + assert.equal(aliceClaimAgain.ok, false); +} + +function testWithdrawalEnforcesExpiry() { + resetDbForTests(); + const now = 1_700_000_000_000; + createOrUpdateVeLock(WALLET, 50_000_000n, 10, now); // 10 weeks + + // Cannot withdraw before expiry + const early = withdrawVeLock(WALLET, now + 5 * WEEK_MS); + assert.equal(early.ok, false); + + // Can withdraw after expiry + const late = withdrawVeLock(WALLET, now + 11 * WEEK_MS); + assert.equal(late.ok, true); + assert.equal(late.releasedStroops, "50000000"); + + // Lock row deleted + assert.equal(readVeLock(WALLET), null); +} + +async function main() { + testWeightCalculation(); + console.log("✓ calculateVeWeight produces linear duration-scaled weights"); + testLockCreationAndDecay(); + console.log("✓ createOrUpdateVeLock stores lock and decays with time"); + testFeeDistributionAndClaim(); + console.log("✓ distributeTreasuryFees splits pro-rata across lockers"); + testWithdrawalEnforcesExpiry(); + console.log("✓ withdrawVeLock blocks premature unlock, succeeds post-expiry"); + + resetDbForTests(); + rmSync(scratch, { recursive: true, force: true }); + console.log("\nAll ve-phaselq-lock tests passed."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});