From 4e0f79451838c079135df401d8128f125a738238 Mon Sep 17 00:00:00 2001 From: Nursca Date: Mon, 19 Oct 2026 22:04:20 +0100 Subject: [PATCH] feat: implement A/B experimentation for forge pricing and copy (phase-174) - Introduced a new module for A/B experimentation that assigns subjects to weighted variants of an experiment. - Added functionality to resolve forge prices based on assigned variants and handle rollbacks. - Implemented subject hashing for privacy and deterministic bucketing for variant assignment. - Created a mechanism to load and parse experiment configurations from environment variables. feat: add billing and metering for paid forge tiers (phase-177) - Developed a billing module to meter forge jobs per wallet per UTC day, enforcing daily quotas. - Configurable daily limits for different billing tiers, with a fallback to default values. - Implemented functions to check quotas and record billing events, ensuring idempotency by job ID. feat: introduce search analytics for query logging and effectiveness (phase-175) - Created a search query logging module to track queries, clicks, and zero-result rates. - Implemented functionality to aggregate search analytics and compute various metrics. - Ensured privacy by hashing queries and viewer wallets, preventing raw data exposure. --- PROJECT_ARCHITECTURE.md | 4 + app/api/agent/forge/route.ts | 47 ++- app/api/cost-budgets/health/route.ts | 32 ++ app/api/cron/cost-budgets/route.ts | 48 +++ app/api/experiments/forge/route.ts | 43 +++ app/api/forge-agent/route.ts | 86 ++++- app/api/profile/search/route.ts | 15 + app/api/search/click/route.ts | 47 +++ app/api/world/search/route.ts | 18 + app/api/x402/route.ts | 23 +- app/api/x402/settle/route.ts | 9 +- app/api/x402/verify/route.ts | 11 +- app/forge/page.tsx | 24 +- components/fusion-chamber.tsx | 26 +- docs/TECHNICAL.md | 37 ++ .../spikes/612-ab-experimentation-findings.md | 28 ++ docs/spikes/613-search-analytics-findings.md | 28 ++ .../614-cost-accounting-budgets-findings.md | 28 ++ .../615-forge-billing-metering-findings.md | 28 ++ lib/__tests__/cost-budgets.test.ts | 127 +++++++ lib/__tests__/experiments.test.ts | 176 +++++++++ lib/__tests__/forge-billing.test.ts | 130 +++++++ lib/__tests__/search-query-log.test.ts | 122 ++++++ lib/cost-budgets.ts | 308 +++++++++++++++ lib/experiments.ts | 352 ++++++++++++++++++ lib/feature-flags.ts | 22 +- lib/forge/billing.ts | 251 +++++++++++++ lib/forge/payment-verifier.ts | 44 ++- lib/phase-copy.ts | 59 +++ lib/search-query-log.ts | 285 ++++++++++++++ lib/server-data-paths.ts | 4 + vercel.json | 4 + 32 files changed, 2422 insertions(+), 44 deletions(-) create mode 100644 app/api/cost-budgets/health/route.ts create mode 100644 app/api/cron/cost-budgets/route.ts create mode 100644 app/api/experiments/forge/route.ts create mode 100644 app/api/search/click/route.ts create mode 100644 docs/spikes/612-ab-experimentation-findings.md create mode 100644 docs/spikes/613-search-analytics-findings.md create mode 100644 docs/spikes/614-cost-accounting-budgets-findings.md create mode 100644 docs/spikes/615-forge-billing-metering-findings.md create mode 100644 lib/__tests__/cost-budgets.test.ts create mode 100644 lib/__tests__/experiments.test.ts create mode 100644 lib/__tests__/forge-billing.test.ts create mode 100644 lib/__tests__/search-query-log.test.ts create mode 100644 lib/cost-budgets.ts create mode 100644 lib/experiments.ts create mode 100644 lib/forge/billing.ts create mode 100644 lib/search-query-log.ts diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index db18ba82..ba1557ad 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -211,6 +211,10 @@ 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-174` | `NEXT_PUBLIC_FEATURE_PHASE_174` / `FEATURE_PHASE_174` | A/B experimentation framework (`lib/experiments.ts`): sticky salted bucketing for forge/chamber copy and pricing variants, `experiment-assignments.json` persistence (hashed subjects), variant-aware 402 challenge/verifier floor, `GET /api/experiments/forge`. Spike 612 (issue #475) | off | Unset var, restart — all resolvers return control price/copy, experiments route 404s, assignments sidecar stays inert | +| `phase-175` | `NEXT_PUBLIC_FEATURE_PHASE_175` / `FEATURE_PHASE_175` | Search analytics (`lib/search-query-log.ts`): hashed query logs for world/profile search, zero-result rate, clickthrough beacons (`POST /api/search/click`), typo-correction effectiveness, closed counters. Spike 613 (issue #476) | off | Unset var, restart — recording no-ops, click route 404s, `search-query-log.json` stays inert | +| `phase-176` | `NEXT_PUBLIC_FEATURE_PHASE_176` / `FEATURE_PHASE_176` | Cost accounting per surface (`lib/cost-budgets.ts`): env budgets, persisted daily burn windows, transition-only webhook alerts, kill-switch (`503` + `Retry-After` on halted surfaces), hourly cron + health route. Spike 614 (issue #477) | off | Unset var, restart — charging no-ops, `isSurfaceHalted` always false, cron reports disabled, `cost-budgets.json` stays inert | +| `phase-177` | `NEXT_PUBLIC_FEATURE_PHASE_177` / `FEATURE_PHASE_177` | Forge billing and metering (`lib/forge/billing.ts`): per-wallet UTC-day quota with tier limits (`PHASE_FORGE_BILLING_TIERS`), idempotent usage events by jobId, `429` + `Retry-After` quota refusal, `forge-billing.json` sidecar. Spike 615 (issue #478) | off | Unset var, restart — quota always allows, recording no-ops, billing sidecar stays inert | diff --git a/app/api/agent/forge/route.ts b/app/api/agent/forge/route.ts index 92a1af7d..99b4c114 100644 --- a/app/api/agent/forge/route.ts +++ b/app/api/agent/forge/route.ts @@ -19,7 +19,10 @@ 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 { PHASER_LIQ_SYMBOL, stroopsToLiqDisplay } from "@/lib/phase-protocol" +import { resolveForgePaymentAmount } from "@/lib/forge/payment-verifier" +import { checkForgeQuota, forgeBillingRetryAfterSeconds, recordForgeBillingEvent } from "@/lib/forge/billing" +import { isSurfaceHalted } from "@/lib/cost-budgets" export const runtime = "nodejs" export const maxDuration = 180 @@ -326,6 +329,32 @@ export async function POST(request: NextRequest): Promise 0) { challenge.amount = requiredAmount } @@ -383,6 +416,14 @@ export async function POST(request: NextRequest): Promise undefined) + // 7. Upload image to IPFS (best-effort) const ipfsCid = await uploadImageToIpfs(imageUrl) diff --git a/app/api/cost-budgets/health/route.ts b/app/api/cost-budgets/health/route.ts new file mode 100644 index 00000000..dbde05a9 --- /dev/null +++ b/app/api/cost-budgets/health/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server" +import { + COST_SURFACES, + evaluateSurfaceBudgets, + isCostBudgetsEnabled, + isSurfaceHalted, + snapshotCostBudgetCounters, +} from "@/lib/cost-budgets" + +export const dynamic = "force-dynamic" + +/** + * GET /api/cost-budgets/health — spike 614 (phase-176). + * + * Operator view of per-surface budgets, burn rate and kill-switch state. + * Evaluation runs without alerts (alerts belong to the cron) so a dashboard + * poll cannot spam webhooks. + */ +export async function GET() { + if (!isCostBudgetsEnabled()) { + return NextResponse.json({ error: "phase-176 flag disabled" }, { status: 404 }) + } + const evaluation = await evaluateSurfaceBudgets({ sendAlerts: false }) + return NextResponse.json({ + ok: true, + surfaces: evaluation.surfaces, + halted: evaluation.halted, + killSwitch: Object.fromEntries(COST_SURFACES.map((s) => [s, isSurfaceHalted(s)])), + counters: snapshotCostBudgetCounters(), + timestamp: new Date().toISOString(), + }) +} diff --git a/app/api/cron/cost-budgets/route.ts b/app/api/cron/cost-budgets/route.ts new file mode 100644 index 00000000..3d508b5a --- /dev/null +++ b/app/api/cron/cost-budgets/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server" +import { evaluateSurfaceBudgets } from "@/lib/cost-budgets" + +export const dynamic = "force-dynamic" +export const maxDuration = 60 + +function validateCronAuth(req: NextRequest): boolean { + const authHeader = req.headers.get("authorization") + const cronSecret = process.env.CRON_SECRET?.trim() + + if (process.env.NODE_ENV === "development" && !cronSecret) { + return true + } + if (req.headers.get("x-vercel-cron")) { + return true + } + if (!cronSecret) return false + if (!authHeader || !authHeader.startsWith("Bearer ")) return false + return authHeader.substring(7) === cronSecret +} + +/** + * GET /api/cron/cost-budgets — spike 614 (phase-176). + * + * Hourly evaluation of per-surface cost budgets: rolls windows, refreshes the + * halt cache, and fires deduped webhook alerts on warning/critical/recovery + * transitions. Response envelope matches the faucet-health cron. + */ +export async function GET(req: NextRequest) { + if (!validateCronAuth(req)) { + return NextResponse.json({ error: "Unauthorized - valid cron secret required" }, { status: 401 }) + } + try { + const evaluation = await evaluateSurfaceBudgets({ sendAlerts: true }) + return NextResponse.json({ + ok: true, + status: evaluation.halted.length > 0 ? "halted" : evaluation.enabled ? "ok" : "disabled", + message: evaluation.enabled + ? `${evaluation.surfaces.length} surfaces evaluated, ${evaluation.halted.length} halted, ${evaluation.alerted.length} alerted` + : "phase-176 flag disabled", + results: evaluation, + timestamp: new Date().toISOString(), + }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return NextResponse.json({ ok: false, error: msg }, { status: 500 }) + } +} diff --git a/app/api/experiments/forge/route.ts b/app/api/experiments/forge/route.ts new file mode 100644 index 00000000..35cd910c --- /dev/null +++ b/app/api/experiments/forge/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server" +import { isExperimentationEnabled, resolveForgeExperimentCopyKey, resolveForgeRequiredAmountStroops } from "@/lib/experiments" +import { forgePriceDisplay } from "@/lib/forge/payment-verifier" +import { pickForgeExperimentCopy } from "@/lib/phase-copy" + +export const dynamic = "force-dynamic" + +/** + * GET /api/experiments/forge — spike 612 (phase-174). + * + * Public, read-only view of the forge price/copy experiment for a subject. + * The wallet is hashed server-side and never echoed or stored raw; the sticky + * assignment store is only touched through the resolver. 404 when the flag is + * off so the route is invisible in the zero-regression path. + */ +export async function GET(request: NextRequest) { + if (!isExperimentationEnabled()) { + return NextResponse.json({ error: "phase-174 flag disabled" }, { status: 404 }) + } + const wallet = request.nextUrl.searchParams.get("wallet")?.trim() || undefined + const lang = request.nextUrl.searchParams.get("lang")?.trim() + + const [price, copyKey] = await Promise.all([ + resolveForgeRequiredAmountStroops(wallet), + resolveForgeExperimentCopyKey(wallet), + ]) + const variantCopy = pickForgeExperimentCopy(lang, copyKey) + + return NextResponse.json({ + experimentId: price.experimentId, + variantId: price.variantId, + copyKey, + amountStroops: price.amountStroops, + priceDisplay: forgePriceDisplay(price.amountStroops), + label: variantCopy.label, + copy: { + // `{amount}` is left intact: the forge page substitutes the variant + // price, the chamber substitutes its on-chain collection price. + paymentGateExplainer: variantCopy.paymentGateExplainer, + paymentGateHint: variantCopy.paymentGateHint, + }, + }) +} diff --git a/app/api/forge-agent/route.ts b/app/api/forge-agent/route.ts index 6fc5cd1c..40d39136 100644 --- a/app/api/forge-agent/route.ts +++ b/app/api/forge-agent/route.ts @@ -8,11 +8,18 @@ import { extractSettlementReceiptTxHash, buildLegacyChallenge, forgePriceDisplay, + resolveForgePaymentAmount, X402_NETWORK, } from "@/lib/forge/payment-verifier" import { runForgePipeline } from "@/lib/forge/pipeline" -import { tokenContractIdForServer, REQUIRED_AMOUNT } from "@/lib/phase-protocol" +import { tokenContractIdForServer } from "@/lib/phase-protocol" import { isSettlementUsed, markSettlementUsedIfUnused } from "@/lib/settlement-store" +import { + checkForgeQuota, + recordForgeBillingEvent, + forgeBillingRetryAfterSeconds, +} from "@/lib/forge/billing" +import { chargeSurfaceCost, isSurfaceHalted } from "@/lib/cost-budgets" import { nanobananaApiKeyConfigured, nanobananaAsyncWebhookEnabled, @@ -29,15 +36,19 @@ export const dynamic = "force-dynamic" const PHASE_LIQ_TOKEN_CONTRACT = tokenContractIdForServer() const ERR_SETTLEMENT_REJECTED = "[ ERROR: SETTLEMENT_REJECTED_BY_FACILITATOR ]" -function paymentRequiredResponse(request: NextRequest) { +async function paymentRequiredResponse(request: NextRequest, payerAddress?: string) { const origin = request.nextUrl.origin - const challenge = buildLegacyChallenge(origin, request.nextUrl.pathname) - const paymentRequirements = buildOfficialPaymentRequirements(origin) + // Spike 612: challenge amount follows the payer's price variant (control when + // flag off / no payer / holdout). One resolved amount feeds challenge, header + // and verifier so the three can never diverge. + const amountStroops = await resolveForgePaymentAmount(payerAddress) + const challenge = buildLegacyChallenge(origin, request.nextUrl.pathname, amountStroops) + const paymentRequirements = buildOfficialPaymentRequirements(origin, amountStroops) const b64 = Buffer.from(JSON.stringify(challenge)).toString("base64") const body: Record = { success: false, error: "Payment Required", - priceDisplay: forgePriceDisplay(), + priceDisplay: forgePriceDisplay(amountStroops), message: "Se requiere pago en PHASELQ. Tras confirmar on-chain, reintenta con Authorization o settlementTxHash.", challenge, @@ -47,7 +58,7 @@ function paymentRequiredResponse(request: NextRequest) { status: 402, headers: { "WWW-Authenticate": `x402 token="${b64}", amount="${challenge.amount}", facilitator="${challenge.facilitator}", network="${X402_NETWORK}"`, - "X-Required-Amount": REQUIRED_AMOUNT, + "X-Required-Amount": amountStroops, "X-Token-Address": PHASE_LIQ_TOKEN_CONTRACT, "X-Facilitator": challenge.facilitator, "X-X402-Network": X402_NETWORK, @@ -82,7 +93,9 @@ export async function POST(request: NextRequest) { } warnPhaserLiqSacMismatchOnce(PHASE_LIQ_TOKEN_CONTRACT, "forge-agent") - const paymentRequirements = buildOfficialPaymentRequirements(request.nextUrl.origin) + const payerForPricing = body.payerAddress?.trim() + const requiredAmountStroops = await resolveForgePaymentAmount(payerForPricing) + const paymentRequirements = buildOfficialPaymentRequirements(request.nextUrl.origin, requiredAmountStroops) const auth = request.headers.get("authorization") const receipt = extractSettlementReceiptTxHash(auth, body) @@ -93,7 +106,7 @@ export async function POST(request: NextRequest) { { status: 403, headers: { "x-correlation-id": correlationId } }, ) } - if (resolution === "missing") return paymentRequiredResponse(request) + if (resolution === "missing") return paymentRequiredResponse(request, payerForPricing) if (receipt) { if (await isSettlementUsed(receipt)) { @@ -120,6 +133,42 @@ export async function POST(request: NextRequest) { const txHash = receipt ?? body.settlementTxHash?.trim() ?? "" + // Spike 614 (phase-176): budget kill-switch — a halted surface refuses new + // work with 503 + Retry-After before any spend happens. + if (isSurfaceHalted("forge")) { + return NextResponse.json( + { success: false, error: "FORGE_SURFACE_HALTED", retryAfterSeconds: 300 }, + { status: 503, headers: { "x-correlation-id": correlationId, "Retry-After": "300" } }, + ) + } + + // Spike 615 (phase-177): paid-tier quota gate. Enforced after settlement + // verification so a quota-exhausted wallet cannot double-spend a settlement + // into a job; the settlement idempotency store above still fences replays. + const quotaPayer = payerForPricing ?? body.payerAddress?.trim() + if (quotaPayer) { + const quota = await checkForgeQuota(quotaPayer) + if (!quota.allowed) { + return NextResponse.json( + { + success: false, + error: "FORGE_QUOTA_EXCEEDED", + tierId: quota.tierId, + usedToday: quota.used, + dailyLimit: quota.limit, + resetAt: quota.resetAt, + }, + { + status: 429, + headers: { + "x-correlation-id": correlationId, + "Retry-After": String(forgeBillingRetryAfterSeconds(quota)), + }, + }, + ) + } + } + // ── Async mode: NanoBanana webhook enabled ────────────────────────────────── // When NANOBANANA_WEBHOOK_SECRET + NANOBANANA_CALLBACK_URL are both set, // submit the image generation task and return immediately with a jobId. @@ -152,6 +201,18 @@ export async function POST(request: NextRequest) { lang: body.lang, }) + // Spike 615/614: meter the accepted job (idempotent by jobId) and book + // the surface cost against the forge budget. + if (quotaPayer) { + await recordForgeBillingEvent({ + wallet: quotaPayer, + jobId: job.id, + settlementTxHash: txHash, + amountStroops: requiredAmountStroops, + }).catch(() => undefined) + } + void chargeSurfaceCost("forge", 20, { requestId: correlationId }).catch(() => undefined) + // Start lore generation in the background while image is being generated // (lore is independent of image and typically finishes in 3–10s) void (async () => { @@ -197,6 +258,15 @@ export async function POST(request: NextRequest) { { ...body, prompt: body.prompt as string }, correlationId, ) + if (quotaPayer) { + await recordForgeBillingEvent({ + wallet: quotaPayer, + jobId: result.jobId, + settlementTxHash: txHash, + amountStroops: requiredAmountStroops, + }).catch(() => undefined) + } + void chargeSurfaceCost("forge", 20, { requestId: correlationId }).catch(() => undefined) return NextResponse.json(result, { headers: { "x-correlation-id": correlationId } }) } catch (e) { const msg = e instanceof Error ? e.message : String(e) diff --git a/app/api/profile/search/route.ts b/app/api/profile/search/route.ts index a2a1afa9..4c1b349f 100644 --- a/app/api/profile/search/route.ts +++ b/app/api/profile/search/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server" import { readFile } from "node:fs/promises" import { serverDataJsonPath } from "@/lib/server-data-paths" +import { isSearchAnalyticsEnabled, recordSearchQuery, searchQueryHash } from "@/lib/search-query-log" import type { ProfileData } from "@/lib/profile-store" export const runtime = "nodejs" @@ -157,5 +158,19 @@ export async function GET(request: NextRequest) { } } + // Spike 613 (phase-175): non-blocking hashed query log. q<2 chars matches + // everything (browse), so only real queries (q>=2) are logged. + if (q.length >= 2 && isSearchAnalyticsEnabled()) { + const correctedFrom = request.nextUrl.searchParams.get("q0")?.trim() || undefined + void recordSearchQuery({ + surface: "profile", + kind: "query", + query_hash: searchQueryHash(q), + result_count: results.length, + session_id: request.nextUrl.searchParams.get("sid")?.trim() || undefined, + corrected_from_hash: correctedFrom ? searchQueryHash(correctedFrom) : undefined, + }).catch(() => undefined) + } + return NextResponse.json({ results, suggested, totalCollectors, nextCursor }) } diff --git a/app/api/search/click/route.ts b/app/api/search/click/route.ts new file mode 100644 index 00000000..83a0efed --- /dev/null +++ b/app/api/search/click/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server" +import { + isSearchAnalyticsEnabled, + recordSearchClick, + searchQueryHash, + type SearchSurface, +} from "@/lib/search-query-log" + +export const dynamic = "force-dynamic" + +/** + * POST /api/search/click — spike 613 (phase-175). + * + * Clickthrough beacon for search results. Body: { surface, query, result_ref?, session_id? }. + * Only the sha256 of the query and result reference are stored. 404 when the + * flag is off; 400 on invalid input; 204 on accept. + */ +export async function POST(request: NextRequest) { + if (!isSearchAnalyticsEnabled()) { + return NextResponse.json({ error: "phase-175 flag disabled" }, { status: 404 }) + } + let body: { surface?: string; query?: string; result_ref?: string; session_id?: string } + try { + body = (await request.json()) as typeof body + } catch { + return NextResponse.json({ error: "invalid JSON" }, { status: 400 }) + } + const surface = body.surface?.trim() as SearchSurface | undefined + const query = body.query?.trim() ?? "" + if (!surface || !query) { + return NextResponse.json({ error: "surface and query are required" }, { status: 400 }) + } + try { + const event = await recordSearchClick({ + surface, + query, + resultRef: body.result_ref?.trim() || undefined, + sessionId: body.session_id?.trim() || undefined, + }) + return NextResponse.json( + { ok: true, recorded: event !== null, query_hash: searchQueryHash(query) }, + { status: 200 }, + ) + } catch { + return NextResponse.json({ error: "invalid search click payload" }, { status: 400 }) + } +} diff --git a/app/api/world/search/route.ts b/app/api/world/search/route.ts index cfbf1ecf..15919b57 100644 --- a/app/api/world/search/route.ts +++ b/app/api/world/search/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { isNarrativeSearchEnabled, searchNarratives } from "@/lib/narrative-search" +import { isSearchAnalyticsEnabled, recordSearchQuery, searchQueryHash } from "@/lib/search-query-log" export const runtime = "nodejs" export const dynamic = "force-dynamic" @@ -21,6 +22,23 @@ export async function GET(request: NextRequest) { const location = params.get("location")?.trim() || undefined const text = params.get("q")?.trim() || undefined + const startedAt = Date.now() const results = await searchNarratives({ entity, location, text }) + + // Spike 613 (phase-175): non-blocking hashed query log — only free-text + // searches are logged (entity/location filters are not typo-relevant). + if (text && isSearchAnalyticsEnabled()) { + const correctedFrom = params.get("q0")?.trim() || undefined + void recordSearchQuery({ + surface: "world", + kind: "query", + query_hash: searchQueryHash(text), + result_count: results.length, + duration_ms: Date.now() - startedAt, + session_id: params.get("sid")?.trim() || undefined, + corrected_from_hash: correctedFrom ? searchQueryHash(correctedFrom) : undefined, + }).catch(() => undefined) + } + return NextResponse.json({ results, total: results.length }) } diff --git a/app/api/x402/route.ts b/app/api/x402/route.ts index 2e75b514..03deeca6 100644 --- a/app/api/x402/route.ts +++ b/app/api/x402/route.ts @@ -6,6 +6,7 @@ import { stroopsToLiqDisplay, tokenContractIdForServer, } from "@/lib/phase-protocol" +import { resolveForgeRequiredAmountStroops } from "@/lib/experiments" export const dynamic = 'force-dynamic' @@ -28,8 +29,8 @@ type LocalX402Token = { contract_id?: string } -function parseRequiredAmount(): number { - const parsed = Number.parseInt(REQUIRED_AMOUNT, 10) +function parseRequiredAmount(amountStroops: string = REQUIRED_AMOUNT): number { + const parsed = Number.parseInt(amountStroops, 10) return Number.isFinite(parsed) && parsed > 0 ? parsed : 0 } @@ -49,10 +50,10 @@ function decodeX402Token(raw: string): LocalX402Token | null { } } -function isSatisfiedPayment(payload: LocalX402Token | null): boolean { +function isSatisfiedPayment(payload: LocalX402Token | null, requiredStroops: string = REQUIRED_AMOUNT): boolean { if (!payload) return false const amount = Number(payload.amount) - return Boolean(payload.invoice) && Number.isFinite(amount) && amount >= parseRequiredAmount() + return Boolean(payload.invoice) && Number.isFinite(amount) && amount >= parseRequiredAmount(requiredStroops) } export async function GET(request: NextRequest) { @@ -61,11 +62,14 @@ export async function GET(request: NextRequest) { } const authHeader = request.headers.get("authorization") const facilitator = facilitatorUrl(request) + // Spike 612: variant-aware floor when a payer address is supplied; control otherwise. + const payerAddress = request.nextUrl.searchParams.get("payer")?.trim() || undefined + const requiredStroops = (await resolveForgeRequiredAmountStroops(payerAddress)).amountStroops if (authHeader && authHeader.startsWith("x402 ")) { const token = authHeader.replace("x402 ", "").trim() const payload = decodeX402Token(token) - if (isSatisfiedPayment(payload)) { + if (isSatisfiedPayment(payload, requiredStroops)) { return NextResponse.json({ status: "success", data: { @@ -85,8 +89,8 @@ export async function GET(request: NextRequest) { token: PHASE_LIQ_TOKEN_CONTRACT, contract_id: PHASE_PROTOCOL_CONTRACT, token_contract: PHASE_LIQ_TOKEN_CONTRACT, - amount: parseRequiredAmount(), - priceDisplay: `${stroopsToLiqDisplay(REQUIRED_AMOUNT)} ${PHASER_LIQ_SYMBOL}`, + amount: parseRequiredAmount(requiredStroops), + priceDisplay: `${stroopsToLiqDisplay(requiredStroops)} ${PHASER_LIQ_SYMBOL}`, facilitator, invoice: `inv_${Date.now()}`, resource: request.nextUrl.pathname, @@ -104,7 +108,7 @@ export async function GET(request: NextRequest) { status: 402, headers: { "WWW-Authenticate": `x402 token="${challengeBase64}", amount="${challenge.amount}", facilitator="${facilitator}", network="${X402_NETWORK}"`, - "X-Required-Amount": REQUIRED_AMOUNT, + "X-Required-Amount": requiredStroops, "X-Token-Address": PHASE_LIQ_TOKEN_CONTRACT, "X-Facilitator": facilitator, "X-X402-Network": X402_NETWORK, @@ -133,7 +137,8 @@ export async function POST(request: NextRequest) { } const payload = decodeX402Token(payment_token) - if (!payload || !isSatisfiedPayment(payload)) { + const requiredStroops = (await resolveForgeRequiredAmountStroops(user_address)).amountStroops + if (!payload || !isSatisfiedPayment(payload, requiredStroops)) { return NextResponse.json({ error: "Invalid or insufficient x402 payment token" }, { status: 401 }) } diff --git a/app/api/x402/settle/route.ts b/app/api/x402/settle/route.ts index bc717378..3bf1fbf0 100644 --- a/app/api/x402/settle/route.ts +++ b/app/api/x402/settle/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { REQUIRED_AMOUNT, tokenContractIdForServer } from "@/lib/phase-protocol" +import { resolveForgeRequiredAmountStroops } from "@/lib/experiments" export const dynamic = 'force-dynamic' @@ -25,10 +26,10 @@ function decodeX402Token(raw: string): LocalX402Token | null { } } -function isSatisfiedPayment(payload: LocalX402Token | null): boolean { +function isSatisfiedPayment(payload: LocalX402Token | null, requiredStroops: string = REQUIRED_AMOUNT): boolean { if (!payload) return false const amount = Number(payload.amount) - const required = Number.parseInt(REQUIRED_AMOUNT, 10) + const required = Number.parseInt(requiredStroops, 10) return Boolean(payload.invoice) && Number.isFinite(amount) && Number.isFinite(required) && amount >= required } @@ -45,7 +46,9 @@ export async function POST(request: NextRequest) { } const payload = decodeX402Token(token) - if (!isSatisfiedPayment(payload)) { + // Spike 612: variant-aware floor for the settling payer; control otherwise. + const requiredStroops = (await resolveForgeRequiredAmountStroops(user)).amountStroops + if (!isSatisfiedPayment(payload, requiredStroops)) { return NextResponse.json({ error: "Invalid or insufficient x402 payment token" }, { status: 401 }) } diff --git a/app/api/x402/verify/route.ts b/app/api/x402/verify/route.ts index ba50e779..20d88a35 100644 --- a/app/api/x402/verify/route.ts +++ b/app/api/x402/verify/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server" import { REQUIRED_AMOUNT } from "@/lib/phase-protocol" import { isSettlementUsed } from "@/lib/settlement-store" +import { resolveForgeRequiredAmountStroops } from "@/lib/experiments" export const dynamic = 'force-dynamic' @@ -24,10 +25,10 @@ function decodeX402Token(raw: string): LocalX402Token | null { } } -function isMaisonedPayment(payload: LocalX402Token | null): boolean { +function isMaisonedPayment(payload: LocalX402Token | null, requiredStroops: string = REQUIRED_AMOUNT): boolean { if (!payload) return false const amount = Number(payload.amount) - const required = Number.parseInt(REQUIRED_AMOUNT, 10) + const required = Number.parseInt(requiredStroops, 10) return Boolean(payload.invoice) && Number.isFinite(amount) && Number.isFinite(required) && amount >= required } @@ -36,12 +37,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "x402 local shim disabled" }, { status: 503 }) } try { - const body = (await request.json()) as { payment_token?: string } + const body = (await request.json()) as { payment_token?: string; user_address?: string } const token = body.payment_token?.trim() if (!token) return NextResponse.json({ error: "Missing payment_token" }, { status: 400 }) const payload = decodeX402Token(token) - const verified = isMaisonedPayment(payload) + // Spike 612: variant-aware floor when the caller supplies the payer; control otherwise. + const requiredStroops = (await resolveForgeRequiredAmountStroops(body.user_address?.trim())).amountStroops + const verified = isMaisonedPayment(payload, requiredStroops) if (verified && typeof payload?.invoice === 'string') { const used = await isSettlementUsed(payload.invoice) diff --git a/app/forge/page.tsx b/app/forge/page.tsx index 3bcdf7e1..861a364d 100644 --- a/app/forge/page.tsx +++ b/app/forge/page.tsx @@ -225,6 +225,28 @@ export default function ForgePage() { const { address, connect, disconnect, connecting, refresh, isHardwareWallet, requestLedgerReview } = useWallet() + // Spike 612 (phase-174): variant hint shown while the 402 gate is open. The + // challenge amount itself is resolved server-side; this is display copy only. + const [expGateHint, setExpGateHint] = useState(null) + useEffect(() => { + if (!isFeatureEnabled("phase-174")) return + let cancelled = false + const params = new URLSearchParams({ lang }) + if (address) params.set("wallet", address) + void fetch(`/api/experiments/forge?${params.toString()}`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + const hint = data?.copy?.paymentGateHint + if (!cancelled && typeof hint === "string" && data?.variantId !== "control") { + setExpGateHint(hint) + } + }) + .catch(() => undefined) + return () => { + cancelled = true + } + }, [address, lang]) + const [name, setName] = useState("") const [priceLiq, setPriceLiq] = useState("1") @@ -1071,7 +1093,7 @@ export default function ForgePage() { : agentState === "FORGING_MATTER" ? forgingTerminalLines[agentTickerIdx % forgingTerminalLines.length] : agentState === "AWAITING_PAYMENT" - ? f.forgeCtaGenerateContacting + ? expGateHint ?? f.forgeCtaGenerateContacting : "" const initiatePrimaryLabel = diff --git a/components/fusion-chamber.tsx b/components/fusion-chamber.tsx index 75ed1d1a..80dbd3f5 100644 --- a/components/fusion-chamber.tsx +++ b/components/fusion-chamber.tsx @@ -197,6 +197,30 @@ export function FusionChamber() { const [narrativeData, setNarrativeData] = useState(null) const [narrativeLoading, setNarrativeLoading] = useState(false) + // Spike 612 (phase-174): experiment copy variant for the payment gate. The + // chamber's actual price is the on-chain collection price (authoritative); + // only the explainer copy varies here — price variants live in the + // forge-agent 402 flow where the server resolves and verifies one amount. + const [expGateCopy, setExpGateCopy] = useState<{ paymentGateExplainer: string } | null>(null) + useEffect(() => { + if (!isFeatureEnabled("phase-174")) return + let cancelled = false + const params = new URLSearchParams({ lang }) + if (address) params.set("wallet", address) + void fetch(`/api/experiments/forge?${params.toString()}`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + const explainer = data?.copy?.paymentGateExplainer + if (!cancelled && typeof explainer === "string" && data?.variantId !== "control") { + setExpGateCopy({ paymentGateExplainer: explainer }) + } + }) + .catch(() => undefined) + return () => { + cancelled = true + } + }, [address, lang]) + const effectivePriceStroops = useMemo(() => { if (collectionId > 0 && collectionInfo?.price) return collectionInfo.price return REQUIRED_AMOUNT @@ -2334,7 +2358,7 @@ export function FusionChamber() { > {!canInitializeGenesisSupply && !processing && (

- {ch.paymentGateExplainer.replace("{amount}", stroopsToLiqDisplay(effectivePriceStroops))} + {(expGateCopy?.paymentGateExplainer ?? ch.paymentGateExplainer).replace("{amount}", stroopsToLiqDisplay(effectivePriceStroops))}

)}