diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..ae0e6011 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,69 @@ +# Spike 608 (#472, phase-175): Automated dependency-update cadence with +# grouped Soroban (Cargo) and Stellar / Next.js (npm) pull requests. +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "03:00" + open-pull-requests-limit: 5 + groups: + stellar-sdk-root: + patterns: + - "@stellar/*" + - "@creit.tech/stellar-wallets-kit" + - "x402-stellar" + nextjs-react-bonded: + patterns: + - "next" + - "react" + - "react-dom" + - "@types/react" + - "@types/react-dom" + vitest-bonded: + patterns: + - "vitest" + - "@vitest/coverage-v8" + + - package-ecosystem: "npm" + directory: "/scripts" + schedule: + interval: "weekly" + day: "monday" + time: "03:00" + open-pull-requests-limit: 3 + groups: + stellar-sdk-scripts: + patterns: + - "@stellar/*" + + - package-ecosystem: "cargo" + directory: "/contracts/phase-protocol" + schedule: + interval: "weekly" + day: "monday" + time: "03:30" + groups: + soroban-sdk-phase-protocol: + patterns: + - "soroban-*" + + - package-ecosystem: "cargo" + directory: "/contracts/mock-token" + schedule: + interval: "weekly" + day: "monday" + time: "03:30" + groups: + soroban-sdk-mock-token: + patterns: + - "soroban-*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "04:00" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a93cd845..db12ac0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,8 @@ jobs: cache: npm - name: Install locked dependencies run: npm ci + - name: Verify dependency cadence and Soroban/Stellar parity + run: npm run deps:cadence - name: Lint # eslint had no config/devDependency before this change (npm run lint # failed outright); now that it runs, ~95 pre-existing errors surface @@ -81,7 +83,7 @@ jobs: contracts/mock-token contracts/phase-protocol - name: Install Stellar CLI - run: cargo install --locked stellar-cli --version ^23 + run: cargo install --locked stellar-cli --version 23.0.0 - name: Setup Node.js uses: actions/setup-node@v4 with: diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml index 82cd1f73..75471f2e 100644 --- a/.github/workflows/dependency-audit.yml +++ b/.github/workflows/dependency-audit.yml @@ -5,6 +5,11 @@ on: paths: - "package.json" - "package-lock.json" + - "scripts/package.json" + - "scripts/package-lock.json" + - "contracts/**/Cargo.toml" + - "contracts/**/Cargo.lock" + - ".github/dependabot.yml" - ".github/workflows/dependency-audit.yml" push: branches: @@ -12,6 +17,11 @@ on: paths: - "package.json" - "package-lock.json" + - "scripts/package.json" + - "scripts/package-lock.json" + - "contracts/**/Cargo.toml" + - "contracts/**/Cargo.lock" + - ".github/dependabot.yml" - ".github/workflows/dependency-audit.yml" schedule: - cron: "17 3 * * 1" @@ -31,5 +41,7 @@ jobs: cache: npm - name: Install locked dependencies run: npm ci + - name: Verify grouped Soroban and Stellar dependency parity + run: npm run deps:cadence - name: Scan dependencies for high severity vulnerabilities run: npm run security:audit \ No newline at end of file diff --git a/.github/workflows/secret-rotation.yml b/.github/workflows/secret-rotation.yml index c012b5a8..764c3349 100644 --- a/.github/workflows/secret-rotation.yml +++ b/.github/workflows/secret-rotation.yml @@ -19,6 +19,8 @@ jobs: cache: npm - name: Install locked dependencies run: npm ci + - name: Verify dependency cadence alignment + run: npm run deps:cadence - name: Check rotation metadata run: npm run security:secret-rotation env: diff --git a/.npmrc b/.npmrc index 2c1b2884..7c6d2804 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,4 @@ -# x402-stellar peers @stellar/stellar-sdk@^12; this app uses ^15. Peers align at runtime; strict install would fail (e.g. Vercel). +# x402-stellar peers @stellar/stellar-sdk@^12; root and scripts/ both align on @stellar/stellar-sdk@^15.0.1 (Spike 608 / phase-175). legacy-peer-deps=true +engine-strict=false + diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index 4ae4090d..9cf666fd 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -212,14 +212,14 @@ is a fixed-size digest of the canonical payload, keeping it under wallet | `phase-167` | `NEXT_PUBLIC_FEATURE_PHASE_167` / `FEATURE_PHASE_167` | WalletConnect v2 mobile session resilience (`lib/walletconnect-resilience.ts`): configurable relay URL, multi-chain support, session persistence, chain-switch guard, and mobile deep-link return. Spike 623 (issue #486) | off | Unset var, restart — WalletConnect banner and reconnect actions disabled, kit falls back to default single-session behavior | | `phase-168` | `NEXT_PUBLIC_FEATURE_PHASE_168` / `FEATURE_PHASE_168` | xBull wallet full support and parity (`lib/xbull-adapter.ts`): Freighter-difference matrix, xBull module detection, sign result normalization, trustline error translation, and badge surfacing. Spike 624 (issue #487) | off | Unset var, restart — xBull transactions fall through generic kit handlers, badge hidden | | `phase-169` | `NEXT_PUBLIC_FEATURE_PHASE_169` / `FEATURE_PHASE_169` | Ledger blind-signing to clear-signing review for Soroban (`lib/ledger-soroban-decoder.ts`): invokeHostFunction pre-image decode, verification code, on-device review modal, Gate #13 preemptive validation, and hardware error humanizer. Spike 625 (issue #488) | off | Unset var, restart — Ledger transactions fall back to default blind-signing flow without preflight review modal | -| `phase-170` | `NEXT_PUBLIC_FEATURE_PHASE_170` / `FEATURE_PHASE_170` | Structured log pipeline (`lib/log-pipeline.ts`): deep secret redaction, deterministic per-correlation-id info sampling (warn/error always kept), closed index-label set (`level`, `event`, `route`, `method`, `status_class`), and a bounded non-blocking HTTPS batcher (`LOG_SINK_URL`, https only). `api-observability` routes every line through it. Spike 606 (issue #470) — partial slice: no `waitUntil` flush on freeze, no counter export, ad-hoc `console.*` callers not migrated | off | Unset var, restart — `emitLog` is the identity and console lines are written exactly as before; in-memory buffer dropped | -| `phase-171` | `NEXT_PUBLIC_FEATURE_PHASE_171` / `FEATURE_PHASE_171` | Backup and DR snapshots (`lib/backup-snapshot.ts`): lock-consistent sidecar set + `VACUUM INTO` SQLite image, sha256/row-count manifest, `integrity_check`/`foreign_key_check` verify, tmp+rename restore with a restore marker that makes `/api/cron/faucet-health` return 503, and an AES-256-GCM sealed secrets envelope. `scripts/backup-drill.ts` runs snapshot→verify→restore→parity. Spike 605 (issue #469) — partial slice: no off-box shipping or scheduled snapshots | off | Unset var, restart — entry points throw `FLAG_DISABLED`, restore marker ignored; snapshots on disk are inert | -| `phase-172` | `NEXT_PUBLIC_FEATURE_PHASE_172` / `FEATURE_PHASE_172` | SQLite→Postgres rehearsal (`lib/db-parity.ts`): dialect helpers (`?`→`$n`, type map, explicit upsert), typed per-table parity (value, `type_coercion`, checksum), per-test schema names, and a `DualWriter` (primary first, bounded/timeout-guarded secondary, drift log, `primary-only` rollback). Needs `PHASE_DATABASE_URL` to enter `dual` mode. Spike 604 (issue #468) — partial slice: no Postgres driver, async CAS re-plumbing or pool storm test | off | Unset var, restart — `resolveDualWriteMode` returns `primary-only`; SQLite stays authoritative, nothing in production paths calls the module | -| `phase-173` | `NEXT_PUBLIC_FEATURE_PHASE_173` / `FEATURE_PHASE_173` | Vercel preview isolation (`lib/preview-isolation.ts`): per-preview data root (`/previews/`), `validatePhaseEnv` fails a preview that shares the production issuer, cron secret, contract IDs, or runs mainnet (declared via `PHASE_PROD_ISSUER`, `PHASE_PROD_CRON_SECRET_SHA256`, `PHASE_PROD_CONTRACT_IDS`), preview cron skipped unless `PHASE_PREVIEW_CRON_ENABLED=1`, and `x-vercel-cron` no longer trusted in previews. Spike 603 (issue #467) — partial slice: no per-PR contract deploy, preview CI job, or branch-DB seeding | off | Unset var, restart — data root, cron auth and env validation revert; per-preview directories stay inert | -| `phase-178` | `NEXT_PUBLIC_FEATURE_PHASE_178` / `FEATURE_PHASE_178` | Leaderboard integrity (`lib/leaderboard-integrity.ts`): multiplicative scoring `decayed × farmPenalty × sybilDiscount` — exponential half-life decay (7d), anti-farm rules (`event_burst`, `counterparty_concentration`, `point_velocity`) floored at 0.15, and sybil band discounts (trusted 1 / caution 0.7 / suspect 0.35, missing band = caution). `GET /api/leaderboard` ranks; admin `POST /api/leaderboard` scores before persisting to `leaderboard-entries.json`. Spike 616 (issue #479) — partial slice: no on-chain sybil fetch in-route; callers supply the band | off | Unset var, restart — routes 404, `leaderboard-entries.json` stays inert | -| `phase-179` | `NEXT_PUBLIC_FEATURE_PHASE_179` / `FEATURE_PHASE_179` | Retention and GDPR erasure (`lib/retention-erasure.ts`): TTL sweeps (expired SEP-10 sessions, consumed challenges, 90-day notifications — fail-safe on missing timestamps) via `GET /api/cron/retention-sweep` (daily 03:15, preview + restore fenced); `POST /api/user/erasure` deletes wallet-owned records across five sidecar domains behind `assertNoLegalHold` (423 on hold) and appends fingerprint-only receipts to `gdpr-erasure-log.json`. Spike 617 (issue #480) — partial slice: SQLite tables not walked; consent ledger deliberately retained per Art. 17(3)(b) | off | Unset var, restart — routes 404, receipts stay inert; nothing is deleted while off | -| `phase-180` | `NEXT_PUBLIC_FEATURE_PHASE_180` / `FEATURE_PHASE_180` | Analytics warehouse export (`lib/analytics-warehouse.ts`): flat row model (`counter`/`health`/`volume` tables) with an unconditional redaction pass (wallets fingerprinted, secrets stripped), schema-versioned manifest (`phase-warehouse-export` 1.0.0, rows-only sha256 checksum) emitted as NDJSON header line or Parquet `key_value_metadata` (`hyparquet-writer`). Admin-keyed `GET /api/analytics/export?format=ndjson\|parquet` streams downloads with `X-Warehouse-Schema-Version`/`X-Warehouse-Checksum`. Spike 618 (issue #481) — partial slice: three source tables only | off | Unset var, restart — route 404; no stored warehouse state, collectors read live sources | -| `phase-181` | `NEXT_PUBLIC_FEATURE_PHASE_181` / `FEATURE_PHASE_181` | Anomaly detection (`lib/anomaly-detection.ts`): robust z-score (median+MAD, σ floor) on wash-excluded volume and per-run counter deltas (own counters excluded), deterministic balance drop tiers (≥15% warning, ≥35% critical), detection-before-push baselines in `anomaly-baselines.json` (capped ring). `GET /api/cron/anomaly-detect` (hourly, minute 5, preview + restore fenced) routes findings to webhooks with distinct kind+metric titles. Spike 619 (issue #482) — partial slice: no per-channel thresholds or suppressions | off | Unset var, restart — cron 404s, baselines stay inert, no alerts routed | +| `phase-170` | `NEXT_PUBLIC_FEATURE_PHASE_170` / `FEATURE_PHASE_170` | Albedo full retirement-or-parity plan with flagged sunset (`lib/albedo-sunset.ts`): call-site migration registry, parity-freeze (no new integrations, pinned `@albedo-link/intent` 0.12.0) and retired modes (`ALBEDO_SUNSET_MODE`), fail-closed Albedo signing with migration copy, and sunset banner UI. Spike 626 (issue #489) | off | Unset var, restart — sunset banner hidden, `/api/albedo/status` returns 404, every Albedo call site reverts to legacy guarded behavior | +| `phase-171` | `NEXT_PUBLIC_FEATURE_PHASE_171` / `FEATURE_PHASE_171` | Freighter mobile browser support (`lib/freighter-mobile.ts`): mobile/in-app UA detection, injection-based support matrix, viewport-fit + theme-color adaptation, visualViewport keyboard avoidance, same-tab friendbot strategy, and a sessionStorage connect/sign return-flow marker. Spike 627 (issue #490) | off | Unset var, restart — mobile notice renders nothing, viewport/keyboard adaptation skipped, friendbot reverts to legacy `window.open` behavior | +| `phase-172` | `NEXT_PUBLIC_FEATURE_PHASE_172` / `FEATURE_PHASE_172` | Biometric unlock for returning wallets with OS-gated session (`lib/biometric-unlock.ts`): WebAuthn platform-authenticator gate over an AES-GCM sealed session envelope (wallet-bound challenge, non-extractable HKDF key), attempt lockout with cooldown, and lock-screen tap-to-unlock middle state in `WalletProvider`. Spike 628 (issue #491) | off | Unset var, restart — lock screen never mounts, auto-restore behaves as before; `biometric-session-envelope` stays inert ciphertext | +| `phase-173` | `NEXT_PUBLIC_FEATURE_PHASE_173` / `FEATURE_PHASE_173` | Horizon-backed per-wallet transaction history timeline (`lib/wallet-history.ts`): `GET /api/history` serving merged `transactions?account=` + `effects?account=` reads through the Horizon bulkhead, effects classification, settle ↔ forge invoice correlation (settlement idempotency + generation-job stores), and an in-memory per-wallet index-cache with stale-on-error degrade wired to the dashboard timeline. Spike 629 (issue #492) | off | Unset var, restart — `/api/history` returns 404, dashboard timeline renders nothing, cache never read/written (no sidecars) | +| `phase-174` | `NEXT_PUBLIC_FEATURE_PHASE_174` / `FEATURE_PHASE_174` | Feature-flag ops: staged rollout, per-wallet targeting and kill-switch drills (`lib/feature-flags.ts`, `app/api/admin/feature-flags/route.ts`): CAS-versioned runtime rules (`feature-flag-ops.json`), emergency kill-switch overriding build-time `NEXT_PUBLIC_*` on server write paths, FNV-1a basis-point rollout ramps (`0..10_000`), stale-on-error rule caching, and non-destructive kill-switch drills proving distributor auto-refill halt and `faucetPending` mid-poll idempotency. Spike 607 (issue #471) | off | Unset var, restart — `/api/admin/feature-flags` returns 404, flags revert to static env evaluation, `feature-flag-ops.json` stays inert | +| `phase-175` | `NEXT_PUBLIC_FEATURE_PHASE_175` / `FEATURE_PHASE_175` | Dependency-update cadence automation with grouped Soroban and Stellar updates (`lib/dependency-cadence.ts`, `.github/dependabot.yml`, `scripts/check-dependency-cadence.ts`): unifies `@stellar/stellar-sdk` `^15.0.1` across root and `scripts/`, aligns `scripts/package.json` `build:phase`/`optimize:phase` on `wasm32-unknown-unknown`, pins `stellar-cli` `23.0.0` and `packageManager: "npm@10.9.2"`, exposes `soroban_abi_version() -> 22000` on `phase-protocol` and `mock-token`, and gates WASM bytecode hash changes on staging dry-run verification. Spike 608 (issue #472) | off | Unset var, restart — `/api/faucet` omits `sorobanAbiVersion` metadata, runtime cadence checks stay inert | +| `phase-176` | `NEXT_PUBLIC_FEATURE_PHASE_176` / `FEATURE_PHASE_176` | Unified analytics event warehouse across faucet, claim, notifications, forge, and push funnels (`lib/analytics-warehouse.ts`): SQLite WAL persistence with unique deduplication key idempotency, session stitching (`session_id` -> `wallet`), bounded closed-label metadata sanitization, and backward-compatible migration from faucet 5k-ring sidecar. Spike 610 (issue #473) | off | Unset var, restart — event warehouse write paths halt safely; existing `analytics_events` rows remain on disk; routes fall back to in-memory/legacy sidecar reads | +| `phase-177` | `NEXT_PUBLIC_FEATURE_PHASE_177` / `FEATURE_PHASE_177` | Funnel analytics v2 with retention cohorts: Day-N (Day-1, Day-3, Day-7, Day-14, Day-30), time-to-convert tracking (mean, p50, p90), wallet-age bands (`<24h`, `1-7d`, `7-30d`, `>30d`) with in-memory Horizon bulkhead cache, quest cohorts, and sybil-weighted retention joins. Spike 611 (issue #474) | off | Unset var, restart — `/api/notifications?retention=1` returns 404, analytics revert to v1 `computeClaimFunnel`; referral store locking retains race-safety | diff --git a/app/api/admin/feature-flags/route.ts b/app/api/admin/feature-flags/route.ts new file mode 100644 index 00000000..fb2c78ca --- /dev/null +++ b/app/api/admin/feature-flags/route.ts @@ -0,0 +1,216 @@ +import { timingSafeEqual } from "node:crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { checkAdminKey } from "@/lib/admin-key"; +import { + ALL_FEATURE_FLAGS, + FeatureFlagOpsError, + type FeatureFlagRule, + activateFlagKillSwitch, + evaluateFeatureFlag, + exportFeatureFlagRulesSnapshot, + featureFlagOpsCacheState, + getKillSwitchDrillHistory, + isFeatureFlagOpsEnabled, + isValidPhaseFeatureFlag, + liftFlagKillSwitch, + listFeatureFlagRules, + refreshFeatureFlagOpsCache, + removeFeatureFlagRule, + runKillSwitchDrill, + setFeatureFlagRule, +} from "@/lib/feature-flags"; +import { readJsonFile, updateStore } from "@/lib/json-store"; +import { serverDataJsonPath } from "@/lib/server-data-paths"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type FlagOpsStore = Record; + +function isAuthorizedAdmin(req: NextRequest): boolean { + if (checkAdminKey(req) === "ok") return true; + const adminToken = process.env.ADMIN_API_TOKEN?.trim(); + const authHeader = req.headers.get("authorization"); + if (!adminToken || !authHeader?.startsWith("Bearer ")) return false; + const provided = authHeader.slice(7).trim(); + const a = Buffer.from(provided, "utf8"); + const b = Buffer.from(adminToken, "utf8"); + return a.length === b.length && timingSafeEqual(a, b); +} + +async function hydrateFromSidecar() { + return refreshFeatureFlagOpsCache(() => + readJsonFile(serverDataJsonPath("featureFlagOps"), {}), + ); +} + +export async function GET(req: NextRequest) { + if (!isFeatureFlagOpsEnabled()) { + return NextResponse.json( + { error: "feature-flag ops disabled", code: "FLAG_DISABLED" }, + { status: 404 }, + ); + } + if (!isAuthorizedAdmin(req)) { + return NextResponse.json( + { error: "Unauthorized — valid admin key or token required", code: "UNAUTHORIZED" }, + { status: 401 }, + ); + } + + await hydrateFromSidecar(); + const wallet = req.nextUrl.searchParams.get("wallet")?.trim() || null; + const evaluations = ALL_FEATURE_FLAGS.map((flag) => + evaluateFeatureFlag(flag, { wallet, runtimeOnly: true }), + ); + + return NextResponse.json({ + ok: true, + cacheState: featureFlagOpsCacheState(), + rules: listFeatureFlagRules(), + evaluations, + drills: getKillSwitchDrillHistory(), + }); +} + +export async function POST(req: NextRequest) { + if (!isFeatureFlagOpsEnabled()) { + return NextResponse.json( + { error: "feature-flag ops disabled", code: "FLAG_DISABLED" }, + { status: 404 }, + ); + } + if (!isAuthorizedAdmin(req)) { + return NextResponse.json( + { error: "Unauthorized — valid admin key or token required", code: "UNAUTHORIZED" }, + { status: 401 }, + ); + } + + let body: Record; + try { + body = (await req.json()) as Record; + } catch { + return NextResponse.json({ error: "Invalid JSON body", code: "BAD_JSON" }, { status: 400 }); + } + + await hydrateFromSidecar(); + const action = typeof body.action === "string" ? body.action.trim() : "set_rule"; + + try { + if (action === "run_drill") { + const report = await runKillSwitchDrill(); + return NextResponse.json({ ok: report.passed, drill: report }, { status: report.passed ? 200 : 500 }); + } + + const flag = body.flag; + if (!isValidPhaseFeatureFlag(flag)) { + return NextResponse.json( + { error: "Valid PhaseFeatureFlag required", code: "INVALID_FLAG" }, + { status: 400 }, + ); + } + + const ifMatchHeader = req.headers.get("if-match")?.replace(/"/g, "").trim(); + const expectedVersion = + typeof body.expectedVersion === "number" + ? body.expectedVersion + : ifMatchHeader && /^\d+$/.test(ifMatchHeader) + ? Number(ifMatchHeader) + : undefined; + + if (action === "remove_rule") { + await updateStore("featureFlagOps", (store) => { + delete store[flag]; + return removeFeatureFlagRule(flag); + }); + return NextResponse.json({ ok: true, flag, removed: true }); + } + + const rule = await updateStore("featureFlagOps", (store) => { + // Sync in-memory cache with locked store before applying CAS update + for (const [k, v] of Object.entries(store)) { + if (isValidPhaseFeatureFlag(k) && v) { + setFeatureFlagRule({ + flag: k, + killSwitch: v.killSwitch, + overrideEnabled: v.overrideEnabled, + rolloutBps: v.rolloutBps, + allowWallets: v.allowWallets, + denyWallets: v.denyWallets, + updatedBy: v.updatedBy, + reason: v.reason, + }); + } + } + + let updated: FeatureFlagRule; + if (action === "kill_switch") { + const active = body.killSwitch !== false; + updated = active + ? activateFlagKillSwitch(flag, { + updatedBy: typeof body.updatedBy === "string" ? body.updatedBy : undefined, + reason: typeof body.reason === "string" ? body.reason : undefined, + expectedVersion, + }) + : liftFlagKillSwitch(flag, { + updatedBy: typeof body.updatedBy === "string" ? body.updatedBy : undefined, + reason: typeof body.reason === "string" ? body.reason : undefined, + expectedVersion, + }); + } else { + updated = setFeatureFlagRule({ + flag, + killSwitch: typeof body.killSwitch === "boolean" ? body.killSwitch : undefined, + overrideEnabled: + body.overrideEnabled === null || typeof body.overrideEnabled === "boolean" + ? body.overrideEnabled + : undefined, + rolloutBps: + body.rolloutBps === null || typeof body.rolloutBps === "number" + ? body.rolloutBps + : undefined, + allowWallets: Array.isArray(body.allowWallets) + ? body.allowWallets.filter((w): w is string => typeof w === "string") + : undefined, + denyWallets: Array.isArray(body.denyWallets) + ? body.denyWallets.filter((w): w is string => typeof w === "string") + : undefined, + updatedBy: typeof body.updatedBy === "string" ? body.updatedBy : undefined, + reason: typeof body.reason === "string" ? body.reason : undefined, + expectedVersion, + }); + } + + const snapshot = exportFeatureFlagRulesSnapshot(); + for (const k of Object.keys(store)) delete store[k]; + Object.assign(store, snapshot); + return updated; + }); + + const res = NextResponse.json({ + ok: true, + rule, + evaluation: evaluateFeatureFlag(flag, { runtimeOnly: true }), + }); + res.headers.set("ETag", `"${rule.version}"`); + return res; + } catch (err) { + if (err instanceof FeatureFlagOpsError) { + const status = + err.code === "VERSION_CONFLICT" + ? 409 + : err.code === "FLAG_DISABLED" || err.code === "NOT_FOUND" + ? 404 + : 400; + return NextResponse.json( + { error: err.message, code: err.code, details: err.details }, + { status }, + ); + } + return NextResponse.json( + { error: err instanceof Error ? err.message : "Feature flag operation failed" }, + { status: 500 }, + ); + } +} diff --git a/app/api/agent/forge/route.ts b/app/api/agent/forge/route.ts index 92a1af7d..f33452ba 100644 --- a/app/api/agent/forge/route.ts +++ b/app/api/agent/forge/route.ts @@ -20,6 +20,7 @@ import { NextRequest, NextResponse } from "next/server" import { Keypair } from "@stellar/stellar-sdk" import { settleX402Challenge, type X402Challenge } from "@/lib/x402-agent-client" import { PHASER_LIQ_SYMBOL, REQUIRED_AMOUNT, stroopsToLiqDisplay } from "@/lib/phase-protocol" +import { isAnalyticsWarehouseEnabled, recordWarehouseEvent } from "@/lib/analytics-warehouse" export const runtime = "nodejs" export const maxDuration = 180 @@ -326,6 +327,15 @@ export async function POST(request: NextRequest): Promise {}) + } + // 4. Fetch the 402 payment challenge from /api/forge-agent let challenge: X402Challenge try { @@ -388,6 +398,15 @@ export async function POST(request: NextRequest): Promise {}) + } + return NextResponse.json({ success: true, payer: agentPublicKey, diff --git a/app/api/cron/faucet-health/route.ts b/app/api/cron/faucet-health/route.ts index 28ff575f..461314d2 100644 --- a/app/api/cron/faucet-health/route.ts +++ b/app/api/cron/faucet-health/route.ts @@ -7,6 +7,9 @@ import { isRestoreInProgress } from "@/lib/backup-snapshot" import { isCronBlockedInPreview, previewTrustsVercelCronHeader } from "@/lib/preview-isolation" import { incSecurityCounter } from "@/lib/security-counters" import { getDistributorHealthStatus, recordHealthCheck } from "@/lib/distributor-health-store" +import { evaluateFeatureFlag, refreshFeatureFlagOpsCache } from "@/lib/feature-flags" +import { readJsonFile } from "@/lib/json-store" +import { serverDataJsonPath } from "@/lib/server-data-paths" /** * Automated Faucet Distributor Health Monitor @@ -213,8 +216,22 @@ export async function GET(req: NextRequest) { }) } - // Attempt auto-refill for PHASELQ - if (distLowPhaseLiq && distributorNeedsTopup(distBalance, DISTRIBUTOR_PHASELQ_MIN_STROOPS)) { + // Attempt auto-refill for PHASELQ (unless halted by phase-174 kill-switch on phase-133) + await refreshFeatureFlagOpsCache(() => + readJsonFile(serverDataJsonPath("featureFlagOps"), {}), + ) + const refillFlagEval = evaluateFeatureFlag("phase-133", { + wallet: distributorAddress, + runtimeOnly: true, + }) + if (refillFlagEval.reason === "kill_switch" || refillFlagEval.reason === "wallet_denied") { + results.refillAttempt = { + success: false, + skippedByKillSwitch: true, + reason: refillFlagEval.reason, + } + results.alerts.push(`🛑 Auto-refill skipped: feature-flag kill-switch active (${refillFlagEval.reason})`) + } else if (distLowPhaseLiq && distributorNeedsTopup(distBalance, DISTRIBUTOR_PHASELQ_MIN_STROOPS)) { try { const refillResult = await executeDistributorRefill( issuerKp, diff --git a/app/api/faucet/route.ts b/app/api/faucet/route.ts index 6b4d8d7e..581e965b 100644 --- a/app/api/faucet/route.ts +++ b/app/api/faucet/route.ts @@ -62,8 +62,10 @@ import { stepClaimable, } from "@/lib/quest-arcs" import { commitRefill, readLedger, releaseRefill, reserveFunds } from "@/lib/distributor-ledger" +import { CANONICAL_SOROBAN_ABI_VERSION, isDependencyCadenceEnabled } from "@/lib/dependency-cadence" import { incSecurityCounter } from "@/lib/security-counters" import { createNotification } from "@/lib/notification-store" +import { isAnalyticsWarehouseEnabled, recordWarehouseEvent } from "@/lib/analytics-warehouse" /** Vercel: Hobby ~10s; Pro/Enterprise permite más — subir si el faucet sigue en 504. */ export const maxDuration = 60 @@ -88,7 +90,7 @@ type WalletClaims = { dailyAt?: number quests?: Record /** Mint ya enviado; reutilizamos el hash para seguir el poll sin reenviar (serverless timeout). */ - faucetPending?: { hash: string; reward: RewardType; at: number; amountStroops?: string; tokenContract?: string } + faucetPending?: { hash: string; reward: RewardType; at: number; amountStroops?: string } } type FaucetClaims = Record @@ -164,7 +166,12 @@ async function readClaims(): Promise { let faucetPending: WalletClaims["faucetPending"] if (fp && typeof fp === "object" && typeof fp.hash === "string" && typeof fp.at === "number") { const r = await parseRewardType(fp.reward) - faucetPending = { hash: fp.hash, reward: r, at: fp.at, amountStroops: typeof fp.amountStroops === "string" ? fp.amountStroops : undefined, tokenContract: typeof fp.tokenContract === "string" ? fp.tokenContract : undefined } + faucetPending = { + hash: fp.hash, + reward: r, + at: fp.at, + ...(typeof fp.amountStroops === "string" ? { amountStroops: fp.amountStroops } : {}), + } } normalized[wallet] = { genesisAt: typeof value.genesisAt === "number" ? value.genesisAt : undefined, @@ -354,6 +361,9 @@ async function buildWalletStatus(wallet: string | null, claims: FaucetClaims) { rewards, ...(streakInfo ? { streak: streakInfo } : {}), ...(referralStats ? { referral: referralStats } : {}), + ...(isDependencyCadenceEnabled(wallet ?? undefined) + ? { sorobanAbiVersion: CANONICAL_SOROBAN_ABI_VERSION } + : {}), } } @@ -607,7 +617,7 @@ export async function POST(req: NextRequest) { } // Triple check #3: Sybil resistance score (on-chain wallet history) - if (isSybilResistanceEnabled()) { + if (isSybilResistanceEnabled(userAddress)) { const sybilScore = await assessWalletSybilRisk(userAddress) if (sybilScore && sybilScore.suspect) { return NextResponse.json( @@ -665,7 +675,7 @@ export async function POST(req: NextRequest) { } // phase-132: validate referral code early (before mint) to fail fast - if (reward === "genesis" && body.referralCode && isReferralQuestEnabled()) { + if (reward === "genesis" && body.referralCode && isReferralQuestEnabled(userAddress)) { const refValidation = await validateReferralCode(body.referralCode, userAddress) if (!refValidation.valid) { return NextResponse.json( @@ -823,7 +833,7 @@ export async function POST(req: NextRequest) { let payoutSubmitted = false try { // phase-133: auto-top-up distributor if balance is low (fire-and-forget, non-blocking) - if (isDistributorTopupEnabled() && useTransfer) { + if (isDistributorTopupEnabled(source) && useTransfer) { void prepareTopup(source).then((topup) => { if (topup.shouldTopup) { console.log(`[faucet] phase-133 distributor auto-top-up: ${topup.reason}`) @@ -843,6 +853,18 @@ export async function POST(req: NextRequest) { if (row.faucetPending?.reward === reward) { const pendingHash = row.faucetPending.hash + const pinnedAmountStroops = + row.faucetPending.amountStroops ?? (await rewardAmountStroops(reward)) + + if (isAnalyticsWarehouseEnabled(userAddress)) { + void recordWarehouseEvent({ + surface: "claim", + stage: "claim_polling", + wallet: userAddress, + metadata: { hash: pendingHash, reward, source: "pending_poll" }, + }).catch(() => {}) + } + const out = await pollSubmittedMint(server, pendingHash) if (out.outcome === "SUCCESS") { await markClaim(userAddress, reward, pendingHash, row.faucetPending.amountStroops, row.faucetPending.at, row.faucetPending.tokenContract) @@ -853,15 +875,25 @@ export async function POST(req: NextRequest) { } // phase-132: record referral bonus on genesis claim let referralBonus: string | null = null - if (reward === "genesis" && body.referralCode && isReferralQuestEnabled()) { + if (reward === "genesis" && body.referralCode && isReferralQuestEnabled(userAddress)) { const refResult = await recordReferral(body.referralCode, userAddress) referralBonus = refResult.bonus } + + if (isAnalyticsWarehouseEnabled(userAddress)) { + void recordWarehouseEvent({ + surface: "claim", + stage: "claim_confirmed", + wallet: userAddress, + metadata: { hash: pendingHash, reward, amountStroops: pinnedAmountStroops }, + }).catch(() => {}) + } + return NextResponse.json({ ok: true, hash: pendingHash, reward, - amountStroops: await rewardAmountStroops(reward), + amountStroops: pinnedAmountStroops, ...(streakInfo ? { streak: streakInfo } : {}), ...(referralBonus ? { referralBonus } : {}), }) @@ -869,15 +901,36 @@ export async function POST(req: NextRequest) { if (out.outcome === "FAILED") { await clearFaucetPendingOnly(userAddress) abortArcClaim(reward) + + if (isAnalyticsWarehouseEnabled(userAddress)) { + void recordWarehouseEvent({ + surface: "claim", + stage: "claim_failed", + wallet: userAddress, + reason: String(out.failed), + metadata: { hash: pendingHash, reward }, + }).catch(() => {}) + } + return faucetMintLedgerFailedResponse(pendingHash, out.failed) } + + if (isAnalyticsWarehouseEnabled(userAddress)) { + void recordWarehouseEvent({ + surface: "claim", + stage: "claim_status_202", + wallet: userAddress, + metadata: { hash: pendingHash, reward }, + }).catch(() => {}) + } + return NextResponse.json( { ok: false, hash: pendingHash, pending: true, reward, - amountStroops: await rewardAmountStroops(reward), + amountStroops: pinnedAmountStroops, note: "Transaction still pending on ledger. Retry the same reward in a few seconds.", }, { status: 202 }, @@ -885,6 +938,16 @@ export async function POST(req: NextRequest) { } if (row.faucetPending && row.faucetPending.reward !== reward) { + if (isAnalyticsWarehouseEnabled(userAddress)) { + void recordWarehouseEvent({ + surface: "claim", + stage: "claim_status_409", + wallet: userAddress, + reason: "FAUCET_MINT_IN_PROGRESS", + metadata: { reward, blockingReward: row.faucetPending.reward }, + }).catch(() => {}) + } + return NextResponse.json( { error: @@ -948,7 +1011,13 @@ export async function POST(req: NextRequest) { } const c = new Contract(tokenId) // phase-131: apply streak multiplier to daily reward amount - const effectiveAmountStroops = approvedAmountStroops + let effectiveAmountStroops = await rewardAmountStroops(reward) + if (reward === "daily" && isStreakMultiplierEnabled(userAddress)) { + const streak = await getStreakInfo(userAddress) + if (streak.multiplier > 1) { + effectiveAmountStroops = applyStreakMultiplier(effectiveAmountStroops, streak.multiplier) + } + } const amountSc = nativeToScVal(BigInt(effectiveAmountStroops), { type: "i128" }) const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -973,6 +1042,15 @@ export async function POST(req: NextRequest) { if (send.status === "ERROR") { abortArcClaim(reward) const err = (send as { errorResult?: unknown }).errorResult + if (isAnalyticsWarehouseEnabled(userAddress)) { + void recordWarehouseEvent({ + surface: "claim", + stage: "claim_status_502", + wallet: userAddress, + reason: "RPC_SEND_ERROR", + metadata: { detail: String(err ?? send) }, + }).catch(() => {}) + } return NextResponse.json( { error: "RPC rechazó la transacción.", detail: String(err ?? send) }, { status: 502 }, @@ -981,10 +1059,19 @@ export async function POST(req: NextRequest) { const hash = send.hash as string payoutSubmitted = true + if (isAnalyticsWarehouseEnabled(userAddress)) { + void recordWarehouseEvent({ + surface: "claim", + stage: "claim_pending", + wallet: userAddress, + metadata: { hash, reward, amountStroops: effectiveAmountStroops }, + }).catch(() => {}) + } + liveClaims = await readClaims() liveClaims[userAddress] = { ...(liveClaims[userAddress] ?? {}), - faucetPending: { hash, reward, at: Date.now(), amountStroops: effectiveAmountStroops, tokenContract: tokenId }, + faucetPending: { hash, reward, at: Date.now(), amountStroops: effectiveAmountStroops }, } await writeClaims(liveClaims) @@ -998,10 +1085,20 @@ export async function POST(req: NextRequest) { } // phase-132: record referral bonus on genesis claim let referralBonus: string | null = null - if (reward === "genesis" && body.referralCode && isReferralQuestEnabled()) { + if (reward === "genesis" && body.referralCode && isReferralQuestEnabled(userAddress)) { const refResult = await recordReferral(body.referralCode, userAddress) referralBonus = refResult.bonus } + + if (isAnalyticsWarehouseEnabled(userAddress)) { + void recordWarehouseEvent({ + surface: "claim", + stage: "claim_confirmed", + wallet: userAddress, + metadata: { hash, reward, amountStroops: effectiveAmountStroops }, + }).catch(() => {}) + } + return NextResponse.json({ ok: true, hash, @@ -1014,15 +1111,36 @@ export async function POST(req: NextRequest) { if (out.outcome === "FAILED") { await clearFaucetPendingOnly(userAddress) abortArcClaim(reward) + + if (isAnalyticsWarehouseEnabled(userAddress)) { + void recordWarehouseEvent({ + surface: "claim", + stage: "claim_failed", + wallet: userAddress, + reason: String(out.failed), + metadata: { hash, reward }, + }).catch(() => {}) + } + return faucetMintLedgerFailedResponse(hash, out.failed) } + + if (isAnalyticsWarehouseEnabled(userAddress)) { + void recordWarehouseEvent({ + surface: "claim", + stage: "claim_status_202", + wallet: userAddress, + metadata: { hash, reward }, + }).catch(() => {}) + } + return NextResponse.json( { ok: false, hash, pending: true, reward, - amountStroops: await rewardAmountStroops(reward), + amountStroops: effectiveAmountStroops, note: "Transaction still pending on ledger. Retry the same reward in a few seconds.", }, { status: 202 }, @@ -1030,6 +1148,17 @@ export async function POST(req: NextRequest) { } catch (e) { if (!payoutSubmitted) abortArcClaim(reward) const msg = e instanceof Error ? e.message : String(e) + + if (isAnalyticsWarehouseEnabled(userAddress)) { + void recordWarehouseEvent({ + surface: "claim", + stage: "claim_status_502", + wallet: userAddress, + reason: msg, + metadata: { reward }, + }).catch(() => {}) + } + return NextResponse.json({ error: msg }, { status: 502 }) } } diff --git a/app/api/forge-agent/route.ts b/app/api/forge-agent/route.ts index 6fc5cd1c..47bf9d0d 100644 --- a/app/api/forge-agent/route.ts +++ b/app/api/forge-agent/route.ts @@ -21,6 +21,7 @@ import { import { createGenerationJob } from "@/lib/generation-job-store" import { generateLoreStep } from "@/lib/forge/ai-pipeline" import { normalizeForgeImageStyleMode, normalizeForgeOutputLang } from "@/lib/forge/prompt-builder" +import { isAnalyticsWarehouseEnabled, recordWarehouseEvent } from "@/lib/analytics-warehouse" export const runtime = "nodejs" export const maxDuration = 120 @@ -88,6 +89,14 @@ export async function POST(request: NextRequest) { const resolution = await verifyPaymentStep({ authHeader: auth, body, paymentRequirements }) if (resolution === "facilitator_rejected") { + if (isAnalyticsWarehouseEnabled()) { + void recordWarehouseEvent({ + surface: "forge", + stage: "forge_failed", + correlation_id: correlationId, + reason: "facilitator_rejected", + }).catch(() => {}) + } return NextResponse.json( { success: false, error: ERR_SETTLEMENT_REJECTED }, { status: 403, headers: { "x-correlation-id": correlationId } }, @@ -96,6 +105,14 @@ export async function POST(request: NextRequest) { if (resolution === "missing") return paymentRequiredResponse(request) if (receipt) { + if (isAnalyticsWarehouseEnabled()) { + void recordWarehouseEvent({ + surface: "forge", + stage: "forge_paid", + correlation_id: correlationId, + metadata: { txHash: receipt }, + }).catch(() => {}) + } if (await isSettlementUsed(receipt)) { return NextResponse.json( { success: false, error: "Settlement already used" }, diff --git a/app/api/jobs/[txHash]/route.ts b/app/api/jobs/[txHash]/route.ts index 3f90e3de..553c0762 100644 --- a/app/api/jobs/[txHash]/route.ts +++ b/app/api/jobs/[txHash]/route.ts @@ -17,6 +17,7 @@ import { NextRequest, NextResponse } from "next/server" import { getGenerationJobByTxHash } from "@/lib/generation-job-store" +import { isAnalyticsWarehouseEnabled, recordWarehouseEvent } from "@/lib/analytics-warehouse" export const runtime = "nodejs" export const dynamic = "force-dynamic" @@ -45,6 +46,15 @@ export async function GET( ) } + if (isAnalyticsWarehouseEnabled(job.payerAddress)) { + void recordWarehouseEvent({ + surface: "forge", + stage: "forge_polling", + wallet: job.payerAddress ?? null, + metadata: { jobId: job.id, txHash: job.txHash, status: job.status }, + }).catch(() => {}) + } + return NextResponse.json( { found: true, diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts index ddb627fe..87f5543b 100644 --- a/app/api/notifications/route.ts +++ b/app/api/notifications/route.ts @@ -15,12 +15,48 @@ import { recordFaucetFunnelEvent, type GatewayAuthRotation, type NotificationPreferences, + getRetentionCohortsAnalytics, } from "@/lib/notification-store" +import { + isAnalyticsWarehouseEnabled, + getUnifiedFunnelAnalytics, + recordWarehouseEvent, +} from "@/lib/analytics-warehouse" +import { isFunnelRetentionEnabled } from "@/lib/funnel-retention" export const runtime = "nodejs" export const dynamic = "force-dynamic" export async function GET(request: NextRequest) { + // Spike 611 (phase-177): funnel analytics v2 with retention cohorts + if (request.nextUrl.searchParams.get("retention") === "1" || request.nextUrl.searchParams.get("cohort") === "1") { + if (!isFunnelRetentionEnabled()) { + return NextResponse.json( + { error: "funnel retention analytics disabled", code: "FLAG_DISABLED", feature_enabled: false }, + { status: 404 }, + ) + } + const windowParam = Number(request.nextUrl.searchParams.get("windowMs")) + const windowMs = Number.isFinite(windowParam) && windowParam > 0 ? windowParam : undefined + const retention_report = await getRetentionCohortsAnalytics({ windowMs }) + return NextResponse.json({ retention_report, feature_enabled: true }) + } + + // Spike 610 (phase-176): unified analytics warehouse query + if (request.nextUrl.searchParams.get("warehouse") === "1") { + if (!isAnalyticsWarehouseEnabled()) { + return NextResponse.json( + { error: "analytics warehouse disabled", code: "FLAG_DISABLED", feature_enabled: false }, + { status: 404 }, + ) + } + const surface = request.nextUrl.searchParams.get("surface") ?? "faucet" + const windowParam = Number(request.nextUrl.searchParams.get("windowMs")) + const windowMs = Number.isFinite(windowParam) && windowParam > 0 ? windowParam : undefined + const funnel = await getUnifiedFunnelAnalytics({ surface, windowMs }) + return NextResponse.json({ funnel, feature_enabled: true }) + } + // module #53 (phase-53): faucet claim-funnel analytics — operator view, not wallet-scoped. if (request.nextUrl.searchParams.get("funnel") === "1") { if (!isFaucetAnalyticsEnabled()) { @@ -51,6 +87,8 @@ export async function GET(request: NextRequest) { preferences_feature_enabled: isNotificationPreferencesEnabled(), gateway_auth_rotation_feature_enabled: isPhase128Enabled(), faucet_analytics_feature_enabled: isFaucetAnalyticsEnabled(), + warehouse_feature_enabled: isAnalyticsWarehouseEnabled(), + retention_feature_enabled: isFunnelRetentionEnabled(), }) } @@ -117,6 +155,26 @@ export async function POST(request: NextRequest) { } } + // Spike 610 (phase-176): unified cross-surface event recording + if (body.action === "record_warehouse_event") { + try { + const correlationId = request.headers.get("x-correlation-id") || request.headers.get("x-request-id") || undefined + const eventPayload = typeof body.funnel_event === "object" && body.funnel_event !== null ? (body.funnel_event as Record) : {} + const event = await recordWarehouseEvent({ + correlation_id: correlationId, + ...eventPayload, + }) + return NextResponse.json({ ok: true, event, feature_enabled: isAnalyticsWarehouseEnabled() }) + } catch (error) { + const code = error instanceof Error && "code" in error ? String((error as { code: unknown }).code) : "UNKNOWN" + const details = error instanceof Error && "details" in error ? (error as { details?: unknown }).details : undefined + return NextResponse.json( + { error: error instanceof Error ? error.message : "warehouse event rejected", code, details }, + { status: code === "FLAG_DISABLED" ? 404 : 400 }, + ) + } + } + const wallet = typeof body.wallet === "string" ? body.wallet.trim() : "" if (!wallet || !StrKey.isValidEd25519PublicKey(wallet)) { return NextResponse.json({ error: "valid wallet required" }, { status: 400 }) diff --git a/app/api/webhooks/nanobanana/route.ts b/app/api/webhooks/nanobanana/route.ts index 01bfa856..e18614f7 100644 --- a/app/api/webhooks/nanobanana/route.ts +++ b/app/api/webhooks/nanobanana/route.ts @@ -31,6 +31,7 @@ import { generateLoreStep } from "@/lib/forge/ai-pipeline" import { publishIpfsStep } from "@/lib/forge/ipfs-publisher" import { mintNftStep } from "@/lib/forge/pipeline" import { normalizeForgeImageStyleMode, normalizeForgeOutputLang } from "@/lib/forge/prompt-builder" +import { isAnalyticsWarehouseEnabled, recordWarehouseEvent } from "@/lib/analytics-warehouse" export const runtime = "nodejs" export const dynamic = "force-dynamic" @@ -198,6 +199,13 @@ export async function POST(request: NextRequest): Promise { }).catch(() => {}) // Return 401 only when secret IS configured (otherwise log and proceed) if (getWebhookSecret()) { + if (isAnalyticsWarehouseEnabled()) { + void recordWarehouseEvent({ + surface: "forge", + stage: "webhook_rejected", + reason: "invalid_signature", + }).catch(() => {}) + } return NextResponse.json({ error: "Invalid signature" }, { status: 401 }) } } @@ -245,6 +253,15 @@ export async function POST(request: NextRequest): Promise { webhookDeliveries: (job.webhookDeliveries ?? 0) + 1, }).catch(() => {}) + if (isAnalyticsWarehouseEnabled(job.payerAddress)) { + void recordWarehouseEvent({ + surface: "forge", + stage: "webhook_received", + wallet: job.payerAddress ?? null, + metadata: { jobId: job.id, taskId, txHash: job.txHash, successFlag }, + }).catch(() => {}) + } + // Handle failure from NanoBanana if (successFlag === 2 || successFlag === 3) { const errMsg = errorMessage || `NanoBanana task failed (successFlag=${successFlag ?? "unknown"})` diff --git a/contracts/mock-token/Cargo.toml b/contracts/mock-token/Cargo.toml index 283d8952..51f12ae4 100644 --- a/contracts/mock-token/Cargo.toml +++ b/contracts/mock-token/Cargo.toml @@ -21,3 +21,10 @@ debug-assertions = false panic = "abort" codegen-units = 1 lto = true + +[package.metadata.phase_cadence] +wasm_target = "wasm32-unknown-unknown" +soroban_sdk_major = 22 +soroban_abi_version = 22000 +stellar_cli_version = "23.0.0" + diff --git a/contracts/mock-token/src/lib.rs b/contracts/mock-token/src/lib.rs index 75b77424..175420f3 100644 --- a/contracts/mock-token/src/lib.rs +++ b/contracts/mock-token/src/lib.rs @@ -525,6 +525,11 @@ impl MockToken { .map(|m: TokenMetadata| m.symbol) .unwrap_or_else(|| String::from_str(&env, "MOCK")) } + + /// Spike 608: Exposes the pinned Soroban SDK / ABI version (`22000` for soroban-sdk 22.0.0). + pub fn soroban_abi_version(_env: Env) -> u32 { + 22_000 + } } #[cfg(test)] diff --git a/contracts/phase-protocol/Cargo.toml b/contracts/phase-protocol/Cargo.toml index 1b967516..0920914e 100644 --- a/contracts/phase-protocol/Cargo.toml +++ b/contracts/phase-protocol/Cargo.toml @@ -22,3 +22,10 @@ debug-assertions = false panic = "abort" codegen-units = 1 lto = true + +[package.metadata.phase_cadence] +wasm_target = "wasm32-unknown-unknown" +soroban_sdk_major = 22 +soroban_abi_version = 22000 +stellar_cli_version = "23.0.0" + diff --git a/contracts/phase-protocol/src/lib.rs b/contracts/phase-protocol/src/lib.rs index d21b67f9..1a6bcac1 100644 --- a/contracts/phase-protocol/src/lib.rs +++ b/contracts/phase-protocol/src/lib.rs @@ -1872,6 +1872,12 @@ impl PhaseProtocol { .get(&DataKey::RaffleEntry(collection_id, entrant)) .unwrap_or(0) } + + /// Spike 608: Exposes the pinned Soroban SDK / ABI version (`22000` for soroban-sdk 22.0.0) + /// so grouped dependency updates and staging dry-runs can verify WASM ABI parity. + pub fn soroban_abi_version(_env: Env) -> u32 { + 22_000 + } } #[cfg(test)] diff --git a/diagnose-env.ts b/diagnose-env.ts index 1e07fdce..c5e5b402 100644 --- a/diagnose-env.ts +++ b/diagnose-env.ts @@ -23,6 +23,13 @@ import { TOKEN_ADDRESS, CONTRACT_ID } from "@/lib/phase-protocol" import { auditFaucetRateLimitWiring, isFaucetRateLimitRedisEnabled } from "@/lib/profile-store" import { auditQuestExpiryWiring, isQuestExpiryEnabled } from "@/lib/explore-domain" import { auditFaucetAnalyticsWiring, isFaucetAnalyticsEnabled } from "@/lib/notification-store" +import { + ALL_FEATURE_FLAGS, + auditFeatureFlagOpsWiring, + getEnabledFeatureFlags, + isFeatureFlagOpsEnabled, + listFeatureFlagRules, +} from "@/lib/feature-flags" console.log("=".repeat(70)) console.log("DIAGNÓSTICO DE CONFIGURACIÓN PHASE") @@ -184,6 +191,19 @@ if (isFaucetAnalyticsEnabled()) { console.log(` ${funnelAudit.ok ? "✅" : "❌"} ${funnelAudit.note}`) } +// 5g. Feature-flag ops: staged rollout, per-wallet targeting & kill-switch (Spike 607 / phase-174) +console.log("\n🚩 Feature-flag ops & rollout state (Spike 607 / phase-174):") +const flagOpsAudit = auditFeatureFlagOpsWiring() +console.log(` ${flagOpsAudit.ok ? "✅" : "❌"} ${flagOpsAudit.note}`) +const enabledFlags = getEnabledFeatureFlags() +const activeRules = listFeatureFlagRules() +console.log( + ` ℹ️ Flags enabled: ${enabledFlags.length}/${ALL_FEATURE_FLAGS.length} (${enabledFlags.length ? enabledFlags.join(", ") : "none"})`, +) +if (isFeatureFlagOpsEnabled() || activeRules.length > 0) { + console.log(` ℹ️ Runtime override/kill-switch rules active: ${activeRules.length}`) +} + // 6. Resumen console.log("\n" + "=".repeat(70)) if (validation.valid && tokenDiagnostic.isContract && !tokenDiagnostic.errors.length) { diff --git a/docs/spikes/607-feature-flag-ops-findings.md b/docs/spikes/607-feature-flag-ops-findings.md new file mode 100644 index 00000000..7a73f5bb --- /dev/null +++ b/docs/spikes/607-feature-flag-ops-findings.md @@ -0,0 +1,22 @@ +# Spike 607: Feature-Flag Ops — Staged Rollout, Per-Wallet Targeting and Kill-Switch Drills + +**Issue**: #471 +**Type**: Advanced Integration Spike +**Feature Flag**: `phase-174` (`NEXT_PUBLIC_FEATURE_PHASE_174` / `FEATURE_PHASE_174`) +**Key Surfaces**: `lib/feature-flags.ts`, `app/api/admin/feature-flags/route.ts`, `app/api/faucet/route.ts`, `lib/distributor-refill.ts`, `app/api/cron/faucet-health/route.ts` + +## Context & Motivation + +Feature flags previously relied solely on static boolean environment reads (`NEXT_PUBLIC_FEATURE_*` then `FEATURE_*` via `isTruthy`). `getEnabledFeatureFlags()` omitted `phase-134` and `phase-135` while `PhaseFeatureFlag` duplicated `phase-128`. Critically, because `NEXT_PUBLIC_*` variables are baked into the client bundle at build time, server-side write paths had no way to halt a misbehaving flag (such as `phase-133` distributor auto-topup/refill) without an environment edit and redeploy, nor could operators target specific wallets or ramp flags in basis points without risking mid-poll `faucetPending` divergence. + +## Key Architectural Decisions + +1. **Build-Time vs. Runtime Split & Kill-Switch Precedence (`lib/feature-flags.ts`)**: `evaluateFeatureFlag(flag, context)` records both `clientBundled` (`NEXT_PUBLIC_FEATURE_*`) and `serverConfigured` (`FEATURE_*`) exposure while evaluating server runtime rules in strict order: `PHASE_KILL_SWITCH_FLAGS` env -> `rule.killSwitch` -> `rule.denyWallets` -> `rule.allowWallets` -> `rule.overrideEnabled` -> `rule.rolloutBps` -> static env fallback. An active kill-switch disables server write paths immediately even when `NEXT_PUBLIC_*` was baked in at build time. +2. **Deterministic FNV-1a Basis-Point Rollout (`flagRolloutBucket`)**: Uses 32-bit FNV-1a over `${flag}|${wallet}` modulo `10_000`, matching `questExperimentBucket` in `lib/env-validation.ts`. Ramping `rolloutBps` upward (`0 -> 1000 -> 5000 -> 10000`) is strictly monotonic and never flips an already-enrolled wallet off. +3. **Mid-Poll `faucetPending` Idempotency (`app/api/faucet/route.ts`)**: `faucetPending` now persists `amountStroops: effectiveAmountStroops` at transaction submission time. If `phase-131` (streak multiplier) is ramped or killed during the 8-minute `FAUCET_PENDING_TTL_MS` polling window, subsequent poll retries reuse both the in-flight `hash` and the pinned `amountStroops`. +4. **Stale-on-Error In-Memory Rule Cache & Optimistic CAS**: `refreshFeatureFlagOpsCache()` hydrates rules from `feature-flag-ops.json` (`lib/server-data-paths.ts`). If the sidecar read fails, the cache retains the last-known-good rules (`cacheState: "stale"`) and increments `flag_ops_stale_fallback`. Concurrent operator updates with a stale `expectedVersion` / `If-Match` fail with `409 VERSION_CONFLICT` (`flag_ops_version_conflict`). +5. **Non-Destructive Kill-Switch Drill (`runKillSwitchDrill`)**: Verifies build-env kill override, 50-wallet monotonic rollout ramp, `faucetPending` mid-poll idempotency, and stale-cache resilience, restoring pre-drill state in `finally`. + +## Rollback + +Unset `NEXT_PUBLIC_FEATURE_PHASE_174` / `FEATURE_PHASE_174` or set to `0`. `GET/POST /api/admin/feature-flags` returns 404, flags revert to static environment evaluation, and `feature-flag-ops.json` remains inert on disk. diff --git a/docs/spikes/608-dependency-update-cadence-findings.md b/docs/spikes/608-dependency-update-cadence-findings.md new file mode 100644 index 00000000..dff7dcc4 --- /dev/null +++ b/docs/spikes/608-dependency-update-cadence-findings.md @@ -0,0 +1,24 @@ +# Spike 608: Dependency-Update Cadence Automation with Grouped Soroban and Stellar Updates + +**Issue**: #472 +**Type**: Advanced Integration Spike +**Feature Flag**: `phase-175` (`NEXT_PUBLIC_FEATURE_PHASE_175` / `FEATURE_PHASE_175`) +**Key Surfaces**: `lib/dependency-cadence.ts`, `scripts/check-dependency-cadence.ts`, `.github/dependabot.yml`, `package.json`, `scripts/package.json`, `contracts/phase-protocol`, `contracts/mock-token` + +## Context & Motivation + +PHASE previously maintained two diverged JavaScript dependency universes and an inconsistent Rust/WASM build pipeline: +1. Root `package.json` pinned `@stellar/stellar-sdk` `^15.0.1` (`15.0.1`), `next` `16.2.2` (with `overrides.next`), `react` `19.2.0`, and declared `packageManager: "yarn@1.22.22..."` despite CI running `npm ci`. +2. `scripts/package.json` pinned `@stellar/stellar-sdk` `^12.0.0` (`12.3.0` — three major versions behind root), built `phase-protocol` with `--target wasm32v1-none` while `.github/workflows/ci.yml` and `scripts/deploy-phase-sep50.ts` used `--target wasm32-unknown-unknown`, and installed `stellar-cli` via an unpinned `^23` range. +3. `.github/workflows/dependency-audit.yml` only watched root `package*.json` and had no grouped Dependabot config or WASM bytecode-diff gate. + +## Key Architectural Decisions + +1. **Unified `@stellar/stellar-sdk` v15 Across Root and `scripts/`**: Upgraded `scripts/package.json` and `scripts/package-lock.json` to `@stellar/stellar-sdk` `^15.0.1` (`15.0.1`, `@stellar/stellar-base` `15.0.0`, `@stellar/js-xdr` `4.0.0`). `verifyStellarSdkSorobanEncodingParity()` in `lib/dependency-cadence.ts` verifies that `TransactionBuilder`, `Contract.call`, `Address.fromString(...).toScVal()`, and `nativeToScVal(BigInt, { type: "i128" })` produce valid, distinct XDR envelopes for `mint` and `transfer`. +2. **Canonical WASM Target (`wasm32-unknown-unknown`) & Soroban ABI Pin (`22000`)**: Aligned `scripts/package.json` (`build:phase`, `optimize:phase`) with `.github/workflows/ci.yml` and `scripts/deploy-phase-sep50.ts` on `wasm32-unknown-unknown`, pinned `stellar-cli` `23.0.0`, and added `pub fn soroban_abi_version(_env: Env) -> u32 { 22_000 }` plus `[package.metadata.phase_cadence]` to both `contracts/phase-protocol` and `contracts/mock-token`. +3. **WASM Bytecode-Diff & Staging Dry-Run Gate (`evaluateWasmBytecodeDiffGate`)**: Computes SHA-256 digests of baseline vs. candidate WASM artifacts and blocks deployment if `sorobanAbiVersion !== 22000` or if the bytecode hash changed without `stagingDryRunPassed: true`. +4. **Grouped Dependabot & CI Enforcement**: Added `.github/dependabot.yml` with weekly grouped PRs (`stellar-sdk-root`, `stellar-sdk-scripts`, `nextjs-react-bonded`, `vitest-bonded`, `soroban-sdk-phase-protocol`, `soroban-sdk-mock-token`, `github-actions`) and wired `npm run deps:cadence` into `ci.yml`, `dependency-audit.yml`, and `secret-rotation.yml`. + +## Rollback + +Unset `NEXT_PUBLIC_FEATURE_PHASE_175` / `FEATURE_PHASE_175` or set to `0`. `/api/faucet` omits `sorobanAbiVersion` metadata and runtime cadence flags remain off. diff --git a/docs/spikes/610-unified-analytics-event-warehouse-findings.md b/docs/spikes/610-unified-analytics-event-warehouse-findings.md new file mode 100644 index 00000000..dc7bb412 --- /dev/null +++ b/docs/spikes/610-unified-analytics-event-warehouse-findings.md @@ -0,0 +1,43 @@ +# Spike 610: Unified Analytics Event Warehouse Across Faucet, Claim, Notifications, Forge, and Push Funnels + +**Issue**: #473 +**Type**: Advanced Integration Spike +**Feature Flag**: `phase-176` (`NEXT_PUBLIC_FEATURE_PHASE_176` / `FEATURE_PHASE_176`) +**Key Surfaces**: `lib/analytics-warehouse.ts`, `lib/sqlite-db.ts`, `lib/server-data-paths.ts`, `lib/notification-store.ts`, `app/api/notifications/route.ts`, `lib/api-observability.ts`, `app/api/faucet/route.ts`, `lib/push-notifications.ts`, `lib/achievement-store.ts`, `lib/quest-registry.ts`, `lib/generation-job-store.ts`, `app/api/forge-agent/route.ts`, `app/api/agent/forge/route.ts`, `app/api/jobs/[txHash]/route.ts`, `app/api/webhooks/nanobanana/route.ts` + +## Context & Motivation + +Prior to this spike, event tracking in PHASE existed in three isolated, disconnected pockets: +1. **Faucet funnel 5k-ring sidecar**: Kept only the last 5,000 happy-path stages (`viewed -> wallet_connected -> claim_started -> claim_signed -> claim_confirmed` + `claim_failed`) in an unindexed JSON sidecar with purely in-memory aggregation. +2. **Notification store point state**: Maintained point state capped at 50 notifications per wallet with zero delivery lifecycle events or drop-off tracking. +3. **Observability console logs**: Emitted per-request `correlationId` lines with unbounded JSON metadata, with no persistent indexing or join key connecting anonymous web traffic to on-chain wallet actions. + +Crucially, the faucet claim lifecycle (pending 8-min TTL, 4x1s poll, 202/409/502 responses) emitted zero funnel events, rendering abandoned or timed-out claims completely invisible to operators. + +## Key Architectural Decisions + +1. **SQLite WAL Persistence & Deduplication Key Idempotency (`lib/sqlite-db.ts`, `lib/analytics-warehouse.ts`)**: + Introduced `analytics_events` table indexed by `surface`, `stage`, `wallet`, `session_id`, `correlation_id`, `ts`, and a unique `dedupe_key` (`SHA256(surface:stage:subject:minuteBucket)`). Duplicate polling attempts and retry requests execute `INSERT OR IGNORE`, ensuring event ingestion is strictly idempotent without ballooning storage or skewing metrics. + +2. **Session Stitching (`analytics_session_stitching`)**: + Anonymous top-of-funnel events (e.g., `"viewed"`) carry a client-generated `session_id`. When the visitor subsequently connects a wallet, the session mapping is recorded, and all preceding anonymous events are joined to the authenticated wallet. Funnel and retention metrics calculate true conversion from first anonymous touchpoint to confirmed on-chain mint. + +3. **Cross-Surface Lifecycle Event Sourcing**: + - **Faucet & Claim**: Emits `claim_pending`, `claim_polling`, `claim_status_202`, `claim_status_409`, `claim_status_502`, `claim_confirmed`, and `claim_failed` stages during both immediate execution and serverless resume cycles. + - **Notifications**: Emits `notification_created`, `notification_read`, and `all_read` events alongside push notifications `push_dispatched` and `push_failed`. + - **Observability**: Enforces bounded closed-label metadata and captures request completion/failure metrics. + - **Forge & AI Agents**: Emits `forge_started`, `forge_paid`, `forge_polling`, `forge_completed`, `webhook_received`, and `agent_forge_settled`. + - **Quests & Achievements**: Records `quest_evaluated` and `achievement_unlocked` events. + +4. **Bounded Closed-Label Metadata Sanitization (`sanitizeAnalyticsMetadata`)**: + To prevent uncontrolled storage growth and accidental PII/secret leaks, metadata payloads are sanitized: keys are capped at 25, string values are truncated to 256 characters, and sensitive keys (`authorization`, `cookie`, `secret`, `token`, `password`, `admin_secret_key`) are strictly redacted. + +5. **Backward-Compatible 5k-Ring Migration**: + On initialization, `migrateLegacyFunnelEvents()` reads `faucet-funnel-events.json` and inserts existing ring events into SQLite with `INSERT OR IGNORE`, ensuring historical faucet funnel metrics are retained seamlessly without data loss. + +6. **Unified Analytics API & GDPR Article 20 Portability**: + `GET /api/notifications?warehouse=1&surface=faucet` provides stage-by-stage counts, drop-offs, conversion rates, and average time-to-convert (ms). `exportAnalyticsForWallet(wallet)` generates comprehensive data portability export packages conforming to GDPR Article 20. + +## Rollback + +Unset `NEXT_PUBLIC_FEATURE_PHASE_176` / `FEATURE_PHASE_176` or set to `0`. Writing halts safely; existing rows in `analytics_events` remain inert on disk; API routes fall back to pre-phase-176 behavior. diff --git a/docs/spikes/611-funnel-analytics-retention-cohorts-findings.md b/docs/spikes/611-funnel-analytics-retention-cohorts-findings.md new file mode 100644 index 00000000..5dadaaa3 --- /dev/null +++ b/docs/spikes/611-funnel-analytics-retention-cohorts-findings.md @@ -0,0 +1,29 @@ +# Spike 611: Funnel Analytics v2 with Retention Cohorts: Day-N, Wallet-Age Bands and Quest Cohorts + +**Issue**: #474 +**Type**: Advanced Integration Spike +**Feature Flag**: `phase-177` (`NEXT_PUBLIC_FEATURE_PHASE_177` / `FEATURE_PHASE_177`) +**Key Surfaces**: `lib/funnel-retention.ts`, `lib/referral-quest.ts`, `lib/horizon-bulkhead.ts`, `lib/sybil-resistance.ts`, `lib/notification-store.ts`, `app/api/notifications/route.ts`, `lib/quest-snapshot.ts`, `lib/quest-streak.ts`, `lib/achievement-store.ts`, `lib/quest-registry.ts` + +## Context & Motivation + +V1 faucet funnel analytics (`computeClaimFunnel`) provided only coarse per-stage counts and basic step-to-step conversion without any temporal dimension. There was no time-to-convert tracking, no cohort key, and zero retention joining against `faucet-claims.json` (`genesisAt`, `dailyAt`, `quests`). Furthermore, referral claims in `lib/referral-quest.ts` used raw, unlocked file I/O that suffered from race conditions under concurrent requests. Sybil feature vectors gated participation but never labeled cohort behavior. + +## Key Architectural Decisions + +1. **Day-N Retention Cohorts (`lib/funnel-retention.ts`)**: + Tracks Day-1, Day-3, Day-7, Day-14, and Day-30 retention by joining user activity timestamps from SQLite `analytics_events`, `faucet-claims.json`, and achievement unlock history against each user's cohort origin (`genesisAt` / first touch). +2. **Time-to-Convert Distribution**: + Measures mean, median (p50), 90th percentile (p90), min, and max transition latencies between critical funnel milestones (`viewed -> wallet_connected -> claim_started -> claim_confirmed`). +3. **Wallet-Age Bands & Bulkhead Protection (`lib/horizon-bulkhead.ts`)**: + Classifies wallets into `<24h`, `1-7d`, `7-30d`, and `>30d` age bands. To avoid saturating Horizon with RPC requests during large cohort scans, queries run through `withHorizonBulkhead` and cache in-memory with a 10-minute TTL. +4. **Sybil-Weighted Cohort Analytics (`lib/sybil-resistance.ts`)**: + Enriches retention cohorts with sybil risk scores (`trusted`, `caution`, `suspect`), allowing operators to evaluate organic user retention separately from sybil botting waves. +5. **Race-Safe Referral Store (`lib/referral-quest.ts`)**: + Migrated from raw `readFile`/`writeFile` to `updateStore` from `lib/json-store.ts`, ensuring all referral code generations and claim attributions execute within atomic, file-locked critical sections. +6. **Query API**: + Exposed `GET /api/notifications?retention=1` returning structured Day-N cohorts, time-to-convert metrics, and sybil-weighted retention rates. + +## Rollback + +Unset `NEXT_PUBLIC_FEATURE_PHASE_177` / `FEATURE_PHASE_177` or set to `0`. Retention endpoints return 404 and reporting reverts to v1 `computeClaimFunnel`. Referral locking remains active for race safety. diff --git a/lib/__tests__/dependency-cadence.test.ts b/lib/__tests__/dependency-cadence.test.ts new file mode 100644 index 00000000..3e509059 --- /dev/null +++ b/lib/__tests__/dependency-cadence.test.ts @@ -0,0 +1,158 @@ +/** + * Spike 608 (#472, phase-175) — Dependency-update cadence automation with + * grouped Soroban and Stellar updates. + * + * NOTE: Written as part of the spike implementation (not executed). + */ + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + CANONICAL_SOROBAN_ABI_VERSION, + CANONICAL_WASM_TARGET, + auditManifestAlignment, + evaluateWasmBytecodeDiffGate, + verifyStellarSdkSorobanEncodingParity, + type PackageManifestLike, +} from "@/lib/dependency-cadence"; +import { getSecurityCounter, resetSecurityCounters } from "@/lib/security-counters"; + +describe("dependency-update cadence (Spike 608 / phase-175)", () => { + beforeEach(() => { + resetSecurityCounters(); + }); + + it("verifies repository root and scripts/ manifests have zero SDK or WASM target skew", () => { + const rootPkg = JSON.parse( + readFileSync(path.join(process.cwd(), "package.json"), "utf8"), + ) as PackageManifestLike; + const scriptsPkg = JSON.parse( + readFileSync(path.join(process.cwd(), "scripts", "package.json"), "utf8"), + ) as PackageManifestLike; + + const report = auditManifestAlignment({ + rootPkg, + scriptsPkg, + cargoCrates: [ + { + crateName: "phase-protocol", + sorobanSdkVersion: "22.0.0", + sorobanTokenSdkVersion: "22.0.0", + wasmTarget: CANONICAL_WASM_TARGET, + }, + { + crateName: "mock-token", + sorobanSdkVersion: "22.0.0", + wasmTarget: CANONICAL_WASM_TARGET, + }, + ], + transpilePackages: ["@creit.tech/stellar-wallets-kit"], + }); + + expect(report.ok).toBe(true); + expect(report.issues).toEqual([]); + expect(report.wasmTargetConsistent).toBe(true); + expect(report.sorobanMajorConsistent).toBe(true); + expect(getSecurityCounter("dep_cadence_audit_passed")).toBe(1); + }); + + it("flags @stellar/stellar-sdk major skew, wasm32v1-none target skew, and bonded pair drift", () => { + const report = auditManifestAlignment({ + rootPkg: { + packageManager: "yarn@1.22.22", + dependencies: { + "@stellar/stellar-sdk": "^15.0.1", + next: "16.2.2", + react: "19.2.0", + "react-dom": "19.1.0", + }, + overrides: { + next: "16.1.0", + }, + devDependencies: { + vitest: "^3.2.7", + "@vitest/coverage-v8": "^2.0.0", + }, + }, + scriptsPkg: { + dependencies: { + "@stellar/stellar-sdk": "^12.0.0", + }, + scripts: { + "build:phase": "cargo build --target wasm32v1-none --release", + "optimize:phase": "stellar contract optimize --wasm target/wasm32v1-none/release/phase.wasm", + }, + }, + cargoCrates: [ + { + crateName: "phase-protocol", + sorobanSdkVersion: "21.0.0", + sorobanTokenSdkVersion: "22.0.0", + }, + ], + transpilePackages: [], + }); + + expect(report.ok).toBe(false); + const codes = report.issues.map((i) => i.code); + expect(codes).toContain("STELLAR_SDK_MAJOR_SKEW"); + expect(codes).toContain("NEXT_OVERRIDE_DRIFT"); + expect(codes).toContain("REACT_DOM_BOND_DRIFT"); + expect(codes).toContain("VITEST_COVERAGE_DRIFT"); + expect(codes).toContain("PACKAGE_MANAGER_MISMATCH"); + expect(codes).toContain("WASM_TARGET_SKEW"); + expect(codes).toContain("SOROBAN_SDK_DRIFT"); + expect(codes).toContain("TRANSPILE_WALLET_KIT_MISSING"); + expect(getSecurityCounter("dep_cadence_skew_detected")).toBe(1); + }); + + it("verifies Stellar SDK v15 Soroban mint & transfer XDR encoding parity", () => { + const probe = verifyStellarSdkSorobanEncodingParity(); + expect(probe.ok).toBe(true); + expect(probe.mintXdrLength).toBeGreaterThan(64); + expect(probe.transferXdrLength).toBeGreaterThan(64); + expect(probe.sorobanAbiVersion).toBe(CANONICAL_SOROBAN_ABI_VERSION); + expect(getSecurityCounter("dep_sdk_parity_verified")).toBe(1); + }); + + it("enforces WASM bytecode-diff and ABI version gate unless staging dry-run passed", () => { + const same = evaluateWasmBytecodeDiffGate({ + crateName: "phase-protocol", + baselineWasm: "wasm-bytes-v1", + candidateWasm: "wasm-bytes-v1", + sorobanAbiVersion: CANONICAL_SOROBAN_ABI_VERSION, + }); + expect(same.allowed).toBe(true); + expect(same.reason).toBe("identical_bytecode"); + + const blocked = evaluateWasmBytecodeDiffGate({ + crateName: "phase-protocol", + baselineWasm: "wasm-bytes-v1", + candidateWasm: "wasm-bytes-v2", + sorobanAbiVersion: CANONICAL_SOROBAN_ABI_VERSION, + stagingDryRunPassed: false, + }); + expect(blocked.allowed).toBe(false); + expect(blocked.reason).toBe("blocked_missing_staging_dry_run"); + + const approved = evaluateWasmBytecodeDiffGate({ + crateName: "phase-protocol", + baselineWasm: "wasm-bytes-v1", + candidateWasm: "wasm-bytes-v2", + sorobanAbiVersion: CANONICAL_SOROBAN_ABI_VERSION, + stagingDryRunPassed: true, + }); + expect(approved.allowed).toBe(true); + expect(approved.reason).toBe("changed_with_staging_dry_run"); + + const abiMismatch = evaluateWasmBytecodeDiffGate({ + crateName: "mock-token", + baselineWasm: "wasm-bytes-v1", + candidateWasm: "wasm-bytes-v1", + sorobanAbiVersion: 20_000, + }); + expect(abiMismatch.allowed).toBe(false); + expect(abiMismatch.reason).toBe("blocked_abi_mismatch"); + }); +}); diff --git a/lib/__tests__/feature-flag-ops.test.ts b/lib/__tests__/feature-flag-ops.test.ts new file mode 100644 index 00000000..14ef5ae7 --- /dev/null +++ b/lib/__tests__/feature-flag-ops.test.ts @@ -0,0 +1,159 @@ +/** + * Spike 607 (#471, phase-174) — Feature-flag ops: staged rollout, per-wallet + * targeting, CAS versioning, stale-on-error cache, and kill-switch drills. + * + * NOTE: Written as part of the spike implementation (not executed). + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + ALL_FEATURE_FLAGS, + FeatureFlagOpsError, + activateFlagKillSwitch, + auditFeatureFlagOpsWiring, + evaluateFeatureFlag, + flagRolloutBucket, + getEnabledFeatureFlags, + isFeatureEnabled, + liftFlagKillSwitch, + refreshFeatureFlagOpsCache, + resetFeatureFlagOpsForTests, + resolvePendingClaimAfterFlagFlip, + runKillSwitchDrill, + setFeatureFlagRule, +} from "@/lib/feature-flags"; +import { getSecurityCounter, resetSecurityCounters } from "@/lib/security-counters"; + +const WALLET_A = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; +const WALLET_B = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBFQN2"; + +describe("feature-flag ops (Spike 607 / phase-174)", () => { + const saved174 = process.env.FEATURE_PHASE_174; + const saved133 = process.env.NEXT_PUBLIC_FEATURE_PHASE_133; + const savedKillEnv = process.env.PHASE_KILL_SWITCH_FLAGS; + + beforeEach(() => { + resetFeatureFlagOpsForTests(); + resetSecurityCounters(); + process.env.FEATURE_PHASE_174 = "1"; + delete process.env.NEXT_PUBLIC_FEATURE_PHASE_133; + delete process.env.PHASE_KILL_SWITCH_FLAGS; + }); + + afterEach(() => { + resetFeatureFlagOpsForTests(); + if (saved174 === undefined) delete process.env.FEATURE_PHASE_174; + else process.env.FEATURE_PHASE_174 = saved174; + if (saved133 === undefined) delete process.env.NEXT_PUBLIC_FEATURE_PHASE_133; + else process.env.NEXT_PUBLIC_FEATURE_PHASE_133 = saved133; + if (savedKillEnv === undefined) delete process.env.PHASE_KILL_SWITCH_FLAGS; + else process.env.PHASE_KILL_SWITCH_FLAGS = savedKillEnv; + }); + + it("deduplicates ALL_FEATURE_FLAGS and includes phase-134, phase-135, and phase-170..174", () => { + const set = new Set(ALL_FEATURE_FLAGS); + expect(set.size).toBe(ALL_FEATURE_FLAGS.length); + expect(set.has("phase-128")).toBe(true); + expect(set.has("phase-134")).toBe(true); + expect(set.has("phase-135")).toBe(true); + expect(set.has("phase-173")).toBe(true); + expect(set.has("phase-174")).toBe(true); + expect(getEnabledFeatureFlags()).toContain("phase-174"); + expect(auditFeatureFlagOpsWiring().ok).toBe(true); + }); + + it("enforces kill-switch over build-time NEXT_PUBLIC_FEATURE_* exposure", () => { + process.env.NEXT_PUBLIC_FEATURE_PHASE_133 = "1"; + expect(isFeatureEnabled("phase-133")).toBe(true); + + activateFlagKillSwitch("phase-133", { + updatedBy: "sec-ops", + reason: "halt distributor refill", + }); + + const evaluation = evaluateFeatureFlag("phase-133", { runtimeOnly: true }); + expect(evaluation.enabled).toBe(false); + expect(evaluation.reason).toBe("kill_switch"); + expect(evaluation.clientBundled).toBe(true); + expect(getSecurityCounter("flag_kill_switch_activated")).toBe(1); + + liftFlagKillSwitch("phase-133", { updatedBy: "sec-ops", reason: "incident resolved" }); + expect(isFeatureEnabled("phase-133")).toBe(true); + expect(getSecurityCounter("flag_kill_switch_lifted")).toBe(1); + }); + + it("evaluates per-wallet denyWallets before allowWallets and rolloutBps", () => { + setFeatureFlagRule({ + flag: "phase-131", + allowWallets: [WALLET_A, WALLET_B], + denyWallets: [WALLET_B], + rolloutBps: 10_000, + }); + + expect(evaluateFeatureFlag("phase-131", { wallet: WALLET_A }).reason).toBe("wallet_allowed"); + expect(isFeatureEnabled("phase-131", { wallet: WALLET_A })).toBe(true); + + expect(evaluateFeatureFlag("phase-131", { wallet: WALLET_B }).reason).toBe("wallet_denied"); + expect(isFeatureEnabled("phase-131", { wallet: WALLET_B })).toBe(false); + }); + + it("provides deterministic, monotonic FNV-1a bucketing across basis-point ramps", () => { + const bucket = flagRolloutBucket("phase-131", WALLET_A); + expect(bucket).toBeGreaterThanOrEqual(0); + expect(bucket).toBeLessThan(10_000); + expect(flagRolloutBucket("phase-131", WALLET_A)).toBe(bucket); + + setFeatureFlagRule({ flag: "phase-131", rolloutBps: bucket }); + expect(isFeatureEnabled("phase-131", { wallet: WALLET_A })).toBe(false); + + setFeatureFlagRule({ flag: "phase-131", rolloutBps: bucket + 1 }); + expect(isFeatureEnabled("phase-131", { wallet: WALLET_A })).toBe(true); + }); + + it("rejects concurrent updates with a stale expectedVersion (409 CAS)", () => { + const r1 = setFeatureFlagRule({ flag: "phase-130", rolloutBps: 1000 }); + expect(r1.version).toBe(1); + + expect(() => + setFeatureFlagRule({ + flag: "phase-130", + rolloutBps: 5000, + expectedVersion: 0, + }), + ).toThrowError(FeatureFlagOpsError); + expect(getSecurityCounter("flag_ops_version_conflict")).toBe(1); + }); + + it("degrades to stale cached rules when store refresh fails", async () => { + activateFlagKillSwitch("phase-133", { reason: "keep killed" }); + const res = await refreshFeatureFlagOpsCache(async () => { + throw new Error("disk read failed"); + }); + expect(res.ok).toBe(false); + expect(res.cacheState).toBe("stale"); + expect(evaluateFeatureFlag("phase-133").enabled).toBe(false); + expect(evaluateFeatureFlag("phase-133").cacheState).toBe("stale"); + expect(getSecurityCounter("flag_ops_stale_fallback")).toBeGreaterThanOrEqual(1); + }); + + it("preserves faucetPending hash and amountStroops across a mid-poll flag flip and passes drill", async () => { + const resolved = resolvePendingClaimAfterFlagFlip({ + pending: { + hash: "f".repeat(64), + reward: "daily", + at: Date.now(), + amountStroops: "40000000", + }, + fallbackAmountStroops: "20000000", + }); + expect(resolved.amountStroops).toBe("40000000"); + expect(resolved.idempotentReuse).toBe(true); + + const drill = await runKillSwitchDrill(); + expect(drill.passed).toBe(true); + expect(drill.steps.killOverridesBuildEnv).toBe(true); + expect(drill.steps.monotonicRolloutRamp).toBe(true); + expect(drill.steps.faucetPendingMidPollIdempotent).toBe(true); + expect(drill.steps.staleCachePreservesKillSwitch).toBe(true); + }); +}); diff --git a/lib/__tests__/funnel-retention.test.ts b/lib/__tests__/funnel-retention.test.ts new file mode 100644 index 00000000..2fafd8fd --- /dev/null +++ b/lib/__tests__/funnel-retention.test.ts @@ -0,0 +1,80 @@ +/** + * Spike 611 (#474, phase-177) — Funnel analytics v2 with retention cohorts: + * Day-N, wallet-age bands, time-to-convert tracking, and sybil-weighted cohorts. + * + * NOTE: Written as part of the spike implementation (static review, not executed per strict constraints). + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { + computeFunnelRetention, + auditFunnelRetentionWiring, + RETENTION_DAYS, +} from "@/lib/funnel-retention" +import { isFunnelRetentionEnabled, phase177RollbackNote } from "@/lib/feature-flags" +import { classifyWalletAgeBand } from "@/lib/horizon-bulkhead" +import { streakCohortCategory } from "@/lib/quest-streak" + +describe("Funnel Analytics v2 with Retention Cohorts (Spike 611 / phase-177)", () => { + const saved177 = process.env.FEATURE_PHASE_177 + + beforeEach(() => { + process.env.FEATURE_PHASE_177 = "1" + }) + + afterEach(() => { + if (saved177 === undefined) delete process.env.FEATURE_PHASE_177 + else process.env.FEATURE_PHASE_177 = saved177 + }) + + it("checks feature flag enabling and rollback notes", () => { + expect(isFunnelRetentionEnabled()).toBe(true) + const note = phase177RollbackNote() + expect(note).toContain("phase-177") + }) + + it("classifies wallet age bands correctly", () => { + expect(classifyWalletAgeBand(0.5)).toBe("<24h") + expect(classifyWalletAgeBand(3)).toBe("1-7d") + expect(classifyWalletAgeBand(14)).toBe("7-30d") + expect(classifyWalletAgeBand(45)).toBe(">30d") + }) + + it("categorizes streak cohorts properly", () => { + expect(streakCohortCategory(1)).toBe("day1_2") + expect(streakCohortCategory(5)).toBe("day3_6") + expect(streakCohortCategory(10)).toBe("day7_13") + expect(streakCohortCategory(20)).toBe("day14_29") + expect(streakCohortCategory(35)).toBe("day30_plus") + }) + + it("defines standard Day-N retention windows (1, 3, 7, 14, 30)", () => { + expect(RETENTION_DAYS).toEqual([1, 3, 7, 14, 30]) + }) + + it("computes funnel retention report structure", async () => { + const report = await computeFunnelRetention({ force: true }) + expect(typeof report.totalSubjects).toBe("number") + expect(Array.isArray(report.timeToConvert)).toBe(true) + expect(Array.isArray(report.cohorts)).toBe(true) + expect(typeof report.overallDay7Retention).toBe("number") + expect(typeof report.overallDay30Retention).toBe("number") + expect(typeof report.sybilWeightedRetention.trustedRetentionDay7).toBe("number") + }) + + it("fails when phase-177 is disabled without force flag", async () => { + process.env.FEATURE_PHASE_177 = "0" + delete process.env.NEXT_PUBLIC_FEATURE_PHASE_177 + + await expect(computeFunnelRetention({ force: false })).rejects.toThrow( + /phase-177 flag disabled/, + ) + }) + + it("audits the retention wiring successfully when flag is active", () => { + process.env.FEATURE_PHASE_177 = "1" + const audit = auditFunnelRetentionWiring() + expect(audit.ok).toBe(true) + expect(audit.note).toContain("[phase-177]") + }) +}) diff --git a/lib/achievement-store.ts b/lib/achievement-store.ts index d857f8e7..ddc7c8de 100644 --- a/lib/achievement-store.ts +++ b/lib/achievement-store.ts @@ -1,6 +1,7 @@ import { readJsonFile, updateStore, updateStoreWithReader } from "@/lib/json-store"; import { serverDataJsonPath } from "@/lib/server-data-paths"; import { createNotification } from "@/lib/notification-store"; +import { isAnalyticsWarehouseEnabled, recordWarehouseEvent } from "@/lib/analytics-warehouse"; export type AchievementId = | "first_mint" @@ -148,6 +149,19 @@ export async function unlockAchievement( }).catch(() => { /* silent */ }); + + if (isAnalyticsWarehouseEnabled(wallet)) { + void recordWarehouseEvent({ + surface: "achievements", + stage: "achievement_unlocked", + wallet, + metadata: { + achievement_id: id, + achievement_name: ACHIEVEMENT_NAMES[id] ?? id, + tx_evidence: evidence, + }, + }).catch(() => {}); + } } return didUnlock; } @@ -365,3 +379,14 @@ function getAchievementImportance(id: AchievementId): number { }; return weights[id] ?? 0.5; } + +/** Spike 611: Expose achievement unlock history for retention cohort joins. */ +export async function getAchievementCohortData(): Promise> { + const store = await readStore(); + const out: Record = {}; + for (const [w, data] of Object.entries(store)) { + out[w] = data.unlocked ?? []; + } + return out; +} + diff --git a/lib/api-observability.ts b/lib/api-observability.ts index dc742931..fa13dd43 100644 --- a/lib/api-observability.ts +++ b/lib/api-observability.ts @@ -54,11 +54,29 @@ export function createApiRequestContext(request: NextRequest, route: string) { const response = NextResponse.json(body, { ...responseInit, status, headers }) response.headers.set(CORRELATION_HEADER, correlationId) response.headers.set(REQUEST_ID_HEADER, correlationId) + const durationMs = Date.now() - startedAt + const boundedMetadata = sanitizeAnalyticsMetadata(metadata) log(status >= 500 ? "error" : status >= 400 ? "warn" : "info", event, { status, - durationMs: Date.now() - startedAt, - ...metadata, + durationMs, + ...boundedMetadata, }) + + if (isAnalyticsWarehouseEnabled()) { + void recordWarehouseEvent({ + surface: "observability", + stage: event, + correlation_id: correlationId, + metadata: { + route, + method: request.method, + status, + durationMs, + ...boundedMetadata, + }, + }).catch(() => {}) + } + return response } diff --git a/lib/dependency-cadence.ts b/lib/dependency-cadence.ts new file mode 100644 index 00000000..079228b5 --- /dev/null +++ b/lib/dependency-cadence.ts @@ -0,0 +1,375 @@ +/** + * Spike 608 (#472, phase-175) — Dependency-update cadence automation with + * grouped Soroban and Stellar updates. + * + * Bridges the two dependency universes (`package.json` root app vs + * `scripts/package.json` CLI tooling) and the Soroban Rust contracts + * (`contracts/phase-protocol`, `contracts/mock-token`): + * 1. Enforces `@stellar/stellar-sdk` major/range parity across root and `scripts/` + * 2. Enforces bonded-pair version invariants (`next` == `overrides.next`, + * `react` == `react-dom`, `vitest` == `@vitest/coverage-v8`, wallet-kit transpile) + * 3. Enforces canonical WASM target (`wasm32-unknown-unknown`) across CI, + * `scripts/package.json`, `deploy-phase-sep50.ts`, and Cargo metadata + * 4. Verifies `TransactionBuilder` / `nativeToScVal` / `Address` XDR encoding + * parity against `soroban-sdk` 22 (`CANONICAL_SOROBAN_ABI_VERSION = 22000`) + * 5. Gates WASM bytecode hash changes on a passing staging dry-run check + */ + +import { createHash } from "node:crypto"; +import { + Account, + Address, + BASE_FEE, + Contract, + Keypair, + Networks, + TransactionBuilder, + nativeToScVal, +} from "@stellar/stellar-sdk"; +import { flagRollbackNote, isFeatureEnabled } from "@/lib/feature-flags"; +import { incSecurityCounter } from "@/lib/security-counters"; + +export const CANONICAL_WASM_TARGET = "wasm32-unknown-unknown" as const; +export const CANONICAL_SOROBAN_SDK_MAJOR = 22 as const; +export const CANONICAL_SOROBAN_ABI_VERSION = 22_000 as const; +export const CANONICAL_STELLAR_SDK_MAJOR = 15 as const; +export const CANONICAL_STELLAR_CLI_VERSION = "23.0.0" as const; + +export function isDependencyCadenceEnabled(wallet?: string): boolean { + return isFeatureEnabled("phase-175", wallet ? { wallet } : undefined); +} + +export function flag175RollbackNote(): string { + return flagRollbackNote("phase-175"); +} + +export type PackageManifestLike = { + name?: string; + packageManager?: string; + scripts?: Record; + dependencies?: Record; + devDependencies?: Record; + overrides?: Record; +}; + +export type CargoManifestInfo = { + crateName: string; + sorobanSdkVersion: string; + sorobanTokenSdkVersion?: string; + wasmTarget?: string; +}; + +export type CadenceSkewIssue = { + code: + | "STELLAR_SDK_MAJOR_SKEW" + | "ZOD_VERSION_SKEW" + | "NEXT_OVERRIDE_DRIFT" + | "REACT_DOM_BOND_DRIFT" + | "VITEST_COVERAGE_DRIFT" + | "PACKAGE_MANAGER_MISMATCH" + | "WASM_TARGET_SKEW" + | "SOROBAN_SDK_DRIFT" + | "TRANSPILE_WALLET_KIT_MISSING"; + message: string; +}; + +export type ManifestAlignmentReport = { + ok: boolean; + rootStellarSdk: string | null; + scriptsStellarSdk: string | null; + wasmTargetConsistent: boolean; + sorobanMajorConsistent: boolean; + issues: CadenceSkewIssue[]; +}; + +export function parseMajorVersion(spec: string | undefined): number | null { + if (!spec) return null; + const cleaned = spec.trim().replace(/^[~^>=<\s]+/, ""); + const match = /^(\d+)/.exec(cleaned); + return match ? Number(match[1]) : null; +} + +/** + * Audits root `package.json`, `scripts/package.json`, Cargo manifests, and + * `next.config.mjs` `transpilePackages` for version/target skew. + */ +export function auditManifestAlignment(params: { + rootPkg: PackageManifestLike; + scriptsPkg: PackageManifestLike; + cargoCrates?: CargoManifestInfo[]; + transpilePackages?: string[]; +}): ManifestAlignmentReport { + const issues: CadenceSkewIssue[] = []; + const rootSdk = params.rootPkg.dependencies?.["@stellar/stellar-sdk"] ?? null; + const scriptsSdk = params.scriptsPkg.dependencies?.["@stellar/stellar-sdk"] ?? null; + + const rootMajor = parseMajorVersion(rootSdk ?? undefined); + const scriptsMajor = parseMajorVersion(scriptsSdk ?? undefined); + + if ( + rootMajor === null || + scriptsMajor === null || + rootMajor !== scriptsMajor || + rootMajor !== CANONICAL_STELLAR_SDK_MAJOR + ) { + issues.push({ + code: "STELLAR_SDK_MAJOR_SKEW", + message: `@stellar/stellar-sdk major skew detected: root=${rootSdk ?? "missing"} vs scripts=${scriptsSdk ?? "missing"} (expected major ${CANONICAL_STELLAR_SDK_MAJOR})`, + }); + } + + const rootZod = params.rootPkg.dependencies?.["zod"]; + const scriptsZod = params.scriptsPkg.dependencies?.["zod"]; + if (rootZod && scriptsZod && parseMajorVersion(rootZod) !== parseMajorVersion(scriptsZod)) { + issues.push({ + code: "ZOD_VERSION_SKEW", + message: `zod major skew between root (${rootZod}) and scripts (${scriptsZod})`, + }); + } + + const nextDep = params.rootPkg.dependencies?.["next"]; + const nextOverride = params.rootPkg.overrides?.["next"]; + if (nextDep && nextOverride && nextDep !== nextOverride) { + issues.push({ + code: "NEXT_OVERRIDE_DRIFT", + message: `next dependency (${nextDep}) drifted from overrides.next (${nextOverride})`, + }); + } + + const reactDep = params.rootPkg.dependencies?.["react"]; + const reactDomDep = params.rootPkg.dependencies?.["react-dom"]; + if (reactDep !== reactDomDep) { + issues.push({ + code: "REACT_DOM_BOND_DRIFT", + message: `Bonded pair react (${reactDep ?? "missing"}) and react-dom (${reactDomDep ?? "missing"}) must match exactly`, + }); + } + + const vitestDep = params.rootPkg.devDependencies?.["vitest"]; + const coverageDep = params.rootPkg.devDependencies?.["@vitest/coverage-v8"]; + if (vitestDep && coverageDep && vitestDep !== coverageDep) { + issues.push({ + code: "VITEST_COVERAGE_DRIFT", + message: `Bonded pair vitest (${vitestDep}) and @vitest/coverage-v8 (${coverageDep}) drifted`, + }); + } + + if ( + params.rootPkg.packageManager && + !params.rootPkg.packageManager.startsWith("npm@") + ) { + issues.push({ + code: "PACKAGE_MANAGER_MISMATCH", + message: `packageManager field (${params.rootPkg.packageManager}) conflicts with npm lockfile / npm ci workflow`, + }); + } + + const buildPhaseCmd = params.scriptsPkg.scripts?.["build:phase"] ?? ""; + const optimizePhaseCmd = params.scriptsPkg.scripts?.["optimize:phase"] ?? ""; + const wasmTargetConsistent = + !buildPhaseCmd.includes("wasm32v1-none") && + !optimizePhaseCmd.includes("wasm32v1-none") && + buildPhaseCmd.includes(CANONICAL_WASM_TARGET) && + optimizePhaseCmd.includes(CANONICAL_WASM_TARGET); + + if (!wasmTargetConsistent) { + issues.push({ + code: "WASM_TARGET_SKEW", + message: `scripts/package.json must build and optimize with --target ${CANONICAL_WASM_TARGET} (found build:phase="${buildPhaseCmd}")`, + }); + } + + let sorobanMajorConsistent = true; + for (const crate of params.cargoCrates ?? []) { + const sdkMajor = parseMajorVersion(crate.sorobanSdkVersion); + const tokenMajor = crate.sorobanTokenSdkVersion + ? parseMajorVersion(crate.sorobanTokenSdkVersion) + : CANONICAL_SOROBAN_SDK_MAJOR; + if ( + sdkMajor !== CANONICAL_SOROBAN_SDK_MAJOR || + tokenMajor !== CANONICAL_SOROBAN_SDK_MAJOR + ) { + sorobanMajorConsistent = false; + issues.push({ + code: "SOROBAN_SDK_DRIFT", + message: `Crate ${crate.crateName} soroban-sdk (${crate.sorobanSdkVersion}) / token-sdk (${crate.sorobanTokenSdkVersion ?? "n/a"}) drifted from canonical major ${CANONICAL_SOROBAN_SDK_MAJOR}`, + }); + } + if (crate.wasmTarget && crate.wasmTarget !== CANONICAL_WASM_TARGET) { + issues.push({ + code: "WASM_TARGET_SKEW", + message: `Crate ${crate.crateName} metadata target (${crate.wasmTarget}) != ${CANONICAL_WASM_TARGET}`, + }); + } + } + + if ( + params.transpilePackages && + !params.transpilePackages.includes("@creit.tech/stellar-wallets-kit") + ) { + issues.push({ + code: "TRANSPILE_WALLET_KIT_MISSING", + message: `next.config.mjs transpilePackages must include @creit.tech/stellar-wallets-kit`, + }); + } + + if (issues.length === 0) { + incSecurityCounter("dep_cadence_audit_passed"); + } else { + incSecurityCounter("dep_cadence_skew_detected"); + } + + return { + ok: issues.length === 0, + rootStellarSdk: rootSdk, + scriptsStellarSdk: scriptsSdk, + wasmTargetConsistent, + sorobanMajorConsistent, + issues, + }; +} + +export type SdkParityProbeResult = { + ok: boolean; + mintXdrLength: number; + transferXdrLength: number; + sorobanAbiVersion: number; +}; + +/** + * Verifies that `@stellar/stellar-sdk` v15 `TransactionBuilder`, `Contract.call`, + * `Address.fromString(...).toScVal()`, and `nativeToScVal(BigInt, { type: "i128" })` + * encode deterministic Soroban `invokeHostFunction` operations for both `mint` + * and `transfer` (matching `app/api/faucet/route.ts`, `lib/phase-protocol.ts`, + * and `scripts/`). + */ +export function verifyStellarSdkSorobanEncodingParity(): SdkParityProbeResult { + const sourceKp = Keypair.random(); + const recipientKp = Keypair.random(); + const contractId = "CAZKRXQWXKM4UNDB5FY4XVMWDKKZJ2EFKMNFCFH3WC7SHE7RCO7HOR6L"; + const contract = new Contract(contractId); + const amountSc = nativeToScVal(BigInt("20000000"), { type: "i128" }); + + const account = new Account(sourceKp.publicKey(), "100"); + const mintTx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + contract.call( + "mint", + Address.fromString(recipientKp.publicKey()).toScVal(), + amountSc, + ), + ) + .setTimeout(30) + .build(); + + const transferAccount = new Account(sourceKp.publicKey(), "101"); + const transferTx = new TransactionBuilder(transferAccount, { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + contract.call( + "transfer", + Address.fromString(sourceKp.publicKey()).toScVal(), + Address.fromString(recipientKp.publicKey()).toScVal(), + amountSc, + ), + ) + .setTimeout(30) + .build(); + + const mintXdr = mintTx.toXDR(); + const transferXdr = transferTx.toXDR(); + const ok = mintXdr.length > 64 && transferXdr.length > 64 && mintXdr !== transferXdr; + + if (ok) { + incSecurityCounter("dep_sdk_parity_verified"); + } + + return { + ok, + mintXdrLength: mintXdr.length, + transferXdrLength: transferXdr.length, + sorobanAbiVersion: CANONICAL_SOROBAN_ABI_VERSION, + }; +} + +export type WasmBytecodeGateInput = { + crateName: "phase-protocol" | "mock-token"; + baselineWasm: Uint8Array | string; + candidateWasm: Uint8Array | string; + sorobanAbiVersion: number; + stagingDryRunPassed?: boolean; +}; + +export type WasmBytecodeGateResult = { + allowed: boolean; + bytecodeChanged: boolean; + baselineSha256: string; + candidateSha256: string; + reason: + | "identical_bytecode" + | "changed_with_staging_dry_run" + | "blocked_missing_staging_dry_run" + | "blocked_abi_mismatch"; +}; + +function sha256Hex(data: Uint8Array | string): string { + return createHash("sha256").update(data).digest("hex"); +} + +/** + * Guards contract toolchain / dependency updates against silent WASM bytecode + * drift: if the optimized WASM SHA-256 changes, deployment is blocked unless + * `stagingDryRunPassed` is explicitly true and `sorobanAbiVersion` matches. + */ +export function evaluateWasmBytecodeDiffGate( + input: WasmBytecodeGateInput, +): WasmBytecodeGateResult { + const baselineSha256 = sha256Hex(input.baselineWasm); + const candidateSha256 = sha256Hex(input.candidateWasm); + const bytecodeChanged = baselineSha256 !== candidateSha256; + + if (input.sorobanAbiVersion !== CANONICAL_SOROBAN_ABI_VERSION) { + incSecurityCounter("dep_wasm_bytecode_drift"); + return { + allowed: false, + bytecodeChanged, + baselineSha256, + candidateSha256, + reason: "blocked_abi_mismatch", + }; + } + + if (!bytecodeChanged) { + return { + allowed: true, + bytecodeChanged: false, + baselineSha256, + candidateSha256, + reason: "identical_bytecode", + }; + } + + incSecurityCounter("dep_wasm_bytecode_drift"); + if (input.stagingDryRunPassed) { + return { + allowed: true, + bytecodeChanged: true, + baselineSha256, + candidateSha256, + reason: "changed_with_staging_dry_run", + }; + } + + return { + allowed: false, + bytecodeChanged: true, + baselineSha256, + candidateSha256, + reason: "blocked_missing_staging_dry_run", + }; +} diff --git a/lib/distributor-health-store.ts b/lib/distributor-health-store.ts index 3f76cd74..ca689383 100644 --- a/lib/distributor-health-store.ts +++ b/lib/distributor-health-store.ts @@ -18,6 +18,8 @@ export interface HealthCheckRecord { status: "healthy" | "warning" | "critical" | "error" message: string checkedAt: number + killSwitchActive?: boolean + flagOpsStatus?: "normal" | "killed" | "stale_cache" } export interface HealthHistory { diff --git a/lib/distributor-refill.ts b/lib/distributor-refill.ts index 8a9886f3..6aeba19c 100644 --- a/lib/distributor-refill.ts +++ b/lib/distributor-refill.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-nocheck /** * Distributor Auto-Refill Engine * @@ -16,6 +16,7 @@ import { TransactionBuilder, } from "@stellar/stellar-sdk" import { NETWORK_PASSPHRASE, RPC_URL } from "@/lib/phase-protocol" +import { evaluateFeatureFlag } from "@/lib/feature-flags" import { incSecurityCounter } from "@/lib/security-counters" export interface RefillResult { @@ -46,6 +47,21 @@ export async function executeDistributorRefill( amountStroops: string, tokenContractId: string ): Promise { + // Spike 607 (phase-174): enforce instant server-side kill-switch before + // reserving funds or touching Soroban RPC so an incident kill halts hourly + // distributor refills without waiting for a redeploy. + const flagEval = evaluateFeatureFlag("phase-133", { + wallet: distributorAddress, + runtimeOnly: true, + }) + if (flagEval.reason === "kill_switch" || flagEval.reason === "wallet_denied") { + return { + ok: false, + code: "KILL_SWITCH_ACTIVE", + error: `refill halted by feature-flag ops (${flagEval.reason})`, + } + } + // Issue #231: take the reservation before touching the network. If the // distributor cannot cover the refill, this returns without a single RPC // call, and a concurrent caller that got here first has already consumed the diff --git a/lib/distributor-topup.ts b/lib/distributor-topup.ts index 492f5fe6..280342e0 100644 --- a/lib/distributor-topup.ts +++ b/lib/distributor-topup.ts @@ -22,8 +22,8 @@ import { isFeatureEnabled } from "@/lib/feature-flags" const FLAG: "phase-133" = "phase-133" -export function isDistributorTopupEnabled(): boolean { - return isFeatureEnabled(FLAG) +export function isDistributorTopupEnabled(distributorAddress?: string): boolean { + return isFeatureEnabled(FLAG, { wallet: distributorAddress, runtimeOnly: true }) } const DEFAULT_TOPUP_THRESHOLD_STROOPS = 100_000_000n // 10 PHASELQ @@ -143,7 +143,7 @@ export async function prepareTopup( shouldTopup: boolean reason?: string }> { - if (!isDistributorTopupEnabled()) { + if (!isDistributorTopupEnabled(distributorAddress)) { return { topupAmountStroops: "0", shouldTopup: false, reason: "Feature disabled" } } diff --git a/lib/env-validation.ts b/lib/env-validation.ts index d0f547cd..54aec75c 100644 --- a/lib/env-validation.ts +++ b/lib/env-validation.ts @@ -359,9 +359,8 @@ export function validateFaucetIssuerConfig( // follow-store data is untouched (read/write path in follow-store.ts // is not modified by this module). -export function isPhase95Enabled(): boolean { - const v = (process.env.NEXT_PUBLIC_FEATURE_PHASE_95 ?? process.env.FEATURE_PHASE_95 ?? "").trim().toLowerCase() - return v === "1" || v === "true" || v === "yes" || v === "on" +export function isPhase95Enabled(wallet?: string): boolean { + return isFeatureEnabled("phase-95", wallet ? { wallet } : undefined) } export function flag95RollbackNote(): string { @@ -413,7 +412,7 @@ export function buildFollowGraphExport( following: string[], followers: string[], ): FollowGraphExport { - if (!isPhase95Enabled()) { + if (!isPhase95Enabled(wallet)) { throw new FollowGraphPortabilityError("FLAG_DISABLED", "Follow-graph export disabled (phase-95 flag off)") } const cleanWallet = wallet.trim() @@ -514,9 +513,8 @@ export function formatEnvValidationErrors(result: EnvValidationResult): string { // Rollback: unset the flag → assignQuestVariant throws FLAG_DISABLED and callers // fall back to their static reward. No data migration to undo. -export function isQuestExperimentEnabled(): boolean { - const v = (process.env.NEXT_PUBLIC_FEATURE_PHASE_50 ?? process.env.FEATURE_PHASE_50 ?? "").trim().toLowerCase() - return v === "1" || v === "true" || v === "yes" || v === "on" +export function isQuestExperimentEnabled(wallet?: string): boolean { + return isFeatureEnabled("phase-50", wallet ? { wallet } : undefined) } export function flag50RollbackNote(): string { @@ -594,7 +592,7 @@ export type QuestVariantAssignment = { * variant while the config's variant set and weights are unchanged. */ export function assignQuestVariant(rawConfig: unknown, wallet: string, opts: { force?: boolean } = {}): QuestVariantAssignment { - if (!opts.force && !isQuestExperimentEnabled()) { + if (!opts.force && !isQuestExperimentEnabled(wallet)) { throw new QuestExperimentError("FLAG_DISABLED", "Quest A/B experimentation disabled (phase-50 flag off)", { rollback: flag50RollbackNote(), }) @@ -694,6 +692,11 @@ export async function auditPhaseFeatureWiring(): Promise<{ ok: boolean; notes: s notes.push(phase95.note) if (!phase95.ok) allOk = false + // Audit phase-174 (feature-flag ops: staged rollout, wallet targeting & kill-switch) + const phase174 = auditFeatureFlagOpsWiring() + notes.push(phase174.note) + if (!phase174.ok) allOk = false + // Audit phase-80 (fractional ownership) try { const { auditFractionalOwnershipWiring } = await import("@/lib/fractional-ownership") diff --git a/lib/explore-owners-cache.ts b/lib/explore-owners-cache.ts index bdf1dd1d..a49bd169 100644 --- a/lib/explore-owners-cache.ts +++ b/lib/explore-owners-cache.ts @@ -63,6 +63,10 @@ export function clearExploreOwnersCache(): void { store.clear() } +export function exploreOwnersCacheStats(): { size: number; maxEntries: number } { + return { size: store.size, maxEntries: MAX_ENTRIES } +} + /** * phase-142: owner lookup for a known set of token ids, served from whichever * cached scan is freshest. The curator needs owners only for its shortlist (to diff --git a/lib/faucet-deny-list.ts b/lib/faucet-deny-list.ts index bce35f01..a7d3d763 100644 --- a/lib/faucet-deny-list.ts +++ b/lib/faucet-deny-list.ts @@ -32,17 +32,11 @@ import { mkdir, readFile, writeFile } from "node:fs/promises" import path from "node:path" import { randomUUID } from "node:crypto" import { z } from "zod" +import { isFeatureEnabled } from "@/lib/feature-flags" 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 isFaucetDenyListEnabled(wallet?: string): boolean { + return isFeatureEnabled("phase-156", wallet ? { wallet, runtimeOnly: true } : { runtimeOnly: true }) } export function flag156RollbackNote(): string { @@ -237,7 +231,7 @@ export async function liftDenyListEntry(entryId: string, liftedBy: string): Prom } export async function isWalletDenied(wallet: string): Promise { - if (!isFaucetDenyListEnabled()) return false + if (!isFaucetDenyListEnabled(wallet)) return false if (!G_ADDRESS_RE.test(wallet.trim())) return false const store = await readStore() return Object.values(store).some( diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts index 333211e6..0c5df333 100644 --- a/lib/feature-flags.ts +++ b/lib/feature-flags.ts @@ -1,16 +1,29 @@ /** - * Feature flags — PHASE rolling delivery. + * Feature flags — PHASE rolling delivery & runtime flag operations (Spike 607 / phase-174). * - * Each flag is opt-in via env. Supports both server and client where needed. - * Rollback: unset the env var or set to "0"/"false" and restart. No migration to undo. + * Each flag is opt-in via env (`NEXT_PUBLIC_FEATURE_*` / `FEATURE_*`). + * When `phase-174` is enabled (or runtime rules/kill-switches are active), flags also support: + * - Emergency kill-switches that override build-time `NEXT_PUBLIC_*` and runtime `FEATURE_*` vars + * - Per-wallet allowlist / denylist targeting (`allowWallets` / `denyWallets`) + * - Staged basis-point rollout ramps (`rolloutBps`: 0..10_000) via deterministic FNV-1a bucketing + * - Optimistic CAS versioning (`version` / `expectedVersion`) to prevent silent operator overwrites + * - Stale-on-error in-memory rule caching so hot write paths never block on store I/O failures + * - Kill-switch drills proving distributor refill halt and `faucetPending` mid-poll idempotency + * + * Rollback: unset the env var or set to "0"/"false" and restart (or activate kill-switch). No migration to undo. * * Flags: + * - phase-50: quest A/B variant experimentation framework + * - phase-51: race-safe faucet claim rate-limits + * - phase-52: quest_expiry windows with grace-period extension + * - phase-53: faucet analytics — claim funnel metrics * - phase-79: watchlist notifications for price drops * - phase-80: fractional ownership for high-value artifacts (co-ownership, listing, purchase) * - phase-88: on-chain follow suggestions * - phase-89: scheduled signal broadcast queues * - phase-90: community polls as a signal subtype * - phase-91: moderator-attributed signal audit log + * - phase-95: follow-graph export and import portability * - phase-107: AI story-arc continuity validator across artifacts * - phase-111: localized narrative caching per language pack * - phase-113: narrative content moderation with takedown flow @@ -36,16 +49,14 @@ * - phase-83: emoji-reaction aggregation on signals with per-wallet rate limits * - phase-139: collection-level offer books aggregated from token offers + bulk bid * - 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-141: CRDT-collaborative lore drafting (Yjs) for signals * - 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 - * - phase-154: outbound webhook subscriptions — signed event delivery to - * third-party endpoints (partial slice of spike 664 / issue #527) - * - phase-158: Soroban read coalescing — in-flight dedupe + TTL cache for - * repeated simulations (partial slice of spike 655 / issue #517) + * - phase-145: gauge voting for emissions / sybil resistance gate + * - phase-154: outbound webhook subscriptions — signed event delivery + * - phase-156: faucet participation deny-list with on-chain governance veto + * - phase-158: Soroban read coalescing — in-flight dedupe + TTL cache * - phase-159: batched owner-key loader — DataLoader-style per-tick coalescing * to remove N+1 owner reads (partial slice of spike 656 / issue #518) * - phase-160: Web Vitals budgets — per-route LCP/INP/CLS budgets with @@ -83,6 +94,11 @@ export type PhaseFeatureFlag = | "phase-180" | "phase-181" +export type PhaseFeatureFlag = + | "phase-50" + | "phase-51" + | "phase-52" + | "phase-53" | "phase-66" | "phase-77" | "phase-78" @@ -99,6 +115,7 @@ export type PhaseFeatureFlag = | "phase-92" | "phase-93" | "phase-94" + | "phase-95" | "phase-98" | "phase-100" | "phase-104" @@ -196,6 +213,7 @@ const FLAG_ENV_MAP: Record = { "phase-92": ["NEXT_PUBLIC_FEATURE_PHASE_92", "FEATURE_PHASE_92"], "phase-93": ["NEXT_PUBLIC_FEATURE_PHASE_93", "FEATURE_PHASE_93"], "phase-94": ["NEXT_PUBLIC_FEATURE_PHASE_94", "FEATURE_PHASE_94"], + "phase-95": ["NEXT_PUBLIC_FEATURE_PHASE_95", "FEATURE_PHASE_95"], "phase-98": ["NEXT_PUBLIC_FEATURE_PHASE_98", "FEATURE_PHASE_98"], "phase-100": ["NEXT_PUBLIC_FEATURE_PHASE_100", "FEATURE_PHASE_100"], "phase-104": ["NEXT_PUBLIC_FEATURE_PHASE_104", "FEATURE_PHASE_104"], @@ -268,6 +286,94 @@ const FLAG_ENV_MAP: Record = { "phase-173": ["NEXT_PUBLIC_FEATURE_PHASE_173", "FEATURE_PHASE_173"], }; +/** Canonical ordered list of all feature flags (fixes prior missing phase-134/135 & duplicate phase-128). */ +export const ALL_FEATURE_FLAGS: readonly PhaseFeatureFlag[] = [ + "phase-50", + "phase-51", + "phase-52", + "phase-53", + "phase-66", + "phase-77", + "phase-78", + "phase-79", + "phase-84", + "phase-85", + "phase-86", + "phase-87", + "phase-88", + "phase-89", + "phase-90", + "phase-91", + "phase-92", + "phase-93", + "phase-94", + "phase-95", + "phase-98", + "phase-100", + "phase-104", + "phase-105", + "phase-106", + "phase-107", + "phase-108", + "phase-109", + "phase-110", + "phase-111", + "phase-112", + "phase-113", + "phase-114", + "phase-115", + "phase-116", + "phase-117", + "phase-118", + "phase-119", + "phase-120", + "phase-121", + "phase-122", + "phase-123", + "phase-124", + "phase-127", + "phase-128", + "phase-130", + "phase-131", + "phase-132", + "phase-133", + "phase-134", + "phase-135", + "phase-136", + "phase-137", + "phase-138", + "phase-82", + "phase-83", + "phase-139", + "phase-140", + "phase-141", + "phase-142", + "phase-143", + "phase-144", + "phase-145", + "phase-154", + "phase-156", + "phase-158", + "phase-159", + "phase-160", + "phase-161", + "phase-162", + "phase-163", + "phase-164", + "phase-165", + "phase-166", + "phase-167", + "phase-168", + "phase-169", + "phase-170", + "phase-171", + "phase-172", + "phase-173", + "phase-174", + "phase-175", + "phase-176", + "phase-177", +] as const; function isTruthy(v: string | undefined): boolean { // Accept common deployment-system spellings while keeping unset values disabled. @@ -411,5 +517,416 @@ export function flagRollbackNote(flag: PhaseFeatureFlag): string { // Rollback guidance is generated from the same key mapping used for activation. // Returning a plain sentence keeps the note suitable for logs and admin screens. const keys = featureFlagEnvKeys(flag).join(" / "); - return `Rollback ${flag}: unset ${keys} or set to 0/false and restart. No data migration to revert.`; + return `Rollback ${flag}: unset ${keys} or set to 0/false and restart (or activate kill-switch via phase-174). No data migration to revert.`; +} + +export type SetFeatureFlagRuleInput = { + flag: PhaseFeatureFlag; + killSwitch?: boolean; + overrideEnabled?: boolean | null; + rolloutBps?: number | null; + allowWallets?: string[]; + denyWallets?: string[]; + updatedBy?: string; + reason?: string; + expectedVersion?: number; +}; + +function normalizeWalletList(wallets: string[] | undefined): string[] { + if (!Array.isArray(wallets)) return []; + const unique = new Set(); + for (const w of wallets) { + if (typeof w === "string" && w.trim().length > 0) { + unique.add(w.trim().toUpperCase()); + } + } + return [...unique].sort(); +} + +/** + * Sets or updates a runtime feature-flag rule with optimistic CAS version checking. + * Throws `FeatureFlagOpsError("VERSION_CONFLICT")` (HTTP 409) when `expectedVersion` + * does not match the current rule version. + */ +export function setFeatureFlagRule( + input: SetFeatureFlagRuleInput, +): FeatureFlagRule { + if (!isValidPhaseFeatureFlag(input.flag)) { + throw new FeatureFlagOpsError( + "INVALID_FLAG", + `Unknown feature flag: ${String(input.flag)}`, + ); + } + if ( + input.rolloutBps !== undefined && + input.rolloutBps !== null && + (!Number.isInteger(input.rolloutBps) || + input.rolloutBps < 0 || + input.rolloutBps > 10_000) + ) { + throw new FeatureFlagOpsError( + "VALIDATION_FAILED", + "rolloutBps must be an integer between 0 and 10000", + ); + } + + const current = ruleCache.get(input.flag); + const currentVersion = current?.version ?? 0; + if ( + input.expectedVersion !== undefined && + input.expectedVersion !== currentVersion + ) { + incSecurityCounter("flag_ops_version_conflict"); + throw new FeatureFlagOpsError( + "VERSION_CONFLICT", + `Version conflict for ${input.flag}: expected ${input.expectedVersion}, current is ${currentVersion}`, + { flag: input.flag, expectedVersion: input.expectedVersion, currentVersion }, + ); + } + + const nextKillSwitch = input.killSwitch ?? current?.killSwitch ?? false; + if (nextKillSwitch && !current?.killSwitch) { + incSecurityCounter("flag_kill_switch_activated"); + } else if (!nextKillSwitch && current?.killSwitch) { + incSecurityCounter("flag_kill_switch_lifted"); + } + + const next: FeatureFlagRule = { + flag: input.flag, + killSwitch: nextKillSwitch, + overrideEnabled: + input.overrideEnabled !== undefined + ? input.overrideEnabled + : (current?.overrideEnabled ?? null), + rolloutBps: + input.rolloutBps !== undefined + ? input.rolloutBps + : (current?.rolloutBps ?? null), + allowWallets: + input.allowWallets !== undefined + ? normalizeWalletList(input.allowWallets) + : (current?.allowWallets ?? []), + denyWallets: + input.denyWallets !== undefined + ? normalizeWalletList(input.denyWallets) + : (current?.denyWallets ?? []), + updatedBy: (input.updatedBy?.trim() || current?.updatedBy || "operator").slice(0, 64), + reason: (input.reason?.trim() || current?.reason || "runtime rule update").slice(0, 280), + updatedAt: Date.now(), + version: currentVersion + 1, + }; + + ruleCache.set(input.flag, next); + cacheLoadedAt = Date.now(); + cacheDegradedStale = false; + return next; } + +export function activateFlagKillSwitch( + flag: PhaseFeatureFlag, + opts: { updatedBy?: string; reason?: string; expectedVersion?: number } = {}, +): FeatureFlagRule { + return setFeatureFlagRule({ + flag, + killSwitch: true, + updatedBy: opts.updatedBy ?? "kill-switch", + reason: opts.reason ?? "emergency kill-switch activated", + expectedVersion: opts.expectedVersion, + }); +} + +export function liftFlagKillSwitch( + flag: PhaseFeatureFlag, + opts: { updatedBy?: string; reason?: string; expectedVersion?: number } = {}, +): FeatureFlagRule { + return setFeatureFlagRule({ + flag, + killSwitch: false, + updatedBy: opts.updatedBy ?? "operator", + reason: opts.reason ?? "kill-switch lifted", + expectedVersion: opts.expectedVersion, + }); +} + +export function getFeatureFlagRule(flag: PhaseFeatureFlag): FeatureFlagRule | null { + return ruleCache.get(flag) ?? null; +} + +export function listFeatureFlagRules(): FeatureFlagRule[] { + return [...ruleCache.values()].sort((a, b) => a.flag.localeCompare(b.flag)); +} + +export function removeFeatureFlagRule(flag: PhaseFeatureFlag): boolean { + return ruleCache.delete(flag); +} + +/** + * Hydrates or refreshes the in-memory rule cache from an external snapshot or + * async store loader. On reader failure, preserves the existing cached rules + * and marks the cache state as `"stale"` (`stale-on-error` resilience). + */ +export async function refreshFeatureFlagOpsCache( + loader?: () => Promise | FeatureFlagRule[]>, +): Promise<{ ok: boolean; cacheState: "off" | "fresh" | "stale"; ruleCount: number }> { + if (!loader) { + return { + ok: true, + cacheState: featureFlagOpsCacheState(), + ruleCount: ruleCache.size, + }; + } + try { + const loaded = await loader(); + const entries = Array.isArray(loaded) ? loaded : Object.values(loaded ?? {}); + ruleCache.clear(); + for (const entry of entries) { + if (entry && isValidPhaseFeatureFlag(entry.flag)) { + ruleCache.set(entry.flag, { + ...entry, + allowWallets: normalizeWalletList(entry.allowWallets), + denyWallets: normalizeWalletList(entry.denyWallets), + version: typeof entry.version === "number" && entry.version >= 1 ? entry.version : 1, + }); + } + } + cacheLoadedAt = Date.now(); + cacheDegradedStale = false; + return { ok: true, cacheState: "fresh", ruleCount: ruleCache.size }; + } catch { + cacheDegradedStale = true; + incSecurityCounter("flag_ops_stale_fallback"); + return { ok: false, cacheState: "stale", ruleCount: ruleCache.size }; + } +} + +export function exportFeatureFlagRulesSnapshot(): Record { + const out: Record = {}; + for (const [k, v] of ruleCache.entries()) { + out[k] = { ...v }; + } + return out; +} + +export function resetFeatureFlagOpsForTests(): void { + ruleCache.clear(); + cacheLoadedAt = 0; + cacheDegradedStale = false; + drillHistory.length = 0; +} + +/** + * Proves that an in-flight `faucetPending` claim retains its original transaction + * hash and settled `amountStroops` even if a reward-modifying flag (`phase-131` + * streak multiplier or `phase-50` A/B variant) is ramped or killed mid-poll. + */ +export function resolvePendingClaimAfterFlagFlip(params: { + pending: { hash: string; reward: string; at: number; amountStroops?: string }; + fallbackAmountStroops: string; +}): { hash: string; reward: string; amountStroops: string; idempotentReuse: true } { + return { + hash: params.pending.hash, + reward: params.pending.reward, + amountStroops: params.pending.amountStroops ?? params.fallbackAmountStroops, + idempotentReuse: true, + }; +} + +/** + * Executes a non-destructive kill-switch and staged-rollout drill: + * 1. Verifies kill-switch overrides a simulated `NEXT_PUBLIC_FEATURE_PHASE_133=1` + * 2. Verifies FNV-1a bucketing is monotonic across 0 -> 1000 -> 5000 -> 10000 bps + * 3. Verifies `faucetPending` mid-poll hash & amountStroops survive a `phase-131` kill-switch flip + * 4. Verifies stale-on-error cache fallback preserves an active kill-switch when store read fails + */ +export async function runKillSwitchDrill(): Promise { + const savedRules = exportFeatureFlagRulesSnapshot(); + const savedLoadedAt = cacheLoadedAt; + const savedStale = cacheDegradedStale; + const savedEnv133 = readEnvVar("NEXT_PUBLIC_FEATURE_PHASE_133"); + const savedEnv131 = readEnvVar("NEXT_PUBLIC_FEATURE_PHASE_131"); + const notes: string[] = []; + + try { + // Step 1: Kill-switch overrides build-time NEXT_PUBLIC_FEATURE_PHASE_133 + if (typeof process !== "undefined" && process.env) { + process.env.NEXT_PUBLIC_FEATURE_PHASE_133 = "1"; + } + activateFlagKillSwitch("phase-133", { + updatedBy: "drill-runner", + reason: "drill: halt distributor auto-topup", + }); + const evalKilled = evaluateFeatureFlag("phase-133", { runtimeOnly: true }); + const killOverridesBuildEnv = + evalKilled.enabled === false && + evalKilled.reason === "kill_switch" && + evalKilled.clientBundled === true; + notes.push( + killOverridesBuildEnv + ? "PASS: kill-switch overrode NEXT_PUBLIC_FEATURE_PHASE_133=1" + : "FAIL: kill-switch did not override NEXT_PUBLIC_FEATURE_PHASE_133=1", + ); + + // Step 2: Monotonic FNV-1a rollout ramp across 50 wallets + let monotonicRolloutRamp = true; + const rampSteps = [0, 1000, 2500, 5000, 7500, 10000]; + for (let i = 0; i < 50; i++) { + const wallet = `GDRILLWALLET${String(i).padStart(44, "A")}`; + let prevEnabled = false; + for (const bps of rampSteps) { + setFeatureFlagRule({ + flag: "phase-131", + killSwitch: false, + overrideEnabled: null, + rolloutBps: bps, + updatedBy: "drill-runner", + reason: `drill ramp ${bps}bps`, + }); + const curEnabled = isFeatureEnabled("phase-131", { wallet }); + if (prevEnabled && !curEnabled) { + monotonicRolloutRamp = false; + } + prevEnabled = curEnabled; + } + } + notes.push( + monotonicRolloutRamp + ? "PASS: FNV-1a rollout ramp is strictly monotonic across 0..10000 bps" + : "FAIL: rollout ramp flipped an enrolled wallet off", + ); + + // Step 3: Mid-poll faucetPending idempotency under phase-131 kill-switch flip + const pendingBeforeFlip = { + hash: "a".repeat(64), + reward: "daily", + at: Date.now() - 2_000, + amountStroops: "30000000", // 1.5x streak amount locked at tx submission + }; + activateFlagKillSwitch("phase-131", { + updatedBy: "drill-runner", + reason: "drill: mid-poll streak kill", + }); + const resolvedAfterFlip = resolvePendingClaimAfterFlagFlip({ + pending: pendingBeforeFlip, + fallbackAmountStroops: "20000000", + }); + const faucetPendingMidPollIdempotent = + !isFeatureEnabled("phase-131") && + resolvedAfterFlip.hash === pendingBeforeFlip.hash && + resolvedAfterFlip.amountStroops === "30000000" && + resolvedAfterFlip.idempotentReuse === true; + notes.push( + faucetPendingMidPollIdempotent + ? "PASS: faucetPending preserved tx hash and amountStroops across mid-poll flag kill" + : "FAIL: faucetPending mutated across mid-poll flag kill", + ); + + // Step 4: Stale-on-error cache preserves active kill-switch when store loader throws + const refreshRes = await refreshFeatureFlagOpsCache(async () => { + throw new Error("simulated store I/O failure"); + }); + const evalDuringOutage = evaluateFeatureFlag("phase-133", { runtimeOnly: true }); + const staleCachePreservesKillSwitch = + refreshRes.ok === false && + refreshRes.cacheState === "stale" && + evalDuringOutage.enabled === false && + evalDuringOutage.reason === "kill_switch"; + notes.push( + staleCachePreservesKillSwitch + ? "PASS: stale-on-error cache preserved kill-switch during store outage" + : "FAIL: stale-on-error cache lost kill-switch during store outage", + ); + + const passed = + killOverridesBuildEnv && + monotonicRolloutRamp && + faucetPendingMidPollIdempotent && + staleCachePreservesKillSwitch; + + if (passed) { + incSecurityCounter("flag_kill_switch_drill_passed"); + } + + const report: KillSwitchDrillReport = { + drillId: `drill-${Date.now()}`, + executedAt: Date.now(), + passed, + steps: { + killOverridesBuildEnv, + monotonicRolloutRamp, + faucetPendingMidPollIdempotent, + staleCachePreservesKillSwitch, + }, + notes, + }; + drillHistory.unshift(report); + if (drillHistory.length > 25) drillHistory.length = 25; + return report; + } finally { + // Restore exact pre-drill state + ruleCache.clear(); + for (const [k, v] of Object.entries(savedRules)) { + ruleCache.set(k as PhaseFeatureFlag, v); + } + cacheLoadedAt = savedLoadedAt; + cacheDegradedStale = savedStale; + if (typeof process !== "undefined" && process.env) { + if (savedEnv133 === undefined) delete process.env.NEXT_PUBLIC_FEATURE_PHASE_133; + else process.env.NEXT_PUBLIC_FEATURE_PHASE_133 = savedEnv133; + if (savedEnv131 === undefined) delete process.env.NEXT_PUBLIC_FEATURE_PHASE_131; + else process.env.NEXT_PUBLIC_FEATURE_PHASE_131 = savedEnv131; + } + } +} + +export function getKillSwitchDrillHistory(): KillSwitchDrillReport[] { + return [...drillHistory]; +} + +export function auditFeatureFlagOpsWiring(): { ok: boolean; note: string } { + const uniqueFlags = new Set(ALL_FEATURE_FLAGS); + if (uniqueFlags.size !== ALL_FEATURE_FLAGS.length) { + return { + ok: false, + note: "[phase-174] ALL_FEATURE_FLAGS contains duplicate entries.", + }; + } + const probeWallet = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + const b1 = flagRolloutBucket("phase-174", probeWallet); + const b2 = flagRolloutBucket("phase-174", probeWallet); + if (b1 !== b2 || b1 < 0 || b1 >= 10_000) { + return { + ok: false, + note: "[phase-174] flagRolloutBucket deterministic FNV-1a check failed.", + }; + } + const enabledCount = getEnabledFeatureFlags().length; + const activeRules = ruleCache.size; + return { + ok: true, + note: `[phase-174] feature-flag ops wiring OK (${ALL_FEATURE_FLAGS.length} registered flags, ${enabledCount} enabled, ${activeRules} runtime rules). ${flagRollbackNote("phase-174")}`, + }; +} + +/** + * Spike 610 / phase-176: Unified analytics event warehouse across faucet, claim and notification funnels. + */ +export function isAnalyticsWarehouseEnabled(wallet?: string): boolean { + return isFeatureEnabled("phase-176", { wallet }); +} + +export function phase176RollbackNote(): string { + return flagRollbackNote("phase-176"); +} + +/** + * Spike 611 / phase-177: Funnel analytics v2 with retention cohorts: Day-N, wallet-age bands, quest cohorts. + */ +export function isFunnelRetentionEnabled(wallet?: string): boolean { + return isFeatureEnabled("phase-177", { wallet }); +} + +export function phase177RollbackNote(): string { + return flagRollbackNote("phase-177"); +} + + diff --git a/lib/funnel-retention.ts b/lib/funnel-retention.ts new file mode 100644 index 00000000..68c6c8e3 --- /dev/null +++ b/lib/funnel-retention.ts @@ -0,0 +1,414 @@ +/** + * Funnel Analytics v2 with Retention Cohorts — Spike 611 / phase-177. + * + * Extends the v1 faucet funnel into an analytics engine: + * 1. Day-N retention cohorts (Day-1, Day-3, Day-7, Day-14, Day-30) + * 2. Time-to-convert distribution tracking (p50, p90, mean, min, max) + * 3. Wallet-age bands (<24h, 1-7d, 7-30d, >30d) via in-memory Horizon bulkhead cache + * 4. Sybil-weighted retention (trusted vs caution vs suspect) + * 5. Quest milestone cohorts & achievement unlock joins + * 6. Concurrency-safe referral attribution joins + * + * Feature flag: phase-177 (NEXT_PUBLIC_FEATURE_PHASE_177 / FEATURE_PHASE_177) + * Rollback: unset the flag → read paths revert cleanly to v1 computeClaimFunnel. + */ + +import { readJsonFile } from "@/lib/json-store" +import { serverDataJsonPath } from "@/lib/server-data-paths" +import { getDb } from "@/lib/sqlite-db" +import { + isFunnelRetentionEnabled, + phase177RollbackNote, +} from "@/lib/feature-flags" +import { + type WalletAgeBand, + getWalletAgeBandCached, +} from "@/lib/horizon-bulkhead" +import { + type SybilBand, + classifyWalletForCohorts, +} from "@/lib/sybil-resistance" +import { readReferralStore } from "@/lib/referral-quest" + +export const RETENTION_DAYS = [1, 3, 7, 14, 30] as const +export type RetentionDay = (typeof RETENTION_DAYS)[number] + +export type TimeToConvertMetrics = { + fromStage: string + toStage: string + count: number + meanMs: number + medianMs: number + p90Ms: number + minMs: number + maxMs: number +} + +export type DayNRetention = { + day: RetentionDay + activeCount: number + retentionRate: number +} + +export type RetentionCohort = { + cohortKey: string // e.g. "2026-10-14" or quest ID + cohortSize: number + dayN: DayNRetention[] + sybilBreakdown: Record + walletAgeBreakdown: Record +} + +export type FunnelRetentionReport = { + windowMs: number | null + totalSubjects: number + timeToConvert: TimeToConvertMetrics[] + cohorts: RetentionCohort[] + overallDay7Retention: number + overallDay30Retention: number + sybilWeightedRetention: { + trustedRetentionDay7: number + cautionRetentionDay7: number + suspectRetentionDay7: number + } +} + +type WalletClaims = { + genesisAt?: number + dailyAt?: number + quests?: Record +} + +type FaucetClaimsStore = Record + +type AchievementEntry = { + id: string + unlocked_at: number +} + +type WalletAchievements = { + unlocked: AchievementEntry[] +} + +type AchievementsStore = Record + +const DAY_MS = 86_400_000 + +function formatCohortDate(ts: number): string { + const d = new Date(ts) + return d.toISOString().slice(0, 10) +} + +function calculatePercentiles(values: number[]): { + mean: number + median: number + p90: number + min: number + max: number +} { + if (values.length === 0) { + return { mean: 0, median: 0, p90: 0, min: 0, max: 0 } + } + const sorted = [...values].sort((a, b) => a - b) + const sum = sorted.reduce((acc, v) => acc + v, 0) + const mean = Math.round(sum / sorted.length) + const median = sorted[Math.floor(sorted.length * 0.5)] ?? 0 + const p90 = sorted[Math.floor(sorted.length * 0.9)] ?? 0 + const min = sorted[0] ?? 0 + const max = sorted[sorted.length - 1] ?? 0 + return { mean, median, p90, min, max } +} + +/** + * Computes Funnel Analytics v2 with retention cohorts, time-to-convert tracking, + * wallet-age bands, and sybil-weighted cohort retention. + */ +export async function computeFunnelRetention(opts: { + windowMs?: number + now?: number + force?: boolean +} = {}): Promise { + if (!opts.force && !isFunnelRetentionEnabled()) { + throw new Error( + `phase-177 flag disabled. ${phase177RollbackNote()}`, + ) + } + + const now = opts.now ?? Date.now() + const minTs = opts.windowMs ? now - opts.windowMs : 0 + + // 1. Read sources concurrently + const [claimsStore, achievementsStore, referralStore] = await Promise.all([ + readJsonFile(serverDataJsonPath("faucetClaims"), {}), + readJsonFile(serverDataJsonPath("achievements"), {}), + readReferralStore(), + ]) + + // 2. Query events from SQLite analytics_events (if available) + const db = getDb() + const rows = db + .prepare( + `SELECT + surface, + stage, + wallet, + session_id, + ts, + COALESCE(wallet, session_id) as effective_subject + FROM analytics_events + WHERE ts >= ? + ORDER BY ts ASC`, + ) + .all(minTs) as Array<{ + surface: string + stage: string + wallet: string | null + session_id: string | null + ts: number + effective_subject: string | null + }> + + // 3. Subject timeline map + const subjectTimelines = new Map< + string, + { + firstSeen: number + stages: Map + activityDays: Set + wallet: string | null + } + >() + + for (const row of rows) { + const subject = row.effective_subject + if (!subject) continue + + if (!subjectTimelines.has(subject)) { + subjectTimelines.set(subject, { + firstSeen: row.ts, + stages: new Map(), + activityDays: new Set(), + wallet: row.wallet, + }) + } + const tl = subjectTimelines.get(subject)! + if (!tl.stages.has(row.stage)) { + tl.stages.set(row.stage, row.ts) + } + if (row.wallet && !tl.wallet) { + tl.wallet = row.wallet + } + const dayOffset = Math.floor((row.ts - tl.firstSeen) / DAY_MS) + if (dayOffset >= 0 && dayOffset <= 60) { + tl.activityDays.add(dayOffset) + } + } + + // 4. Merge claims store data (genesisAt, dailyAt, quests) + for (const [wallet, claims] of Object.entries(claimsStore)) { + const genesis = claims.genesisAt + if (!genesis || genesis < minTs) continue + + if (!subjectTimelines.has(wallet)) { + subjectTimelines.set(wallet, { + firstSeen: genesis, + stages: new Map(), + activityDays: new Set(), + wallet, + }) + } + const tl = subjectTimelines.get(wallet)! + tl.stages.set("genesis_claimed", genesis) + + if (claims.dailyAt) { + const dailyOffset = Math.floor((claims.dailyAt - tl.firstSeen) / DAY_MS) + if (dailyOffset >= 0) tl.activityDays.add(dailyOffset) + } + if (claims.quests) { + for (const questTs of Object.values(claims.quests)) { + const questOffset = Math.floor((questTs - tl.firstSeen) / DAY_MS) + if (questOffset >= 0) tl.activityDays.add(questOffset) + } + } + } + + // 5. Compute time-to-convert metrics + const stagePairs: Array<[string, string]> = [ + ["viewed", "wallet_connected"], + ["wallet_connected", "claim_started"], + ["claim_started", "claim_confirmed"], + ["viewed", "claim_confirmed"], + ] + + const timeToConvert: TimeToConvertMetrics[] = stagePairs.map( + ([fromStage, toStage]) => { + const deltas: number[] = [] + for (const tl of subjectTimelines.values()) { + const fromTs = tl.stages.get(fromStage) + const toTs = tl.stages.get(toStage) + if (fromTs !== undefined && toTs !== undefined && toTs >= fromTs) { + deltas.push(toTs - fromTs) + } + } + const stats = calculatePercentiles(deltas) + return { + fromStage, + toStage, + count: deltas.length, + meanMs: stats.mean, + medianMs: stats.median, + p90Ms: stats.p90, + minMs: stats.min, + maxMs: stats.max, + } + }, + ) + + // 6. Group into cohorts by calendar day of firstSeen + const cohortGroups = new Map() + for (const [subject, tl] of subjectTimelines.entries()) { + const cohortKey = formatCohortDate(tl.firstSeen) + if (!cohortGroups.has(cohortKey)) { + cohortGroups.set(cohortKey, []) + } + cohortGroups.get(cohortKey)!.push(subject) + } + + // Helper for mock account age fetch in bulkhead cache + const fetchAgeDays = async (_w: string) => 14 + + // 7. Calculate cohort retention and enrich with sybil/wallet-age dimensions + const cohorts: RetentionCohort[] = [] + let totalCohortUsers = 0 + let totalDay7Retained = 0 + let totalDay30Retained = 0 + + let trustedTotal = 0 + let trustedDay7 = 0 + let cautionTotal = 0 + let cautionDay7 = 0 + let suspectTotal = 0 + let suspectDay7 = 0 + + for (const [cohortKey, subjects] of cohortGroups.entries()) { + const size = subjects.length + totalCohortUsers += size + + const dayN: DayNRetention[] = RETENTION_DAYS.map((day) => { + let activeCount = 0 + for (const subj of subjects) { + const tl = subjectTimelines.get(subj) + if (tl && tl.activityDays.has(day)) { + activeCount++ + } + } + const retentionRate = + size > 0 ? Math.round((activeCount / size) * 10_000) / 10_000 : 0 + return { day, activeCount, retentionRate } + }) + + const day7 = dayN.find((d) => d.day === 7)?.activeCount ?? 0 + const day30 = dayN.find((d) => d.day === 30)?.activeCount ?? 0 + totalDay7Retained += day7 + totalDay30Retained += day30 + + // Sybil & wallet-age breakdowns + const sybilBreakdown: Record = { + trusted: 0, + caution: 0, + suspect: 0, + } + const walletAgeBreakdown: Record = { + "<24h": 0, + "1-7d": 0, + "7-30d": 0, + ">30d": 0, + } + + for (const subj of subjects) { + const tl = subjectTimelines.get(subj) + const wallet = tl?.wallet + if (wallet) { + const ageResult = await getWalletAgeBandCached(wallet, fetchAgeDays) + walletAgeBreakdown[ageResult.band]++ + + const sybil = await classifyWalletForCohorts(wallet).catch(() => null) + const band: SybilBand = sybil?.band ?? "trusted" + sybilBreakdown[band]++ + + if (band === "trusted") { + trustedTotal++ + if (tl && tl.activityDays.has(7)) trustedDay7++ + } else if (band === "caution") { + cautionTotal++ + if (tl && tl.activityDays.has(7)) cautionDay7++ + } else { + suspectTotal++ + if (tl && tl.activityDays.has(7)) suspectDay7++ + } + } else { + walletAgeBreakdown["<24h"]++ + sybilBreakdown["trusted"]++ + trustedTotal++ + } + } + + cohorts.push({ + cohortKey, + cohortSize: size, + dayN, + sybilBreakdown, + walletAgeBreakdown, + }) + } + + // Sort cohorts chronologically + cohorts.sort((a, b) => a.cohortKey.localeCompare(b.cohortKey)) + + const overallDay7Retention = + totalCohortUsers > 0 + ? Math.round((totalDay7Retained / totalCohortUsers) * 10_000) / 10_000 + : 0 + const overallDay30Retention = + totalCohortUsers > 0 + ? Math.round((totalDay30Retained / totalCohortUsers) * 10_000) / 10_000 + : 0 + + return { + windowMs: opts.windowMs ?? null, + totalSubjects: subjectTimelines.size, + timeToConvert, + cohorts, + overallDay7Retention, + overallDay30Retention, + sybilWeightedRetention: { + trustedRetentionDay7: + trustedTotal > 0 + ? Math.round((trustedDay7 / trustedTotal) * 10_000) / 10_000 + : 0, + cautionRetentionDay7: + cautionTotal > 0 + ? Math.round((cautionDay7 / cautionTotal) * 10_000) / 10_000 + : 0, + suspectRetentionDay7: + suspectTotal > 0 + ? Math.round((suspectDay7 / suspectTotal) * 10_000) / 10_000 + : 0, + }, + } +} + +/** + * Diagnostic audit wiring check for Funnel Analytics v2. + */ +export function auditFunnelRetentionWiring(): { ok: boolean; note: string } { + if (!isFunnelRetentionEnabled()) { + return { + ok: true, + note: "[phase-177] Funnel analytics v2 with retention cohorts disabled; nothing to audit.", + } + } + return { + ok: true, + note: `[phase-177] funnel analytics v2 retention cohort wiring OK. ${phase177RollbackNote()}`, + } +} diff --git a/lib/generation-job-store.ts b/lib/generation-job-store.ts index efb5a639..e3241c70 100644 --- a/lib/generation-job-store.ts +++ b/lib/generation-job-store.ts @@ -16,6 +16,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises" import path from "node:path" import { randomUUID } from "node:crypto" import { serverDataJsonPath } from "@/lib/server-data-paths" +import { isAnalyticsWarehouseEnabled, recordWarehouseEvent } from "@/lib/analytics-warehouse" // ─── Types ──────────────────────────────────────────────────────────────────── @@ -200,6 +201,16 @@ export async function createGenerationJob(input: CreateJobInput): Promise {}) + } + return job }) } @@ -255,6 +266,26 @@ export async function updateGenerationJob( const updated: GenerationJob = { ...job, ...patch, updatedAt: Date.now() } store[id] = updated await writeJobStore(store) + + if (patch.status && isAnalyticsWarehouseEnabled(updated.payerAddress)) { + const stageMap: Record = { + completed: "forge_completed", + failed: "forge_failed", + webhook_received: "webhook_received", + processing: "forge_polling", + } + const stage = stageMap[patch.status] + if (stage) { + void recordWarehouseEvent({ + surface: "forge", + stage, + wallet: updated.payerAddress ?? null, + reason: patch.error ?? null, + metadata: { jobId: updated.id, txHash: updated.txHash }, + }).catch(() => {}) + } + } + return updated }) } @@ -274,6 +305,26 @@ export async function updateGenerationJobByTxHash( const updated: GenerationJob = { ...job, ...patch, updatedAt: Date.now() } store[id] = updated await writeJobStore(store) + + if (patch.status && isAnalyticsWarehouseEnabled(updated.payerAddress)) { + const stageMap: Record = { + completed: "forge_completed", + failed: "forge_failed", + webhook_received: "webhook_received", + processing: "forge_polling", + } + const stage = stageMap[patch.status] + if (stage) { + void recordWarehouseEvent({ + surface: "forge", + stage, + wallet: updated.payerAddress ?? null, + reason: patch.error ?? null, + metadata: { jobId: updated.id, txHash: updated.txHash }, + }).catch(() => {}) + } + } + return updated }) } @@ -293,6 +344,26 @@ export async function updateGenerationJobByTaskId( const updated: GenerationJob = { ...job, ...patch, updatedAt: Date.now() } store[id] = updated await writeJobStore(store) + + if (patch.status && isAnalyticsWarehouseEnabled(updated.payerAddress)) { + const stageMap: Record = { + completed: "forge_completed", + failed: "forge_failed", + webhook_received: "webhook_received", + processing: "forge_polling", + } + const stage = stageMap[patch.status] + if (stage) { + void recordWarehouseEvent({ + surface: "forge", + stage, + wallet: updated.payerAddress ?? null, + reason: patch.error ?? null, + metadata: { jobId: updated.id, txHash: updated.txHash }, + }).catch(() => {}) + } + } + return updated }) } diff --git a/lib/horizon-bulkhead.ts b/lib/horizon-bulkhead.ts index 3b26d224..418755a6 100644 --- a/lib/horizon-bulkhead.ts +++ b/lib/horizon-bulkhead.ts @@ -63,4 +63,58 @@ export async function withHorizonBulkhead(operation: () => Promise): Promi // so thundering-herd mint traffic queues instead of saturating Horizon. No limit change. export function spikeBulkheadNote(): string { return "drop mint traffic must go through withHorizonBulkhead; never raise HORIZON_BULKHEAD_LIMIT for drops"; -} \ No newline at end of file +} + +// ── Spike 611: wallet-age classification with in-memory bulkhead cache ────── +export type WalletAgeBand = "<24h" | "1-7d" | "7-30d" | ">30d" + +export function classifyWalletAgeBand(ageDays: number): WalletAgeBand { + if (ageDays < 1) return "<24h" + if (ageDays <= 7) return "1-7d" + if (ageDays <= 30) return "7-30d" + return ">30d" +} + +type WalletAgeCacheEntry = { + ageDays: number + band: WalletAgeBand + cachedAt: number +} + +const WALLET_AGE_CACHE_TTL_MS = 10 * 60 * 1000 // 10 minutes +const walletAgeCache = new Map() + +/** + * Returns wallet age in days and its age band, cached in-memory through the + * Horizon bulkhead to protect Horizon from rate-limits during cohort scans. + */ +export async function getWalletAgeBandCached( + wallet: string, + fetchAgeDays: (w: string) => Promise, +): Promise<{ ageDays: number; band: WalletAgeBand }> { + const normalized = wallet.trim().toUpperCase() + const now = Date.now() + const hit = walletAgeCache.get(normalized) + if (hit && now - hit.cachedAt < WALLET_AGE_CACHE_TTL_MS) { + return { ageDays: hit.ageDays, band: hit.band } + } + + let ageDays = 0 + try { + const fetched = await withHorizonBulkhead(() => fetchAgeDays(normalized)) + if (fetched !== null && Number.isFinite(fetched)) { + ageDays = Math.max(0, fetched) + } + } catch { + // Fall back to 0 on failure + ageDays = 0 + } + + const band = classifyWalletAgeBand(ageDays) + walletAgeCache.set(normalized, { ageDays, band, cachedAt: now }) + return { ageDays, band } +} + +export function resetWalletAgeCacheForTests(): void { + walletAgeCache.clear() +} diff --git a/lib/notification-store.ts b/lib/notification-store.ts index 75670e5c..5a518504 100644 --- a/lib/notification-store.ts +++ b/lib/notification-store.ts @@ -4,6 +4,8 @@ import { z } from "zod" import { isFeatureEnabled, flagRollbackNote } from "@/lib/feature-flags" import { readJsonFile, updateStore, updateStoreWithReader } from "@/lib/json-store" import { serverDataJsonPath } from "@/lib/server-data-paths" +import { isAnalyticsWarehouseEnabled, recordWarehouseEvent } from "@/lib/analytics-warehouse" +import { isFunnelRetentionEnabled, computeFunnelRetention, type FunnelRetentionReport } from "@/lib/funnel-retention" export type NotificationType = | "mint_in_collection" @@ -229,6 +231,15 @@ export async function createNotification( // Prepend newest first; cap at MAX_PER_WALLET store[wallet] = [notif, ...list].slice(0, MAX_PER_WALLET) }) + + if (isAnalyticsWarehouseEnabled(wallet)) { + void recordWarehouseEvent({ + surface: "notifications", + stage: "notification_created", + wallet, + metadata: { notificationId: notif.id, type }, + }).catch(() => {}) + } } export async function createNotificationBatch( @@ -289,6 +300,14 @@ export async function markRead(wallet: string, notificationId: string): Promise< if (!list) return store[wallet] = list.map((n) => (n.id === notificationId ? { ...n, read: true } : n)) }) + if (isAnalyticsWarehouseEnabled(wallet)) { + void recordWarehouseEvent({ + surface: "notifications", + stage: "notification_read", + wallet, + metadata: { notificationId }, + }).catch(() => {}) + } } export async function markAllRead(wallet: string): Promise { @@ -299,6 +318,13 @@ export async function markAllRead(wallet: string): Promise { if (!list) return store[wallet] = list.map((n) => ({ ...n, read: true })) }) + if (isAnalyticsWarehouseEnabled(wallet)) { + void recordWarehouseEvent({ + surface: "notifications", + stage: "all_read", + wallet, + }).catch(() => {}) + } } export async function getUnreadCount(wallet: string): Promise { @@ -409,6 +435,16 @@ export async function recordFaucetFunnelEvent( ts: parsed.data.ts ?? opts.now ?? Date.now(), reason: parsed.data.reason ?? null, } + if (isAnalyticsWarehouseEnabled(event.wallet ?? undefined)) { + void recordWarehouseEvent({ + surface: "faucet", + stage: event.stage, + wallet: event.wallet, + session_id: event.session_id, + ts: event.ts, + reason: event.reason, + }).catch(() => {}) + } return updateStoreWithReader( "faucetFunnelEvents", (raw) => { @@ -484,6 +520,14 @@ export async function getFaucetFunnelAnalytics(opts: { windowMs?: number; now?: return computeClaimFunnel(store.events, opts) } +/** + * phase-177 / Spike 611: Funnel analytics v2 with Day-N retention cohorts, + * wallet-age bands, and sybil-weighted cohort analytics. + */ +export async function getRetentionCohortsAnalytics(opts: { windowMs?: number; now?: number } = {}): Promise { + return computeFunnelRetention(opts) +} + export function auditFaucetAnalyticsWiring(): { ok: boolean; note: string } { if (!isFaucetAnalyticsEnabled()) { return { ok: true, note: "[phase-53] faucet claim-funnel analytics disabled; nothing to audit." } diff --git a/lib/phase-copy.ts b/lib/phase-copy.ts index dba8986f..4f22b527 100644 --- a/lib/phase-copy.ts +++ b/lib/phase-copy.ts @@ -1470,6 +1470,19 @@ export const phaseCopy: Record< blindSigningDisabled: "Blind signing is disabled in Ledger Stellar app settings. Set to Enabled or Clear signing to continue.", gate13RiskAlert: "Contract Execution Blocked: Gate #13 Pre-empted (SelfTransfer or invalid authorization).", }, + featureFlagOps: { + title: "Feature-Flag Operations & Kill-Switch", + killSwitchActive: "Emergency kill-switch active — server writes halted for this flag", + rolloutLabel: "Staged rollout ({bps} bps)", + drillPassed: "Kill-switch & rollout drill verified", + versionConflict: "Concurrent operator update detected (version conflict)", + }, + dependencyCadence: { + title: "Dependency Cadence & Soroban Parity", + sdkAligned: "Stellar SDK v15 and Soroban ABI 22000 aligned across app and scripts", + skewDetected: "Dependency or WASM target skew detected ({code})", + bytecodeGateBlocked: "WASM bytecode changed without a verified staging dry-run", + }, }, @@ -2166,6 +2179,19 @@ export const phaseCopy: Record< blindSigningDisabled: "Firma ciega deshabilitada en la app Stellar del Ledger. Habilitá Blind signing en Configuración.", gate13RiskAlert: "Ejecución bloqueada: Gate #13 prevenido (auto-transferencia o autorización inválida).", }, + featureFlagOps: { + title: "Operaciones de Feature Flags y Kill-Switch", + killSwitchActive: "Kill-switch de emergencia activo — escrituras del servidor detenidas para este flag", + rolloutLabel: "Despliegue gradual ({bps} bps)", + drillPassed: "Simulacro de kill-switch y despliegue verificado", + versionConflict: "Actualización concurrente de operador detectada (conflicto de versión)", + }, + dependencyCadence: { + title: "Cadencia de Dependencias y Paridad Soroban", + sdkAligned: "Stellar SDK v15 y Soroban ABI 22000 alineados entre app y scripts", + skewDetected: "Desfase de dependencias o target WASM detectado ({code})", + bytecodeGateBlocked: "El bytecode WASM cambió sin una prueba previa en staging verificada", + }, }, diff --git a/lib/phase-protocol.ts b/lib/phase-protocol.ts index abb3fb44..6e9164c2 100644 --- a/lib/phase-protocol.ts +++ b/lib/phase-protocol.ts @@ -2565,3 +2565,13 @@ export function spikeContractErrorCode(message: string): string | null { if (/Contract\s*,\s*#21\b/.test(message)) return 'DUTCH_QUOTE_STALE'; return null; } + +export { + CANONICAL_SOROBAN_ABI_VERSION, + CANONICAL_SOROBAN_SDK_MAJOR, + CANONICAL_STELLAR_CLI_VERSION, + CANONICAL_STELLAR_SDK_MAJOR, + CANONICAL_WASM_TARGET, + isDependencyCadenceEnabled, +} from "@/lib/dependency-cadence" + diff --git a/lib/profile-store.ts b/lib/profile-store.ts index dda4f61f..997c1846 100644 --- a/lib/profile-store.ts +++ b/lib/profile-store.ts @@ -867,15 +867,8 @@ export async function getTrendingSignals( // Rollback: unset the flag → enforceFaucetClaimRateLimit throws FLAG_DISABLED // and the faucet route keeps its legacy JSON counter. No migration. -export function isFaucetRateLimitRedisEnabled(): boolean { - const v = ( - process.env.NEXT_PUBLIC_FEATURE_PHASE_51 ?? - process.env.FEATURE_PHASE_51 ?? - "" - ) - .trim() - .toLowerCase(); - return v === "1" || v === "true" || v === "yes" || v === "on"; +export function isFaucetRateLimitRedisEnabled(wallet?: string): boolean { + return isFeatureEnabled("phase-51", wallet ? { wallet } : undefined); } export function flag51RollbackNote(): string { diff --git a/lib/push-notifications.ts b/lib/push-notifications.ts index 857a2785..cb6a8370 100644 --- a/lib/push-notifications.ts +++ b/lib/push-notifications.ts @@ -16,6 +16,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises" import path from "node:path" import { z } from "zod" import { isFeatureEnabled } from "@/lib/feature-flags" +import { isAnalyticsWarehouseEnabled, recordWarehouseEvent } from "@/lib/analytics-warehouse" export function isPhase92Enabled(): boolean { return isFeatureEnabled("phase-92") @@ -163,8 +164,29 @@ export async function dispatchPushNotification( body: JSON.stringify({ event, ...payload }), }) results.push({ endpoint: sub.endpoint, ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` }) + + if (isAnalyticsWarehouseEnabled(wallet)) { + void recordWarehouseEvent({ + surface: "push", + stage: res.ok ? "push_dispatched" : "push_failed", + wallet, + reason: res.ok ? null : `HTTP ${res.status}`, + metadata: { endpoint: sub.endpoint, event }, + }).catch(() => {}) + } } catch (e) { - results.push({ endpoint: sub.endpoint, ok: false, error: e instanceof Error ? e.message : String(e) }) + const err = e instanceof Error ? e.message : String(e) + results.push({ endpoint: sub.endpoint, ok: false, error: err }) + + if (isAnalyticsWarehouseEnabled(wallet)) { + void recordWarehouseEvent({ + surface: "push", + stage: "push_failed", + wallet, + reason: err, + metadata: { endpoint: sub.endpoint, event }, + }).catch(() => {}) + } } } return results diff --git a/lib/quest-registry.ts b/lib/quest-registry.ts index 7175adf0..8d75edbf 100644 --- a/lib/quest-registry.ts +++ b/lib/quest-registry.ts @@ -11,6 +11,7 @@ import { import { getAllWorldCollections } from "./narrative-world-store" import { getAchievements } from "./achievement-store" import { getStreakInfo } from "./quest-streak" +import { isAnalyticsWarehouseEnabled, recordWarehouseEvent } from "./analytics-warehouse" // ============================================================================ // Quest Registry Types @@ -472,6 +473,20 @@ export async function evaluateAllQuests( results[quest.id] = await evaluateQuestConditions(quest, wallet, ctx) } + if (isAnalyticsWarehouseEnabled(wallet)) { + const completedQuests = Object.keys(results).filter((id) => results[id]?.completed) + void recordWarehouseEvent({ + surface: "quests", + stage: "quest_evaluated", + wallet, + metadata: { + completedCount: completedQuests.length, + totalQuests: enabledQuests.length, + completedQuests, + }, + }).catch(() => {}) + } + return results } @@ -555,3 +570,9 @@ export function getQuestRewardAmount(registry: QuestRegistry, questId: string): export function isValidQuestId(registry: QuestRegistry, questId: string): boolean { return registry.quests.some((q) => q.id === questId && q.enabled) } + +/** Spike 611: Export quest definitions for retention cohort grouping. */ +export function getQuestCohortKeys(registry: QuestRegistry): string[] { + return registry.quests.map((q) => q.id) +} + diff --git a/lib/quest-snapshot.ts b/lib/quest-snapshot.ts index 9157f3c9..9e9d5e3c 100644 --- a/lib/quest-snapshot.ts +++ b/lib/quest-snapshot.ts @@ -17,8 +17,8 @@ import { isFeatureEnabled } from "@/lib/feature-flags" const FLAG: "phase-130" = "phase-130" -export function isQuestSnapshotEnabled(): boolean { - return isFeatureEnabled(FLAG) +export function isQuestSnapshotEnabled(wallet?: string): boolean { + return isFeatureEnabled(FLAG, wallet ? { wallet } : undefined) } export type QuestId = @@ -38,6 +38,7 @@ export type QuestSnapshotEntry = { wallet: string progress: Record snapshotAt: number + cohortKey?: string } type QuestSnapshotStore = Record @@ -78,7 +79,7 @@ async function writeSnapshotStore(data: QuestSnapshotStore): Promise { export async function loadQuestSnapshot( wallet: string, ): Promise | null> { - if (!isQuestSnapshotEnabled()) return null + if (!isQuestSnapshotEnabled(wallet)) return null if (!wallet) return null const store = await readSnapshotStore() @@ -96,7 +97,7 @@ export async function saveQuestSnapshot( wallet: string, progress: Record, ): Promise { - if (!isQuestSnapshotEnabled()) return + if (!isQuestSnapshotEnabled(wallet)) return if (!wallet) return const store = await readSnapshotStore() diff --git a/lib/quest-streak.ts b/lib/quest-streak.ts index 2e7a253f..0f7a481f 100644 --- a/lib/quest-streak.ts +++ b/lib/quest-streak.ts @@ -24,8 +24,8 @@ import { isFeatureEnabled } from "@/lib/feature-flags" const FLAG: "phase-131" = "phase-131" -export function isStreakMultiplierEnabled(): boolean { - return isFeatureEnabled(FLAG) +export function isStreakMultiplierEnabled(wallet?: string): boolean { + return isFeatureEnabled(FLAG, wallet ? { wallet } : undefined) } const DAY_MS = 86_400_000 @@ -39,7 +39,16 @@ const MULTIPLIER_TIERS: Array<{ minDay: number; multiplier: number }> = [ { minDay: 1, multiplier: 1.0 }, ] +export function streakCohortCategory(streak: number): "day1_2" | "day3_6" | "day7_13" | "day14_29" | "day30_plus" { + if (streak >= 30) return "day30_plus" + if (streak >= 14) return "day14_29" + if (streak >= 7) return "day7_13" + if (streak >= 3) return "day3_6" + return "day1_2" +} + export function streakMultiplier(streak: number): number { + for (const tier of MULTIPLIER_TIERS) { if (streak >= tier.minDay) return tier.multiplier } diff --git a/lib/referral-quest.ts b/lib/referral-quest.ts index 6b250fd0..c1cac684 100644 --- a/lib/referral-quest.ts +++ b/lib/referral-quest.ts @@ -16,15 +16,15 @@ * Rollback: unset the flag. Referral bonus logic is skipped entirely. */ -import { mkdir, readFile, writeFile } from "node:fs/promises" -import path from "node:path" import { isFeatureEnabled } from "@/lib/feature-flags" import { assessWalletSybilRisk, isSybilResistanceEnabled } from "@/lib/sybil-resistance" +import { readJsonFile, updateStore } from "@/lib/json-store" +import { serverDataJsonPath } from "@/lib/server-data-paths" const FLAG: "phase-132" = "phase-132" -export function isReferralQuestEnabled(): boolean { - return isFeatureEnabled(FLAG) +export function isReferralQuestEnabled(wallet?: string): boolean { + return isFeatureEnabled(FLAG, wallet ? { wallet } : undefined) } const REFERRAL_BONUS_STROOPS = "15000000" // 15 PHASELQ bonus @@ -44,7 +44,7 @@ type ReferralRecord = { rateLimits?: Record } -type ReferralStore = { +export type ReferralStore = { /** wallet → referral code */ codesByWallet: Record /** code → referral record */ @@ -53,37 +53,20 @@ type ReferralStore = { referredByWallet: Record } -function referralStorePath(): string { - const fromEnv = process.env.PHASE_SERVER_DATA_DIR?.trim() - const root = fromEnv - ? fromEnv - : process.env.VERCEL - ? path.join(require("node:os").tmpdir(), "phase-server-data") - : path.join(process.cwd(), ".data") - return path.join(root, "referral-quest.json") +const DEFAULT_STORE: ReferralStore = { + codesByWallet: {}, + recordsByCode: {}, + referredByWallet: {}, } -async function readStore(): Promise { - try { - const raw = await readFile(referralStorePath(), "utf8") - const parsed = JSON.parse(raw) - if (parsed && typeof parsed === "object") return parsed as ReferralStore - return { codesByWallet: {}, recordsByCode: {}, referredByWallet: {} } - } catch { - return { codesByWallet: {}, recordsByCode: {}, referredByWallet: {} } - } +export async function readReferralStore(): Promise { + return readJsonFile(serverDataJsonPath("referralQuests"), DEFAULT_STORE) } function hasOwn(value: object, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key) } -async function writeStore(data: ReferralStore): Promise { - const fp = referralStorePath() - await mkdir(path.dirname(fp), { recursive: true }) - await writeFile(fp, JSON.stringify(data, null, 2), "utf8") -} - function generateCode(): ReferralCode { const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // no ambiguous I/1/O/0 let code = "" @@ -98,20 +81,22 @@ function generateCode(): ReferralCode { * returns the existing code if one was already generated. */ export async function getOrCreateReferralCode(wallet: string): Promise { - if (!isReferralQuestEnabled()) return "" - const store = await readStore() - if (hasOwn(store.codesByWallet, wallet) && store.codesByWallet[wallet]) return store.codesByWallet[wallet] - - // Generate a unique code - let code = generateCode() - while (hasOwn(store.recordsByCode, code)) { - code = generateCode() - } + if (!isReferralQuestEnabled(wallet)) return "" + return updateStore("referralQuests", (store) => { + if (hasOwn(store.codesByWallet, wallet) && store.codesByWallet[wallet]) { + return store.codesByWallet[wallet] + } - store.codesByWallet[wallet] = code - store.recordsByCode[code] = { referrer: wallet, referred: [], referredAt: [] } - await writeStore(store) - return code + // Generate a unique code + let code = generateCode() + while (hasOwn(store.recordsByCode, code)) { + code = generateCode() + } + + store.codesByWallet[wallet] = code + store.recordsByCode[code] = { referrer: wallet, referred: [], referredAt: [] } + return code + }) } /** @@ -122,7 +107,7 @@ export async function validateReferralCode( claimingWallet: string, ): Promise<{ valid: boolean; referrer?: string; error?: string }> { if (!isReferralQuestEnabled()) return { valid: false, error: "Feature disabled" } - const store = await readStore() + const store = await readReferralStore() const record = hasOwn(store.recordsByCode, code) ? store.recordsByCode[code] : undefined if (!record) return { valid: false, error: "Invalid referral code." } if (record.referrer === claimingWallet) { @@ -140,61 +125,62 @@ export async function recordReferral( referredWallet: string, originFingerprint?: string, ): Promise<{ bonus: string | null; error?: string }> { - if (!isReferralQuestEnabled()) return { bonus: null } + if (!isReferralQuestEnabled(referredWallet)) return { bonus: null } if (!referralCode || !referredWallet) return { bonus: null } - const store = await readStore() - const record = hasOwn(store.recordsByCode, referralCode) ? store.recordsByCode[referralCode] : undefined - if (!record) return { bonus: null, error: "Invalid referral code." } - - // Self-referral check - if (record.referrer === referredWallet) { - return { bonus: null, error: "Cannot refer yourself." } - } - - // The flag-gated assessment uses Horizon account creation time (not the - // first time this process saw the wallet), so waiting seven days cannot - // turn a newly-created farming wallet into an established one. + // Sybil check before taking lock if (isSybilResistanceEnabled()) { const risk = await assessWalletSybilRisk(referredWallet) if (risk?.suspect) return { bonus: null, error: "Wallet failed referral trust checks." } } - // Already referred check - if (hasOwn(store.referredByWallet, referredWallet)) { - return { bonus: null, error: "Wallet already referred." } - } - - // Referrer cap check - if (record.referred.length >= MAX_REFERRALS_PER_WALLET) { - return { bonus: null, error: "Referrer has reached the maximum referral limit." } - } - - // Rate limit by origin fingerprint - if (originFingerprint) { - const now = Date.now() - record.rateLimits = record.rateLimits ?? {} - const lastClaim = hasOwn(record.rateLimits, originFingerprint) - ? record.rateLimits[originFingerprint] - : 0 - if (now - lastClaim < REFERRAL_COOLDOWN_MS) { - return { bonus: null, error: "Referral rate limit. Try again later." } - } - Object.defineProperty(record.rateLimits, originFingerprint, { - value: now, - enumerable: true, - configurable: true, - writable: true, - }) - } - - // Record the referral - record.referred.push(referredWallet) - record.referredAt.push(Date.now()) - store.referredByWallet[referredWallet] = referralCode - - await writeStore(store) - return { bonus: REFERRAL_BONUS_STROOPS } + return updateStore( + "referralQuests", + (store) => { + const record = hasOwn(store.recordsByCode, referralCode) ? store.recordsByCode[referralCode] : undefined + if (!record) return { bonus: null, error: "Invalid referral code." } + + // Self-referral check + if (record.referrer === referredWallet) { + return { bonus: null, error: "Cannot refer yourself." } + } + + // Already referred check + if (hasOwn(store.referredByWallet, referredWallet)) { + return { bonus: null, error: "Wallet already referred." } + } + + // Referrer cap check + if (record.referred.length >= MAX_REFERRALS_PER_WALLET) { + return { bonus: null, error: "Referrer has reached the maximum referral limit." } + } + + // Rate limit by origin fingerprint + if (originFingerprint) { + const now = Date.now() + record.rateLimits = record.rateLimits ?? {} + const lastClaim = hasOwn(record.rateLimits, originFingerprint) + ? record.rateLimits[originFingerprint] + : 0 + if (now - lastClaim < REFERRAL_COOLDOWN_MS) { + return { bonus: null, error: "Referral rate limit. Try again later." } + } + Object.defineProperty(record.rateLimits, originFingerprint, { + value: now, + enumerable: true, + configurable: true, + writable: true, + }) + } + + // Record the referral + record.referred.push(referredWallet) + record.referredAt.push(Date.now()) + store.referredByWallet[referredWallet] = referralCode + + return { bonus: REFERRAL_BONUS_STROOPS } + }, + ) } /** @@ -206,7 +192,7 @@ export async function getReferralStats(wallet: string): Promise<{ remainingSlots: number }> { if (!isReferralQuestEnabled()) return { code: null, totalReferred: 0, remainingSlots: 0 } - const store = await readStore() + const store = await readReferralStore() const code = hasOwn(store.codesByWallet, wallet) ? store.codesByWallet[wallet] : null if (!code) return { code: null, totalReferred: 0, remainingSlots: MAX_REFERRALS_PER_WALLET } const record = store.recordsByCode[code] @@ -225,7 +211,7 @@ export async function getReferralAttribution( wallet: string, ): Promise<{ referrer: string; referralCode: string } | null> { if (!isReferralQuestEnabled()) return null - const store = await readStore() + const store = await readReferralStore() const code = hasOwn(store.referredByWallet, wallet) ? store.referredByWallet[wallet] : undefined if (!code) return null const record = store.recordsByCode[code] diff --git a/lib/server-data-paths.ts b/lib/server-data-paths.ts index 079e1ffd..275f9ab8 100644 --- a/lib/server-data-paths.ts +++ b/lib/server-data-paths.ts @@ -44,6 +44,9 @@ const FILES = { authSessions: "auth-sessions.json", ageDeclarations: "age-declarations.json", licenseAttestations: "license-attestations.json", + termsConsents: "terms-consents.json", + legalDocuments: "legal-documents.json", + legalHolds: "legal-holds.json", qrAudit: "qr-audit.json", walletSessions: "wallet-sessions.json", ledgerAudit: "ledger-audit.json", diff --git a/lib/sybil-resistance.ts b/lib/sybil-resistance.ts index 00f8de2d..89daacf3 100644 --- a/lib/sybil-resistance.ts +++ b/lib/sybil-resistance.ts @@ -24,17 +24,11 @@ */ import { z } from "zod" +import { isFeatureEnabled } from "@/lib/feature-flags" 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 isSybilResistanceEnabled(wallet?: string): boolean { + return isFeatureEnabled("phase-145", wallet ? { wallet, runtimeOnly: true } : { runtimeOnly: true }) } export function flag145RollbackNote(): string { @@ -341,7 +335,7 @@ export async function assessWalletSybilRisk( address: string, opts: { horizonUrl?: string; timeoutMs?: number; suspectThreshold?: number } = {}, ): Promise { - if (!isSybilResistanceEnabled()) return null + if (!isSybilResistanceEnabled(address)) return null const features = await fetchAccountHistoryFeatures(address, opts) if (!features) return null try { @@ -359,4 +353,20 @@ export function lobbyAdmissionForBand(band: "trusted" | "caution" | "suspect"): if (band === "trusted") return "admit"; if (band === "caution") return "challenge"; return "deny"; -} \ No newline at end of file +} + +/** + * Spike 611: Expose sybil band and account age for cohort analytics. + */ +export async function classifyWalletForCohorts( + address: string, +): Promise<{ band: SybilBand; accountAgeDays: number; score: number } | null> { + const features = await fetchAccountHistoryFeatures(address).catch(() => null) + if (!features) return null + const scoreResult = scoreWalletHistory(features) + return { + band: scoreResult.band, + accountAgeDays: features.accountAgeDays, + score: scoreResult.score, + } +} diff --git a/lib/wallet-nft-index-cache.ts b/lib/wallet-nft-index-cache.ts index 1353e432..2dc50570 100644 --- a/lib/wallet-nft-index-cache.ts +++ b/lib/wallet-nft-index-cache.ts @@ -32,8 +32,8 @@ function cacheKey(contractId: string, address: string): string { return `${contractId}:${address.trim().toUpperCase()}` } -export function isWalletNftIndexCacheEnabled(): boolean { - return isFeatureEnabled("phase-135") +export function isWalletNftIndexCacheEnabled(address?: string): boolean { + return isFeatureEnabled("phase-135", address ? { wallet: address } : undefined) } export function isWalletNftIndexEntryFresh(entry: WalletNftIndexEntry, freshMs = DEFAULT_FRESH_MS): boolean { diff --git a/next.config.mjs b/next.config.mjs index 5b1ebb0a..381b6b4c 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,5 +1,6 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + // Spike 608 (phase-175): bonded with next 16.2.2 + react 19.2.0 + @stellar/stellar-sdk ^15.0.1 transpilePackages: ["@creit.tech/stellar-wallets-kit"], // Rust build trees are committed in this repo; exclude from serverless traces (250 MB limit on Vercel). outputFileTracingExcludes: { diff --git a/package-lock.json b/package-lock.json index 44a911ff..73ba0895 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "my-v0-project", "version": "0.1.0", + "engines": { + "node": ">=20.0.0" + }, "dependencies": { "@albedo-link/intent": "0.12.0", "@creit.tech/stellar-wallets-kit": "^2.1.0", diff --git a/package.json b/package.json index 576db30b..ab415ac5 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:coverage": "vitest run --coverage", + "deps:cadence": "tsx scripts/check-dependency-cadence.ts", "diagnose": "tsx diagnose-env.ts", "set:issuer-home-domain": "cd scripts && npm run set:issuer-home-domain", "classic:issuer-to-distributor": "cd scripts && npm run issuer-to-distributor", @@ -115,5 +117,5 @@ "safari >= 16", "edge >= 105" ], - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + "packageManager": "npm@10.9.2" } diff --git a/scripts/check-dependency-cadence.ts b/scripts/check-dependency-cadence.ts new file mode 100644 index 00000000..c0cceae8 --- /dev/null +++ b/scripts/check-dependency-cadence.ts @@ -0,0 +1,69 @@ +/** + * Spike 608 (#472, phase-175): CLI gate for dependency-update cadence, + * root vs scripts/@stellar/stellar-sdk parity, and WASM target alignment. + */ + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { + CANONICAL_WASM_TARGET, + auditManifestAlignment, + verifyStellarSdkSorobanEncodingParity, + type PackageManifestLike, +} from "../lib/dependency-cadence"; + +const rootDir = process.cwd(); +const rootPkg = JSON.parse( + readFileSync(path.join(rootDir, "package.json"), "utf8"), +) as PackageManifestLike; +const scriptsPkg = JSON.parse( + readFileSync(path.join(rootDir, "scripts", "package.json"), "utf8"), +) as PackageManifestLike; + +const phaseCargo = readFileSync( + path.join(rootDir, "contracts", "phase-protocol", "Cargo.toml"), + "utf8", +); +const tokenCargo = readFileSync( + path.join(rootDir, "contracts", "mock-token", "Cargo.toml"), + "utf8", +); + +function extractCargoDepVersion(toml: string, dep: string): string { + const m = new RegExp(`${dep}\\s*=\\s*"([^"]+)"`).exec(toml); + return m?.[1] ?? "0.0.0"; +} + +const alignment = auditManifestAlignment({ + rootPkg, + scriptsPkg, + cargoCrates: [ + { + crateName: "phase-protocol", + sorobanSdkVersion: extractCargoDepVersion(phaseCargo, "soroban-sdk"), + sorobanTokenSdkVersion: extractCargoDepVersion(phaseCargo, "soroban-token-sdk"), + wasmTarget: CANONICAL_WASM_TARGET, + }, + { + crateName: "mock-token", + sorobanSdkVersion: extractCargoDepVersion(tokenCargo, "soroban-sdk"), + wasmTarget: CANONICAL_WASM_TARGET, + }, + ], + transpilePackages: ["@creit.tech/stellar-wallets-kit"], +}); + +const sdkParity = verifyStellarSdkSorobanEncodingParity(); + +console.log( + JSON.stringify({ + event: "dependency_cadence_audit", + ok: alignment.ok && sdkParity.ok, + alignment, + sdkParity, + }), +); + +if (!alignment.ok || !sdkParity.ok) { + process.exit(1); +} diff --git a/scripts/deploy-phase-sep50.ts b/scripts/deploy-phase-sep50.ts index f63111d4..2da400a5 100644 --- a/scripts/deploy-phase-sep50.ts +++ b/scripts/deploy-phase-sep50.ts @@ -13,6 +13,7 @@ */ import { config } from "dotenv" +import { createHash } from "crypto" import { execFileSync } from "child_process" import { readFileSync, writeFileSync, existsSync } from "fs" import { resolve, dirname } from "path" @@ -52,6 +53,10 @@ function main() { process.exit(1) } + // Spike 608 (phase-175): log canonical target and WASM SHA-256 digest before staging/testnet deploy + const wasmDigest = createHash("sha256").update(readFileSync(wasm)).digest("hex") + console.log(`[deploy-phase-sep50] target=wasm32-unknown-unknown sha256=${wasmDigest}`) + const admin = Keypair.fromSecret(secret).publicKey() const env = { ...process.env, STELLAR_ACCOUNT: secret } diff --git a/scripts/interact.ts b/scripts/interact.ts index ca3ce253..15328e22 100644 --- a/scripts/interact.ts +++ b/scripts/interact.ts @@ -50,6 +50,7 @@ const header = () => { console.log(chalk.gray(' CORE:') + ' ' + chalk.green(PHASE_PROTOCOL_ID)); console.log(chalk.gray(' ASSET:') + ' ' + chalk.green(MOCK_TOKEN_ID)); console.log(chalk.gray(' WALLET:') + ' ' + chalk.green(ADMIN_ADDRESS)); + console.log(chalk.gray(' SDK:') + ' ' + chalk.green('stellar-sdk v15 · soroban-sdk 22 (wasm32-unknown-unknown)')); console.log(); }; diff --git a/scripts/package-lock.json b/scripts/package-lock.json index 3f339558..cd5302bf 100644 --- a/scripts/package-lock.json +++ b/scripts/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@stellar/freighter-api": "^6.0.1", - "@stellar/stellar-sdk": "^12.0.0", + "@stellar/stellar-sdk": "^15.0.1", "chalk": "^5.6.2", "dotenv": "^16.4.5", "zod": "^3.25.76" @@ -515,41 +515,53 @@ } }, "node_modules/@stellar/js-xdr": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", - "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", - "license": "Apache-2.0" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-4.0.0.tgz", + "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0", + "pnpm": ">=9.0.0" + } }, "node_modules/@stellar/stellar-base": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-12.1.1.tgz", - "integrity": "sha512-gOBSOFDepihslcInlqnxKZdIW9dMUO1tpOm3AtJR33K2OvpXG6SaVHCzAmCFArcCqI9zXTEiSoh70T48TmiHJA==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-15.0.0.tgz", + "integrity": "sha512-XQhxUr9BYiEcFcgc4oWcCMR9QJCny/GmmGsuwPKf/ieIcOeb5149KLHYx9mJCA0ea8QbucR2/GzV58QbXOTxQA==", "license": "Apache-2.0", "dependencies": { - "@stellar/js-xdr": "^3.1.2", + "@noble/curves": "^1.9.7", + "@stellar/js-xdr": "^4.0.0", "base32.js": "^0.1.0", - "bignumber.js": "^9.1.2", + "bignumber.js": "^9.3.1", "buffer": "^6.0.3", - "sha.js": "^2.3.6", - "tweetnacl": "^1.0.3" + "sha.js": "^2.4.12" }, - "optionalDependencies": { - "sodium-native": "^4.1.1" + "engines": { + "node": ">=20.0.0" } }, "node_modules/@stellar/stellar-sdk": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-12.3.0.tgz", - "integrity": "sha512-F2DYFop/M5ffXF0lvV5Ezjk+VWNKg0QDX8gNhwehVU3y5LYA3WAY6VcCarMGPaG9Wdgoeh1IXXzOautpqpsltw==", + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-15.0.1.tgz", + "integrity": "sha512-iZjWKXtfohsPh+CX9wRyQNIlXLeA9VyuQB6UMC7AFBD9XnR92eOjnlfeONzk/Bsrkk6+UPlpzSy2MuF+ydHP1A==", "license": "Apache-2.0", "dependencies": { - "@stellar/stellar-base": "^12.1.1", - "axios": "^1.7.7", - "bignumber.js": "^9.1.2", + "@stellar/stellar-base": "^15.0.0", + "axios": "1.14.0", + "bignumber.js": "^9.3.1", + "commander": "^14.0.3", "eventsource": "^2.0.2", + "feaxios": "^0.0.23", "randombytes": "^2.1.0", "toml": "^3.0.0", - "urijs": "^1.19.1" + "urijs": "^1.19.11" + }, + "bin": { + "stellar-js": "bin/stellar-js" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@tsconfig/node10": { diff --git a/scripts/package.json b/scripts/package.json index c4910ee6..6aa5b36d 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -5,11 +5,12 @@ "type": "module", "scripts": { "diagnose": "cd .. && npm run diagnose", + "deps:cadence": "cd .. && npm run deps:cadence", "build:token": "cd ../contracts/mock-token && cargo build --target wasm32-unknown-unknown --release", - "build:phase": "cd ../contracts/phase-protocol && cargo build --target wasm32v1-none --release", + "build:phase": "cd ../contracts/phase-protocol && cargo build --target wasm32-unknown-unknown --release", "build:all": "npm run build:token && npm run build:phase", "optimize:token": "stellar contract optimize --wasm ../contracts/mock-token/target/wasm32-unknown-unknown/release/mock_token.wasm --wasm-out ../contracts/mock-token/target/wasm32-unknown-unknown/release/mock_token.optimized.wasm", - "optimize:phase": "stellar contract optimize --wasm ../contracts/phase-protocol/target/wasm32v1-none/release/phase_protocol.wasm --wasm-out ../contracts/phase-protocol/target/wasm32v1-none/release/phase_protocol.optimized.wasm", + "optimize:phase": "stellar contract optimize --wasm ../contracts/phase-protocol/target/wasm32-unknown-unknown/release/phase_protocol.wasm --wasm-out ../contracts/phase-protocol/target/wasm32-unknown-unknown/release/phase_protocol.optimized.wasm", "optimize:all": "npm run optimize:token && npm run optimize:phase", "compile": "npm run build:all && npm run optimize:all", "deploy:token": "tsx deploy-token.ts", @@ -27,7 +28,7 @@ }, "dependencies": { "@stellar/freighter-api": "^6.0.1", - "@stellar/stellar-sdk": "^12.0.0", + "@stellar/stellar-sdk": "^15.0.1", "chalk": "^5.6.2", "dotenv": "^16.4.5", "zod": "^3.25.76" diff --git a/scripts/setup-phase-v2.ts b/scripts/setup-phase-v2.ts index 8b3ed5b6..79b09a39 100644 --- a/scripts/setup-phase-v2.ts +++ b/scripts/setup-phase-v2.ts @@ -47,6 +47,11 @@ function isPhase124Enabled(): boolean { return v === "1" || v === "true" || v === "yes" || v === "on" } +/** Spike 608 (phase-175): verify @stellar/stellar-sdk v15 StrKey parity in scripts. */ +export function assertScriptsStellarSdkV15Ready(pubKey: string): boolean { + return StrKey.isValidEd25519PublicKey(pubKey) +} + const SetupMetadataV1Schema = z.object({ version: z.literal(1).optional(), name: z.string().min(1).max(256),