diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c34a3675..5f9e2b52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,21 @@ jobs: cache: npm - name: Install locked dependencies run: npm ci + - name: API contract (breaking-change gate, spike 663) + # Breaking changes are measured against the target branch snapshot, so a + # regenerated docs/api/openapi.json cannot hide them. Exit 1 = breaking + # without a version bump + changelog entry, exit 2 = stale snapshot. + env: + BASE_REF: ${{ github.base_ref }} + run: | + BASE_ARGS="" + if [ -n "$BASE_REF" ]; then + git fetch --no-tags --depth=1 origin "$BASE_REF" + if git show "origin/$BASE_REF:docs/api/openapi.json" > "$RUNNER_TEMP/openapi-base.json" 2>/dev/null; then + BASE_ARGS="--base $RUNNER_TEMP/openapi-base.json" + fi + fi + npm run api:contract -- $BASE_ARGS - name: Lint # eslint had no config/devDependency before this change (npm run lint # failed outright); now that it runs, ~95 pre-existing errors surface @@ -52,10 +67,9 @@ jobs: continue-on-error: true run: npm run lint - name: Type check - # Pre-existing type errors exist outside this change's scope; surfaced but - # non-blocking until tracked and fixed separately. - continue-on-error: true run: npm run typecheck + - name: Unit tests + run: npm test - name: Build run: npm run build env: diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index db18ba82..0e857039 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -100,6 +100,7 @@ Owns: - **Deterministic status codes** (validation, cooldown, authorization, pending). - **No implicit success**: all state transitions explicit and auditable. - **Compatibility routes** allowed if typed and documented. +- **Versioned contract** (`phase-api-versioning`): `lib/api-contract.ts` is the source of truth for the public surface; `docs/api/openapi.json` and `docs/api/changelog.json` are rendered from it. Middleware negotiates `X-API-Version` / `Accept-Version` on `/api/*`, echoes `X-API-Version`, answers unknown versions with `406 UNSUPPORTED_API_VERSION` and sunset ones with `410 API_VERSION_SUNSET`, and adds `Deprecation` / `Sunset` / `Link` from `ROUTE_DEPRECATIONS`. CI blocks a breaking diff against the target branch unless a new version and a `breaking: true` changelog entry ship with it. - **Optimistic concurrency on shared mutable records**: a record that more than one client can edit carries an integer `version`. Reads publish it as an `ETag`; mutations may echo it back as `If-Match` (or `parent_version` for appends) and @@ -211,8 +212,7 @@ 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-api-versioning` | `NEXT_PUBLIC_FEATURE_PHASE_API_VERSIONING` / `FEATURE_PHASE_API_VERSIONING` | API versioning (spike 663 / #526): middleware negotiation for `/api/*`, `X-API-Version` echo, typed `406`/`410`, RFC 9745/8594 deprecation headers, `GET /api/versions` (summary or `?format=openapi`). The CI contract gate (`npm run api:contract`) runs regardless of the flag | off | Unset var, restart — no negotiation or version headers, `/api/versions` returns 404; nothing persisted | Flags are read via `lib/feature-flags.ts:isFeatureEnabled`. Client flags use `NEXT_PUBLIC_*`, server also accepts `FEATURE_*`. Zero regression when off. diff --git a/app/api/governance/ve-lock/route.ts b/app/api/governance/ve-lock/route.ts index af037f9b..2fec85f2 100644 --- a/app/api/governance/ve-lock/route.ts +++ b/app/api/governance/ve-lock/route.ts @@ -92,7 +92,7 @@ export async function POST(request: NextRequest) { if (!claimRes.ok) { return NextResponse.json({ error: claimRes.error }, { status: 400, headers: CORS }); } - return NextResponse.json({ ok: true, ...claimRes }, { status: 200, headers: CORS }); + return NextResponse.json({ ...claimRes }, { status: 200, headers: CORS }); } if (action === "withdraw") { @@ -100,7 +100,7 @@ export async function POST(request: NextRequest) { if (!withdrawRes.ok) { return NextResponse.json({ error: withdrawRes.error }, { status: 400, headers: CORS }); } - return NextResponse.json({ ok: true, ...withdrawRes }, { status: 200, headers: CORS }); + return NextResponse.json({ ...withdrawRes }, { status: 200, headers: CORS }); } if (action === "lock") { diff --git a/app/api/ipfs/[...cid]/route.ts b/app/api/ipfs/[...cid]/route.ts index d5b053be..18a516e0 100644 --- a/app/api/ipfs/[...cid]/route.ts +++ b/app/api/ipfs/[...cid]/route.ts @@ -50,7 +50,7 @@ export async function GET( // the 1-year shared cache. const { verifyCID, cacheControlForCid } = await import("@/lib/cid-verification") const requestedCid = parsed.data[0] ?? "" - const verification = verifyCID(requestedCid, result.bytes) + const verification = verifyCID(requestedCid, new Uint8Array(result.bytes)) const gated = request.headers.get("x-phase-gated") === "1" if (!verification.ok && verification.code === "CID_MISMATCH") { diff --git a/app/api/market/[id]/offers/[offer_id]/route.ts b/app/api/market/[id]/offers/[offer_id]/route.ts index 6fdf59f4..ec629c15 100644 --- a/app/api/market/[id]/offers/[offer_id]/route.ts +++ b/app/api/market/[id]/offers/[offer_id]/route.ts @@ -53,6 +53,7 @@ export async function POST( // If accepted, mark listing as sold let royalty: Awaited> | null = null + let splits: Awaited> | null = null if (action === "accept") { await soldListing(id) @@ -65,6 +66,12 @@ export async function POST( } } + // Spike 674 (flag-gated): N-way fan-out at accept (dust → first payee, idempotent per offer). + if (isPhaseCollabSplitsEnabled() && listing.split_payees && listing.split_payees.length > 0) { + const computed = computeCollabSplits(offer.amount_phaselq, listing.split_payees) + splits = await recordCollabSplits(id, offer.id, computed) + } + // phase-140: split proceeds with the original creator on a secondary sale. if (isPhase140Enabled()) { const split = computeRoyaltySplit(listing, offer.amount_phaselq) diff --git a/app/api/nft-index/subscribe/route.ts b/app/api/nft-index/subscribe/route.ts index d4eadd6a..83bfeccc 100644 --- a/app/api/nft-index/subscribe/route.ts +++ b/app/api/nft-index/subscribe/route.ts @@ -157,8 +157,21 @@ export function validateWebhookUrl( if (/^\d+$/.test(host) || /^\[/.test(host) || host.includes(":")) { return { ok: false, error: "webhookUrl must use a public hostname", reason: "ip_literal" } } - if (host === "169.254.169.254" || host.startsWith("10.") || host.startsWith("192.168.")) { - return { ok: false, error: "webhookUrl must use a public hostname", reason: "private_network" } + const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + if (v4) { + const [a, b] = [Number(v4[1]), Number(v4[2])] + if (a === 127 || a === 0) { + return { ok: false, error: "webhookUrl host is not routable", reason: "loopback" } + } + const isPrivate = + a === 10 || + (a === 100 && b >= 64 && b <= 127) || // CGNAT + (a === 169 && b === 254) || // link-local, incl. cloud metadata + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) + if (isPrivate) { + return { ok: false, error: "webhookUrl must use a public hostname", reason: "private_network" } + } } return { ok: true, url } } diff --git a/app/api/notifications/winback/route.ts b/app/api/notifications/winback/route.ts index 43dd8d2f..6e074534 100644 --- a/app/api/notifications/winback/route.ts +++ b/app/api/notifications/winback/route.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { isFeatureEnabled } from "@/lib/feature-flags"; import { evaluateWinbackCampaign, recordWinbackDispatch, getWinbackStore } from "@/lib/churn-winback"; -import { addNotification } from "@/lib/notification-store"; +import { createNotification } from "@/lib/notification-store"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -88,16 +88,12 @@ export async function POST(request: NextRequest) { if (body.dispatch && evaluation.eligible && evaluation.cohort !== "control") { await recordWinbackDispatch(wallet, evaluation.cohort, evaluation.hook, experimentId); try { - await addNotification({ - recipientWallet: wallet, - type: "system", + await createNotification(wallet, "winback_reactivation", { title: evaluation.title, body: evaluation.body, - data: { - cohort: evaluation.cohort, - hook: evaluation.hook, - experimentId, - }, + cohort: evaluation.cohort, + hook: evaluation.hook, + experimentId, }); dispatched = true; } catch { diff --git a/app/api/profile/avatar/route.ts b/app/api/profile/avatar/route.ts index 6028b5c7..498f1d20 100644 --- a/app/api/profile/avatar/route.ts +++ b/app/api/profile/avatar/route.ts @@ -259,13 +259,7 @@ export async function verifyAvatarOwnershipSignature( imageUrl: string, signatureBase64: string, ): Promise { - try { - const { Keypair } = await import("@stellar/stellar-sdk") - const { SIGNATURE_PREFIX, sha256Hex } = await import("@/lib/viewer-signature") - const message = `phase-avatar:v1:${await sha256Hex(JSON.stringify({ wallet, imageUrl }))}` - const data = new TextEncoder().encode(SIGNATURE_PREFIX + message) - return Keypair.fromPublicKey(wallet).verify(data, Buffer.from(signatureBase64, "base64")) - } catch { - return false - } + const { sha256Hex, verifySep53Signature } = await import("@/lib/viewer-signature") + const message = `phase-avatar:v1:${await sha256Hex(JSON.stringify({ wallet, imageUrl }))}` + return verifySep53Signature(wallet, message, signatureBase64) } \ No newline at end of file diff --git a/app/api/versions/route.ts b/app/api/versions/route.ts new file mode 100644 index 00000000..5b76343d --- /dev/null +++ b/app/api/versions/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { + API_CHANGELOG, + API_VERSIONS, + buildOpenApiDocument, + currentApiVersion, + ROUTE_DEPRECATIONS, +} from "@/lib/api-versioning" + +export const dynamic = "force-dynamic" + +/** phase-api-versioning: machine-readable versions, deprecations, changelog and OpenAPI contract. */ +export async function GET(request: NextRequest) { + if (!isFeatureEnabled("phase-api-versioning")) { + return NextResponse.json({ error: "Feature phase-api-versioning disabled" }, { status: 404 }) + } + const format = request.nextUrl.searchParams.get("format") + if (format === "openapi") { + return NextResponse.json(buildOpenApiDocument()) + } + if (format && format !== "summary") { + return NextResponse.json({ error: "format must be summary or openapi" }, { status: 400 }) + } + return NextResponse.json({ + current: currentApiVersion(), + versions: API_VERSIONS, + deprecations: ROUTE_DEPRECATIONS, + changelog: API_CHANGELOG, + }) +} diff --git a/components/chamber/artifact-viewer.tsx b/components/chamber/artifact-viewer.tsx deleted file mode 100644 index b3a42524..00000000 --- a/components/chamber/artifact-viewer.tsx +++ /dev/null @@ -1 +0,0 @@ -placeholder \ No newline at end of file diff --git a/components/chamber/catalog-thumb.tsx b/components/chamber/catalog-thumb.tsx new file mode 100644 index 00000000..81c5028f --- /dev/null +++ b/components/chamber/catalog-thumb.tsx @@ -0,0 +1,25 @@ +"use client" + +// Extracted verbatim from components/fusion-chamber.tsx (refactor #33 imported it but never committed the file). + +import { useState } from "react" +import { IpfsDisplayImg } from "@/components/ipfs-display-img" + +export function ChamberCatalogThumb({ collectionId, uri }: { collectionId: number; uri: string }) { + const [broken, setBroken] = useState(false) + if (!uri.trim() || broken) { + return ( + + #{collectionId} + + ) + } + return ( + setBroken(true)} + /> + ) +} diff --git a/components/chamber/certificate-exporter.tsx b/components/chamber/certificate-exporter.tsx deleted file mode 100644 index b3a42524..00000000 --- a/components/chamber/certificate-exporter.tsx +++ /dev/null @@ -1 +0,0 @@ -placeholder \ No newline at end of file diff --git a/components/chamber/chamber-chrome.ts b/components/chamber/chamber-chrome.ts new file mode 100644 index 00000000..9919347f --- /dev/null +++ b/components/chamber/chamber-chrome.ts @@ -0,0 +1,11 @@ +// Extracted verbatim from components/fusion-chamber.tsx (refactor #33 imported it but never committed the file). + +/** Same zinc / violet chrome as `/dashboard` (Phase Market). */ +export const chamberChromeNav = + "inline-flex min-h-[36px] items-center rounded-sm border border-zinc-700 bg-zinc-900/60 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-zinc-400 transition-colors hover:border-violet-500/50 hover:text-violet-300" +export const chamberChromeNavHere = + "inline-flex min-h-[36px] items-center rounded-sm border border-violet-600/50 bg-violet-950/50 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-violet-300" +export const chamberChromePrimaryBtn = + "flex min-h-[46px] w-full items-center justify-center bg-gradient-to-r from-violet-600 to-cyan-500 py-3 text-center text-[11px] font-bold uppercase tracking-wide text-white shadow-[0_0_18px_rgba(139,92,246,0.35)] transition-opacity hover:opacity-90 disabled:pointer-events-none" +export const chamberChromeRefreshBtn = + "flex items-center gap-1.5 rounded-sm border border-zinc-700 bg-zinc-900/60 px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-400 transition-colors hover:border-violet-500/50 hover:text-violet-300 disabled:opacity-40" diff --git a/components/chamber/energy-gauge-display.tsx b/components/chamber/energy-gauge-display.tsx deleted file mode 100644 index b3a42524..00000000 --- a/components/chamber/energy-gauge-display.tsx +++ /dev/null @@ -1 +0,0 @@ -placeholder \ No newline at end of file diff --git a/components/chamber/log-stream.tsx b/components/chamber/log-stream.tsx new file mode 100644 index 00000000..68ca66df --- /dev/null +++ b/components/chamber/log-stream.tsx @@ -0,0 +1,36 @@ +"use client" + +// Extracted verbatim from components/fusion-chamber.tsx (refactor #33 imported it but never committed the file). + +import type { RefObject } from "react" +import { cn } from "@/lib/utils" + +export function ChamberLogStream({ + lines, + endRef, +}: { + lines: { id: number; text: string }[] + endRef: RefObject +}) { + return ( + <> + {lines.map((l, i) => { + const fault = /FAIL|Failed|ERROR|FAULT|ABORT|REJECT|denied|timeout|sequence|desync/i.test(l.text) + return ( +
+ {l.text} +
+ ) + })} +
+ + ) +} diff --git a/components/chamber/phaser-liq-token-link.tsx b/components/chamber/phaser-liq-token-link.tsx new file mode 100644 index 00000000..d334c640 --- /dev/null +++ b/components/chamber/phaser-liq-token-link.tsx @@ -0,0 +1,45 @@ +"use client" + +// Extracted verbatim from components/fusion-chamber.tsx (refactor #33 imported it but never committed the file). + +import { TokenIcon } from "@/components/token-icon" +import { playTacticalUiClick } from "@/lib/tactical-ui-click" +import { cn } from "@/lib/utils" + +/** Enlace al asset PHASELQ en Stellar Expert (icono + símbolo). */ +export function PhaserLiqTokenLink({ + href, + symbol, + iconClassName, + className, + variant = "default", +}: { + href: string + symbol: string + iconClassName?: string + className?: string + variant?: "default" | "violet" +}) { + return ( + { + e.stopPropagation() + playTacticalUiClick() + }} + className={cn( + "inline-flex items-center gap-1.5 rounded-sm border border-transparent font-mono tracking-normal underline-offset-[3px] transition-colors hover:underline focus:outline-none", + variant === "violet" + ? "text-violet-200/95 hover:border-violet-500/45 hover:bg-violet-950/35 hover:text-violet-50 focus-visible:ring-2 focus-visible:ring-violet-400/55" + : "text-cyan-400/95 hover:border-cyan-500/45 hover:bg-cyan-950/35 hover:text-cyan-50 focus-visible:ring-2 focus-visible:ring-cyan-400/60", + className, + )} + > + + {symbol} + + ) +} diff --git a/components/chamber/settlement-wizard.tsx b/components/chamber/settlement-wizard.tsx deleted file mode 100644 index b3a42524..00000000 --- a/components/chamber/settlement-wizard.tsx +++ /dev/null @@ -1 +0,0 @@ -placeholder \ No newline at end of file diff --git a/components/chamber/terminal-header.tsx b/components/chamber/terminal-header.tsx deleted file mode 100644 index b3a42524..00000000 --- a/components/chamber/terminal-header.tsx +++ /dev/null @@ -1 +0,0 @@ -placeholder \ No newline at end of file diff --git a/components/fusion-chamber.tsx b/components/fusion-chamber.tsx index 75ed1d1a..34679ff8 100644 --- a/components/fusion-chamber.tsx +++ b/components/fusion-chamber.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck "use client" import { albedoImplicitTxAllowed, isAlbedoSelectedInKit } from "@/lib/albedo-intent-client" @@ -22,6 +21,7 @@ import { pickCopy } from "@/lib/phase-copy" import { cn } from "@/lib/utils" import { GatewayHealthDashboard } from "@/components/gateway-health-dashboard" import { QrScannerModal } from "@/components/qr-scanner-modal" +import type { ParsedQrPayload } from "@/lib/qr-code" import { recordMobileDeepLinkReturn } from "@/lib/walletconnect-resilience" import { isFeatureEnabled } from "@/lib/feature-flags" import { decodeSorobanTransaction } from "@/lib/ledger-soroban-decoder" @@ -69,18 +69,12 @@ import { stroopsToLiqDisplay, } from "@/lib/phase-protocol" import { PhaseArtifactVisualizer, type ArtifactVerificationMode } from "@/components/phase-artifact-visualizer" -import { TerminalHeader } from "@/components/chamber/terminal-header" -import { ArtifactViewer } from "@/components/chamber/artifact-viewer" -import { SettlementWizard } from "@/components/chamber/settlement-wizard" -import { EnergyGaugeDisplay } from "@/components/chamber/energy-gauge-display" -import { CertificateExporter } from "@/components/chamber/certificate-exporter" -import { useChamberSettlement } from "@/components/chamber/use-chamber-settlement" import { ChamberLogStream } from "@/components/chamber/log-stream" import { ChamberCatalogThumb } from "@/components/chamber/catalog-thumb" import { PhaserLiqTokenLink } from "@/components/chamber/phaser-liq-token-link" import { chamberChromeNav, chamberChromeNavHere, chamberChromePrimaryBtn, chamberChromeRefreshBtn } from "@/components/chamber/chamber-chrome" -/** Primer `G…` válido en el texto del portapapeles (una línea o varias). */ +/** Primer `G…` válido en el texto del portapapeles (una línea o varias). */ function recipientGFromClipboardText(text: string, isValidG: (addr: string) => boolean): string { const u = text.trim() if (isValidG(u)) return u @@ -98,7 +92,7 @@ type PaymentPhase = "idle" | "busy" | "error" function truncateAddress(addr: string) { if (!addr || addr.length < 14) return addr - return `${addr.slice(0, 6)}…${addr.slice(-4)}` + return `${addr.slice(0, 6)}…${addr.slice(-4)}` } function formatPowerBp(bp: number) { @@ -110,7 +104,7 @@ function formatPowerBp(bp: number) { -/** Enlace al asset PHASELQ en Stellar Expert (icono + símbolo). */ +/** Enlace al asset PHASELQ en Stellar Expert (icono + símbolo). */ /** Same zinc / violet chrome as `/dashboard` (Phase Market). */ @@ -148,7 +142,7 @@ export function FusionChamber() { /** Stroops en el SAC que el contrato PHASE usa en `settle` (puede ser 0 con saldo en otro emisor). */ const [tokenBalance, setTokenBalance] = useState("0") - /** Máximo PHASELQ visto (Horizon + SACs) — solo informativo si difiere de `tokenBalance`. */ + /** Máximo PHASELQ visto (Horizon + SACs) — solo informativo si difiere de `tokenBalance`. */ const [walletLiqTotal, setWalletLiqTotal] = useState("0") const [hasPhased, setHasPhased] = useState(null) const [phaseId, setPhaseId] = useState(null) @@ -158,7 +152,7 @@ export function FusionChamber() { const [genesisLoading, setGenesisLoading] = useState(false) const [faucetEnabled, setFaucetEnabled] = useState(null) const [systemDesync, setSystemDesync] = useState(false) - /** Pedestal: acuñación NFT de fase en curso */ + /** Pedestal: acuñación NFT de fase en curso */ const [mintingArtifact, setMintingArtifact] = useState(false) /** THE_REACTOR: POST /api/phase-nft/custodian-release (mismo flujo que el panel COLLECT). */ const [claimToWalletBusy, setClaimToWalletBusy] = useState(false) @@ -166,9 +160,9 @@ export function FusionChamber() { const [operatorModalOpen, setOperatorModalOpen] = useState(false) const [collectionInfo, setCollectionInfo] = useState(null) const [collectionLoadState, setCollectionLoadState] = useState<"idle" | "loading" | "done">("idle") - /** `image` parseado de `token_uri` on-chain (colección 0 o refuerzo). */ + /** `image` parseado de `token_uri` on-chain (colección 0 o refuerzo). */ const [artifactImageFromUri, setArtifactImageFromUri] = useState(null) - /** `owner_of(phaseId)` — verificación de originalidad vs wallet conectada. */ + /** `owner_of(phaseId)` — verificación de originalidad vs wallet conectada. */ const [onChainTokenOwner, setOnChainTokenOwner] = useState(null) const [tokenOwnerLookupDone, setTokenOwnerLookupDone] = useState(false) /** `balance(wallet)` en contrato PHASE (recuento NFT utilidad). */ @@ -341,7 +335,7 @@ export function FusionChamber() { world = json.world } } catch { - /* no world — silent */ + /* no world — silent */ } if (cancelled) return setWorldData(world) @@ -390,7 +384,7 @@ export function FusionChamber() { if (!cancelled && json.narrative) setNarrativeData(json.narrative) } } catch { - /* narrator offline — no narrative shown */ + /* narrator offline — no narrative shown */ } finally { if (!cancelled) setNarrativeLoading(false) } @@ -679,7 +673,7 @@ export function FusionChamber() { const url = URL.createObjectURL(blob) window.open(url, "_blank", "noopener,noreferrer") window.setTimeout(() => URL.revokeObjectURL(url), 60_000) - toast.success(lang === "es" ? "Metadata abierta en nueva pestaña." : "Metadata opened in new tab.") + toast.success(lang === "es" ? "Metadata abierta en nueva pestaña." : "Metadata opened in new tab.") } catch { toast.error(lang === "es" ? "No se pudo leer metadata on-chain." : "Could not read on-chain metadata.") } @@ -704,13 +698,13 @@ export function FusionChamber() { const neededLiq = stroopsToLiqDisplay(effectivePriceStroops) const haveLiq = stroopsToLiqDisplay(tokenBalance) appendLog(`[ INSUFFICIENT_BALANCE ] ${friendly}`) - appendLog(`[ READOUT ] ${haveLiq} PHASELQ (UI) · need ≥ ${neededLiq} for this collection`) + appendLog(`[ READOUT ] ${haveLiq} PHASELQ (UI) · need ≥ ${neededLiq} for this collection`) appendLog(logs.lowEnergyWarning) - appendLog(`${logs.tracePrefix} HostError Contract #2 — PhaseError::InsufficientBalance`) + appendLog(`${logs.tracePrefix} HostError Contract #2 — PhaseError::InsufficientBalance`) if (faucetEnabled) { appendLog( lang === "es" - ? "[ SUGERENCIA ] Usá el faucet (Genesis o Daily) para PHASELQ en el SAC." + ? "[ SUGERENCIA ] Usá el faucet (Genesis o Daily) para PHASELQ en el SAC." : "[ HINT ] Use the faucet (Genesis or Daily) for PHASELQ on the SAC.", ) } @@ -764,13 +758,13 @@ export function FusionChamber() { const balDisplay = stroopsToLiqDisplay(bal) const reqDisplay = stroopsToLiqDisplay(effectivePriceStroops) appendLog( - `[ BALANCE_CHECK ] ${signerAddress.slice(0, 6)}… · ${balDisplay} PHASELQ, Required: ${reqDisplay} PHASELQ`, + `[ BALANCE_CHECK ] ${signerAddress.slice(0, 6)}… · ${balDisplay} PHASELQ, Required: ${reqDisplay} PHASELQ`, ) appendLog(logs.lowEnergyWarning) if (faucetEnabled && balBigInt === BigInt("0")) { appendLog( lang === "es" - ? "[ SUGERENCIA ] Esa cuenta tiene 0 PHASELQ en el SAC. Usá el faucet u otra wallet desde el modal." + ? "[ SUGERENCIA ] Esa cuenta tiene 0 PHASELQ en el SAC. Usá el faucet u otra wallet desde el modal." : "[ HINT ] That account has 0 PHASELQ on the SAC. Use the faucet or pick another wallet from the modal.", ) } @@ -871,7 +865,7 @@ export function FusionChamber() { const msg = data.code === "NOT_ISSUER_CUSTODY" ? lang === "es" - ? "El NFT no está en custodia del emisor (quizá ya está en tu wallet)." + ? "El NFT no está en custodia del emisor (quizá ya está en tu wallet)." : "This NFT is not held by the configured issuer (it may already be in your wallet)." : data.detail || data.error || `HTTP ${res.status}` appendLog(`[ NFT_COLLECT_FAIL ] ${msg}`) @@ -989,7 +983,7 @@ export function FusionChamber() { if (!res.ok) { const msg = typeof data.error === "string" ? data.error : `HTTP ${res.status}` const detail = typeof data.detail === "string" ? data.detail : undefined - appendLog(`${ch.logs.faucetFailPrefix} ${msg}${detail ? ` — ${detail}` : ""}`) + appendLog(`${ch.logs.faucetFailPrefix} ${msg}${detail ? ` — ${detail}` : ""}`) toast.error(normalizeToastError(msg), detail ? { description: detail } : undefined) return } @@ -997,7 +991,7 @@ export function FusionChamber() { const pendingMsg = data.note || (lang === "es" - ? "Transacción de faucet pendiente en ledger. Reintenta en unos segundos." + ? "Transacción de faucet pendiente en ledger. Reintenta en unos segundos." : "Faucet transaction pending on ledger. Retry in a few seconds.") appendLog(`${ch.logs.tracePrefix} ${pendingMsg}`) toast.message(pendingMsg) @@ -1042,7 +1036,7 @@ export function FusionChamber() { ) toast.success( lang === "es" - ? `Asset clásico acreditado: +${data.amount ?? classicBootstrapAmount} ${classicAsset.code}` + ? `Asset clásico acreditado: +${data.amount ?? classicBootstrapAmount} ${classicAsset.code}` : `Classic asset credited: +${data.amount ?? classicBootstrapAmount} ${classicAsset.code}`, ) await refreshClassicStatus() @@ -1168,7 +1162,7 @@ export function FusionChamber() { async (label: string, value: string) => { try { await navigator.clipboard.writeText(value) - const preview = value.length > 48 ? `${value.slice(0, 22)}…${value.slice(-10)}` : value + const preview = value.length > 48 ? `${value.slice(0, 22)}…${value.slice(-10)}` : value toast.success( lang === "es" ? `${label} copiado: ${preview}` @@ -1250,7 +1244,7 @@ export function FusionChamber() { const expertUrl = stellarExpertPhaserLiqUrl() const isOwnerOnChain = phased && phaseId != null && tokenOwnerLookupDone && isAuthentic(address, onChainTokenOwner) - /** `owner_of` = emisor clásico PHASELQ: el usuario puede usar COLLECT en el panel lateral (servidor firma transfer). */ + /** `owner_of` = emisor clásico PHASELQ: el usuario puede usar COLLECT en el panel lateral (servidor firma transfer). */ const issuerCustodyForCollect = useMemo(() => { if (!phased || phaseId == null || phaseId <= 0 || !tokenOwnerLookupDone || !onChainTokenOwner?.trim()) { return false @@ -1277,7 +1271,7 @@ export function FusionChamber() { Boolean(onChainTokenOwner) && tokenUriExists const manualAddEnabled = phased && phaseId != null && phaseId > 0 - /** Freighter “Token ID” = entero, sin `#`; evita confundir con asset `CODE:issuer`. */ + /** Freighter “Token ID” = entero, sin `#`; evita confundir con asset `CODE:issuer`. */ const nftNumericTokenIdStr = useMemo(() => { if (phaseId == null || !Number.isFinite(Number(phaseId))) return "" return String(Math.max(0, Math.floor(Number(phaseId)))) @@ -1415,7 +1409,7 @@ export function FusionChamber() { const msg = data.code === "NOT_ISSUER_CUSTODY" ? lang === "es" - ? "El NFT no está en custodia del emisor (quizá ya está en tu wallet)." + ? "El NFT no está en custodia del emisor (quizá ya está en tu wallet)." : "This NFT is not held by the configured issuer (it may already be in your wallet)." : data.detail || data.error || `HTTP ${res.status}` appendLog(`[ NFT_COLLECT_FAIL ] ${msg}`) @@ -1457,7 +1451,7 @@ export function FusionChamber() { if (data.ok === true && data.sep50Ready === true) { toast.success(lang === "es" ? "SEP-50: todas las comprobaciones OK." : "SEP-50: all checks passed.") } else if (data.ok === true) { - toast.message(lang === "es" ? "SEP-50: revisá el informe (algunos ítems fallan)." : "SEP-50: see report (some checks failed).") + toast.message(lang === "es" ? "SEP-50: revisá el informe (algunos ítems fallan)." : "SEP-50: see report (some checks failed).") } else { toast.error(chCopy.freighterSep50CheckFailToast) } @@ -1519,7 +1513,7 @@ export function FusionChamber() { }} className={chamberChromeRefreshBtn} > - ⟳ {ch.sync} + ⟳ {ch.sync} {!address ? (
)} @@ -1617,7 +1611,7 @@ export function FusionChamber() { {lowEnergy && address && !phased && !invalidCollection && (

- âš¡ {ch.lowEnergyTitle} + ⚡ {ch.lowEnergyTitle}

{ch.reqLiqPrefix}{" "} @@ -1687,7 +1681,7 @@ export function FusionChamber() {

{ch.classicAssetLabel}
- {displayPhaserLiqSymbol(classicAsset.code)} · {truncateAddress(classicAsset.issuer)} + {displayPhaserLiqSymbol(classicAsset.code)} · {truncateAddress(classicAsset.issuer)}
@@ -1705,7 +1699,7 @@ export function FusionChamber() {

- {lang === "es" ? "Colección / red" : "Collection / network"} + {lang === "es" ? "Colección / red" : "Collection / network"}

@@ -1739,7 +1733,7 @@ export function FusionChamber() {
{phased ? ( - {ch.nftMinted} · #{phaseId} + {ch.nftMinted} · #{phaseId} ) : ( {ch.liquid} @@ -1770,7 +1764,7 @@ export function FusionChamber() { className="tactical-interactive-glitch tactical-btn tactical-phosphor mt-2.5 w-full py-2.5 text-[9px] uppercase tracking-widest text-cyan-200 disabled:opacity-50" > - {connecting ? `â—Œ ${ch.uplinking}` : `â–£ ${ch.linkWallet}`} + {connecting ? `◌ ${ch.uplinking}` : `▣ ${ch.linkWallet}`} ) : ( @@ -1785,7 +1779,7 @@ export function FusionChamber() { }} className="tactical-interactive-glitch tactical-btn mt-2.5 w-full border-red-500/30 py-2 text-[9px] uppercase tracking-widest text-red-400/80 hover:border-red-500/60" > - â—‡ {ch.disconnect} + ◇ {ch.disconnect} )} @@ -1863,7 +1857,7 @@ export function FusionChamber() {
- {/* Exhibition pedestal — NFT de utilidad PHASE (Soroban) */} + {/* Exhibition pedestal — NFT de utilidad PHASE (Soroban) */}

{ch.pedestalVisualShort} @@ -2050,16 +2044,16 @@ export function FusionChamber() { onClick={() => playTacticalUiClick()} className="shrink-0 font-mono text-[9px] uppercase tracking-widest text-violet-400/80 transition hover:text-violet-200" > - Stellar Expert ↗ + Stellar Expert ↗

- owner_of(#{Math.max(0, Math.floor(phaseId))}) ≡ wallet ·{" "} + owner_of(#{Math.max(0, Math.floor(phaseId))}) ≡ wallet ·{" "} {phaseLedgerNftCountDone ? ch.artifactLedgerBalanceContract.replace("{count}", phaseLedgerNftCount ?? "0") : lang === "es" - ? "SINCRONIZANDO balance()…" - : "SYNCING balance()…"} + ? "SINCRONIZANDO balance()…" + : "SYNCING balance()…"}

) : issuerCustodyForCollect ? ( @@ -2080,18 +2074,18 @@ export function FusionChamber() {
) : null} - {/* ── Narrative World connection ── */} + {/* ── Narrative World connection ── */} {worldData && (
- {`[ MUNDO: ${worldData.world_name} ]`} ↗ + {`[ MUNDO: ${worldData.world_name} ]`} ↗ {narrativeLoading ? (

- [ NARRADOR: generando conexión... ] + [ NARRADOR: generando conexión... ]

) : narrativeData ? (

@@ -2105,7 +2099,7 @@ export function FusionChamber() {

{ch.chamberTechnicalDetails} - â–¾ + ▾
@@ -2122,9 +2116,9 @@ export function FusionChamber() {
-
{lang === "es" ? "Emisión" : "Supply"}
+
{lang === "es" ? "Emisión" : "Supply"}
- {collectionSupply?.minted ?? "—"} / {collectionSupply?.cap ?? "—"} + {collectionSupply?.minted ?? "—"} / {collectionSupply?.cap ?? "—"}
@@ -2141,7 +2135,7 @@ export function FusionChamber() {
{ch.artifact.powerLevel}
- {ch.artifact.stateSolid} · {formatPowerBp(energyLevelBp ?? 10_000)} + {ch.artifact.stateSolid} · {formatPowerBp(energyLevelBp ?? 10_000)}
@@ -2167,7 +2161,7 @@ export function FusionChamber() { onClick={() => playTacticalUiClick()} className="inline-flex rounded-sm border border-violet-500/40 bg-violet-950/50 px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100 transition-colors hover:border-violet-400 hover:bg-violet-900/40" > - {lang === "es" ? "Bóveda PHASE (Dashboard) ↗" : "PHASE vault (Dashboard) ↗"} + {lang === "es" ? "Bóveda PHASE (Dashboard) ↗" : "PHASE vault (Dashboard) ↗"} playTacticalUiClick()} className="inline-flex rounded-sm border border-violet-500/40 bg-violet-950/50 px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100 transition-colors hover:border-violet-400 hover:bg-violet-900/40" > - Stellar Expert ↗ + Stellar Expert ↗
@@ -2195,7 +2189,7 @@ export function FusionChamber() { ? "Aun si Freighter da error, ya puedes intentar Add manually con estos datos. Si falla, pulsa SYNC y reintenta en 30-90s." : "Even if Freighter errors, you can already try Add manually using these values. If it fails, press SYNC and retry in 30-90s." : lang === "es" - ? "Freighter puede usar su propio backend al añadir coleccionables. Si falla, usá el botón naranja Ping índice (self-transfer); para enviar el NFT a otra persona copiá su G… al portapapeles y usá el botón magenta. Tu lista oficial en PHASE está en el dashboard (bóveda RPC)." + ? "Freighter puede usar su propio backend al añadir coleccionables. Si falla, usá el botón naranja Ping índice (self-transfer); para enviar el NFT a otra persona copiá su G… al portapapeles y usá el botón magenta. Tu lista oficial en PHASE está en el dashboard (bóveda RPC)." : "Freighter may use its own backend when adding collectibles. If it fails, use the amber Ping index button (self-transfer); to send the NFT to someone else copy their G-address to the clipboard, then use the magenta button. Your authoritative PHASE list is the dashboard vault (RPC scan)."}

) : ( @@ -2214,7 +2208,7 @@ export function FusionChamber() { }} className="w-full rounded-sm border border-zinc-600 bg-zinc-900/80 py-2 text-[10px] font-semibold uppercase tracking-wide text-zinc-200 transition-colors hover:border-zinc-500 hover:bg-zinc-800 disabled:opacity-45" > - {freighterSep50Busy ? "…" : ch.freighterSep50CheckButton} + {freighterSep50Busy ? "…" : ch.freighterSep50CheckButton} {freighterSep50Report ? (
@@ -2259,7 +2253,7 @@ export function FusionChamber() {
                 

) : (
- {/* Collection image preview — shown pre-mint when imageUri is available */} + {/* Collection image preview — shown pre-mint when imageUri is available */} {effectiveArtifactImage && (
{ch.collectionLine .replace("{id}", String(collectionId)) - .replace("{name}", collectionInfo.name || "—")} + .replace("{name}", collectionInfo.name || "—")}

)} {collectionId === 0 && ( @@ -2363,12 +2357,12 @@ export function FusionChamber() { > {genesisLoading - ? `â–¶â–¶ ${ch.genesisSupplyLoading}` + ? `▶▶ ${ch.genesisSupplyLoading}` : processing && x402Tx === "busy" - ? `â–¶â–¶ ${ch.x402StreamActive}` + ? `▶▶ ${ch.x402StreamActive}` : canInitializeGenesisSupply ? ch.initializeGenesisSupply - : `â–£ ${ch.executeSettlement}`} + : `▣ ${ch.executeSettlement}`}
@@ -2376,7 +2370,7 @@ export function FusionChamber() {
{authenticityPending ? (

- â—Œ {ch.verifyingOwner} + ◌ {ch.verifyingOwner}

) : !address ? (

@@ -2395,13 +2389,13 @@ export function FusionChamber() { > {claimToWalletBusy - ? `â–¶â–¶ ${ch.rewardsNftCollectSending}` + ? `▶▶ ${ch.rewardsNftCollectSending}` : ch.reactorClaimNftCta} ) : isOwnerOnChain ? (

- ● {ch.solidStateStandby} + ● {ch.solidStateStandby}

) : tokenOwnerLookupDone && phaseId != null ? (

@@ -2409,7 +2403,7 @@ export function FusionChamber() {

) : (

- ● {ch.solidStateStandby} + ● {ch.solidStateStandby}

)}
@@ -2548,7 +2542,7 @@ export function FusionChamber() {

{ch.systemLogs}

- ● {ch.live} + ● {ch.live}
@@ -2591,7 +2585,7 @@ export function FusionChamber() { className="rounded-sm border border-zinc-600 px-2 py-1 font-mono text-sm leading-none text-zinc-300 transition-colors hover:bg-zinc-900" aria-label={ch.operatorPanelBackdropClose} > - × + ×
@@ -2649,17 +2643,17 @@ export function FusionChamber() { >

- ┃ {ch.systemLogs} + ┃ {ch.systemLogs}

- ● {ch.live} + ● {ch.live}
@@ -2687,4 +2681,3 @@ export function FusionChamber() {
) } - diff --git a/components/ipfs-display-img.tsx b/components/ipfs-display-img.tsx index 4584ba6d..cac3e579 100644 --- a/components/ipfs-display-img.tsx +++ b/components/ipfs-display-img.tsx @@ -20,7 +20,7 @@ type Props = { fallback?: ReactNode } -export function IpfsDisplayImg({ uri, alt = "", className, loading = "lazy", variant, focalPoint, onExhausted }: Props) { +export function IpfsDisplayImg({ uri, alt = "", className, loading = "lazy", variant, focalPoint, onExhausted, fallback }: Props) { const candidates = useMemo(() => { const urls = ipfsHttpsGatewayUrls(uri) if (!variant) return urls diff --git a/components/liquidity-faucet-control.tsx b/components/liquidity-faucet-control.tsx index 38e4b0d0..b24a286a 100644 --- a/components/liquidity-faucet-control.tsx +++ b/components/liquidity-faucet-control.tsx @@ -25,6 +25,7 @@ import { } from "@/lib/phase-protocol" import { playTacticalUiClick } from "@/lib/tactical-ui-click" import { cn } from "@/lib/utils" +import { albedoRetiredSignMessage, isAlbedoRetired } from "@/lib/albedo-sunset" function comparableStellarAccountG(addr: string): string { const t = addr.trim() diff --git a/components/notifications-modal.tsx b/components/notifications-modal.tsx index 3c895123..d76ef747 100644 --- a/components/notifications-modal.tsx +++ b/components/notifications-modal.tsx @@ -25,6 +25,7 @@ type NotificationType = | "moderation_appeal_resolved" | "quest_arc_generated" | "quest_arc_completed" + | "winback_reactivation" type Notification = { id: string @@ -220,6 +221,7 @@ const NOTIF_LABELS: Record = { moderation_appeal_resolved: { en: phaseCopy.en.moderationQueue.notifyLabel, es: phaseCopy.es.moderationQueue.notifyLabel }, quest_arc_generated: { en: phaseCopy.en.questArcs.notifyLabel, es: phaseCopy.es.questArcs.notifyLabel }, quest_arc_completed: { en: phaseCopy.en.questArcs.notifyLabel, es: phaseCopy.es.questArcs.notifyLabel }, + winback_reactivation: { en: "Welcome back", es: "Bienvenida de vuelta" }, } const NOTIF_ICONS: Record = { @@ -240,6 +242,7 @@ const NOTIF_ICONS: Record = { moderation_appeal_resolved: "⚖", quest_arc_generated: "➶", quest_arc_completed: "✓", + winback_reactivation: "↺", } function timeAgo(ts: number): string { diff --git a/components/phase-artifact-visualizer.tsx b/components/phase-artifact-visualizer.tsx index dd8771bf..684b7936 100644 --- a/components/phase-artifact-visualizer.tsx +++ b/components/phase-artifact-visualizer.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck "use client" import type { ArtifactLabelsCopy } from "@/lib/phase-copy" @@ -10,13 +9,13 @@ import { useCallback, useEffect, useId, useMemo, useState } from "react" export type ArtifactVerificationMode = "locked" | "verifying" | "verified" -const GLITCH_CHARS = "ABCDEFGHKMNPQRSTUVWXYZ023456789#%â–‘â–’" +const GLITCH_CHARS = "ABCDEFGHKMNPQRSTUVWXYZ023456789#%░▒" function scramblePreservingShape(target: string): string { let out = "" for (let i = 0; i < target.length; i++) { const ch = target[i]! - if (!/[A-Za-z0-9#%·/]/.test(ch)) { + if (!/[A-Za-z0-9#%·/]/.test(ch)) { out += ch continue } @@ -125,7 +124,7 @@ type Props = { serial: number energyLevelBp: number collectionTitle?: string - /** Nombre legible para fila pública COLLECTION_NAME. */ + /** Nombre legible para fila pública COLLECTION_NAME. */ collectionDisplayName?: string imageUrl?: string | null className?: string @@ -138,20 +137,20 @@ type Props = { isOwner?: boolean authenticityPending?: boolean onAccessPrivateMetadata?: () => void - /** `get_total_minted` / cap — null si RPC falla o aún no hay dato. */ + /** `get_total_minted` / cap — null si RPC falla o aún no hay dato. */ supplyMinted?: number | null supplyCap?: number | null /** Variante compacta para layouts con poco alto visible (p. ej. Chamber). */ compact?: boolean - /** Mostrar panel público (collection/contract/supply) debajo del arte. */ + /** Mostrar panel público (collection/contract/supply) debajo del arte. */ showPublicMetaPanel?: boolean /** Mostrar panel privado (private channel/secret/power/signature) debajo del arte. */ showPrivateMetaPanel?: boolean - /** Cámara: un solo panel limpio, sin ASCII ni banner ruidoso cuando el dueño está verificado. */ + /** Cámara: un solo panel limpio, sin ASCII ni banner ruidoso cuando el dueño está verificado. */ chamberPresentation?: boolean /** Marco exterior lo aporta el padre (split preview cian); sin tarjeta esmeralda duplicada. */ chamberFrameless?: boolean - /** Cámara: preview clicable sin la fila «EXPAND» bajo la imagen. */ + /** Cámara: preview clicable sin la fila «EXPAND» bajo la imagen. */ suppressExpandLabel?: boolean /** Token ID exacto para copiar (p. ej. Freighter); por defecto entero derivado de `serial`. */ dockCopyTokenId?: string @@ -161,19 +160,19 @@ type Props = { collectNftBusy?: boolean collectLabel?: string collectBusyLabel?: string - /** Cámara: el padre muestra caption + copias; aquí solo el bloque de imagen (holo). */ + /** Cámara: el padre muestra caption + copias; aquí solo el bloque de imagen (holo). */ chamberMetaPanelExternal?: boolean } function truncateContractMid(id: string) { const t = id.trim() - if (!t) return "—" + if (!t) return "—" if (t.length <= 12) return t - return `${t.slice(0, 4)}…${t.slice(-4)}` + return `${t.slice(0, 4)}…${t.slice(-4)}` } /** - * Monitor de escasez y propiedad: panel público (gris), canal privado (cian) con descifrado animado. + * Monitor de escasez y propiedad: panel público (gris), canal privado (cian) con descifrado animado. */ export function PhaseArtifactVisualizer({ mode, @@ -213,8 +212,8 @@ export function PhaseArtifactVisualizer({ const asciiInner = 38 const line = (inner: string) => { - const t = inner.length > asciiInner ? `${inner.slice(0, asciiInner - 1)}…` : inner - return `â•‘ ${t.padEnd(asciiInner, " ")} â•‘` + const t = inner.length > asciiInner ? `${inner.slice(0, asciiInner - 1)}…` : inner + return `║ ${t.padEnd(asciiInner, " ")} ║` } const isVerified = mode === "verified" const isVerifying = mode === "verifying" @@ -232,20 +231,20 @@ export function PhaseArtifactVisualizer({ ? labels.verifying : labels.terminalRestricted - const topBlock = ["╔════════════════════════════════════════╗", line(head), "╠════════════════════════════════════════╣"].join("\n") + const topBlock = ["╔════════════════════════════════════════╗", line(head), "╠════════════════════════════════════════╣"].join("\n") const powerStateLabel = isVerified ? labels.stateSolid : labels.stateLiquid - const powerFinalPrivate = `${powerStateLabel} · ${formatPowerBp(energyLevelBp)}` + const powerFinalPrivate = `${powerStateLabel} · ${formatPowerBp(energyLevelBp)}` const secretFinal = `#${Math.max(0, Math.floor(serial))}` const contractPublic = truncateContractMid(contractId) const publicCollectionLine = collectionDisplayName?.trim() || (collectionTitle != null && collectionTitle.length > 0 ? collectionTitle.replace(/\s*\/\/.*$/, "").trim() : "") || - "—" + "—" const supplyLine = - supplyMinted != null && supplyCap != null ? `[ ${supplyMinted} / ${supplyCap} ]` : `[ — / — ]` + supplyMinted != null && supplyCap != null ? `[ ${supplyMinted} / ${supplyCap} ]` : `[ — / — ]` const supplyRemainingRatio = supplyCap != null && supplyCap > 0 && supplyMinted != null ? (supplyCap - supplyMinted) / supplyCap @@ -712,7 +711,7 @@ export function PhaseArtifactVisualizer({ ) const chamberCaptionEl = - chamberMinimal && publicCollectionLine !== "—" ? ( + chamberMinimal && publicCollectionLine !== "—" ? (

- {dockTokenCopyValue.trim() ? `#${dockTokenCopyValue}` : "—"} + {dockTokenCopyValue.trim() ? `#${dockTokenCopyValue}` : "—"}

@@ -782,7 +781,7 @@ export function PhaseArtifactVisualizer({ onClick={() => void Promise.resolve(onCollectNft())} className="tactical-interactive-glitch inline-flex shrink-0 items-center justify-center self-end border border-violet-500/55 bg-violet-950/35 px-3 py-2 text-[9px] font-bold uppercase tracking-widest text-violet-100 shadow-[inset_0_0_0_1px_rgba(167,139,250,0.12)] transition-colors hover:border-violet-400 hover:bg-violet-900/40 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 sm:self-auto" > - {collectNftBusy ? collectBusyLabel ?? "…" : collectLabel ?? "Collect"} + {collectNftBusy ? collectBusyLabel ?? "…" : collectLabel ?? "Collect"} ) : null} @@ -920,4 +919,3 @@ export function PhaseArtifactVisualizer({ ) } - diff --git a/components/phase-protected-preview.tsx b/components/phase-protected-preview.tsx index 3ffbb3f3..e0c877eb 100644 --- a/components/phase-protected-preview.tsx +++ b/components/phase-protected-preview.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck "use client" import { IpfsDisplayImg } from "@/components/ipfs-display-img" @@ -6,16 +5,16 @@ import { cn } from "@/lib/utils" import { viewerSignatureShort } from "@/lib/viewer-signature" import { truncateAddress } from "@/lib/explore-domain" -// ── phase-92: push notifications for replies and mentions ── +// ── phase-92: push notifications for replies and mentions ── // This preview component renders independently of push-subscription state -// (core logic lives in lib/push-notifications.ts); no change needed here — +// (core logic lives in lib/push-notifications.ts); no change needed here — // verified for zero regression. -// ── phase-77: wash-trading detection heuristics for listings ── +// ── phase-77: wash-trading detection heuristics for listings ── // Renders independently while wash-trading analysis protects volume stats in // backend APIs and listing badges. Preserves preview wiring with zero regression. Rollback: unset FEATURE_PHASE_77. -// ── phase-122: off-chain delta display hint (flag-gated, zero regression when off) ── +// ── phase-122: off-chain delta display hint (flag-gated, zero regression when off) ── function isPhase122Enabled(): boolean { const v = (typeof process !== "undefined" ? (process.env.NEXT_PUBLIC_FEATURE_PHASE_122 ?? "") : "")?.trim().toLowerCase() return v === "1" || v === "true" || v === "yes" || v === "on" @@ -43,7 +42,7 @@ export type PhaseProtectedPreviewLabels = { * resolved ownership via full on-chain RPC (`chainVerified`), else derived * from comparing the truncated on-chain owner (as served by /api/explore) * against the connected viewer's own address using the same truncation. - * UI signal only — not a security gate. + * UI signal only — not a security gate. */ export function resolvePhaseProtectedPreviewVerified( chainVerified: boolean | undefined, @@ -57,26 +56,26 @@ export function resolvePhaseProtectedPreviewVerified( } type Props = { - /** `ipfs://…` o HTTPS (p. ej. metadatos / gateway); reintenta gateways si uno falla. */ + /** `ipfs://…` o HTTPS (p. ej. metadatos / gateway); reintenta gateways si uno falla. */ uri: string className?: string /** - * Dueño on-chain de la utilidad PHASE para esta colección (wallet conectada). - * Pásalo cuando ya hiciste la llamada RPC completa (p. ej. detalle/Reactor). - * Si se omite, el widget se autoverifica comparando `ownerTruncated` × `viewerAddress` - * (mismo truncado que sirve /api/explore) sin RPC extra — solo señal de UI, no gate de seguridad. + * Dueño on-chain de la utilidad PHASE para esta colección (wallet conectada). + * Pásalo cuando ya hiciste la llamada RPC completa (p. ej. detalle/Reactor). + * Si se omite, el widget se autoverifica comparando `ownerTruncated` × `viewerAddress` + * (mismo truncado que sirve /api/explore) sin RPC extra — solo señal de UI, no gate de seguridad. */ chainVerified?: boolean - /** Owner truncado (`GABCDE…WXYZ`) tal como lo devuelve /api/explore, para autoverificación cliente. */ + /** Owner truncado (`GABCDE…WXYZ`) tal como lo devuelve /api/explore, para autoverificación cliente. */ ownerTruncated?: string - /** Wallet conectada (para sello que varía por visor). */ + /** Wallet conectada (para sello que varía por visor). */ viewerAddress: string | null | undefined labels: PhaseProtectedPreviewLabels } /** * Mercado / listado: miniatura degradada + scanlines + PENDING_FUSION si no hay prueba on-chain. - * Si `chainVerified`, muestra arte más nítido y sello de cadena (HD reservado al Reactor). + * Si `chainVerified`, muestra arte más nítido y sello de cadena (HD reservado al Reactor). */ export function PhaseProtectedPreview({ uri, className, chainVerified, ownerTruncated, viewerAddress, labels }: Props) { const sig = viewerSignatureShort(viewerAddress) @@ -103,7 +102,7 @@ export function PhaseProtectedPreview({ uri, className, chainVerified, ownerTrun /> {isDelta ? (
- Δ off-chain + Δ off-chain
) : null} @@ -128,15 +127,14 @@ export function PhaseProtectedPreview({ uri, className, chainVerified, ownerTrun > {verified ? ( - {labels.chainVerifiedSeal} · SIG:{sig} + {labels.chainVerifiedSeal} · SIG:{sig} ) : ( - {labels.unverifiedCopy} · {sig} + {labels.unverifiedCopy} · {sig} )} ) } - diff --git a/components/wallet-provider.tsx b/components/wallet-provider.tsx index 71652cb3..54aa10de 100644 --- a/components/wallet-provider.tsx +++ b/components/wallet-provider.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck "use client" import { @@ -46,9 +45,9 @@ import { LedgerClearSigningModal } from "@/components/ledger-clear-signing-modal import type { DecodedSorobanInvocation } from "@/lib/ledger-soroban-decoder" -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── // Types -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── type WalletContextValue = { address: string | null @@ -64,11 +63,11 @@ type WalletContextValue = { isXBull: boolean connect: () => Promise disconnect: () => void - /** Re-sync con la wallet vía kit; devuelve la dirección activa o null. */ + /** Re-sync con la wallet vía kit; devuelve la dirección activa o null. */ refresh: () => Promise /** - * Abre el modal de @creit.tech/stellar-wallets-kit para elegir o cambiar wallet (misma sesión que `connect`). - * Útil antes de firmar un settle: el usuario confirma qué G… firma y recibe el NFT. `null` si cierra el modal. + * Abre el modal de @creit.tech/stellar-wallets-kit para elegir o cambiar wallet (misma sesión que `connect`). + * Útil antes de firmar un settle: el usuario confirma qué G… firma y recibe el NFT. `null` si cierra el modal. */ openWalletPicker: () => Promise refreshArtistAlias: () => Promise @@ -78,9 +77,9 @@ type WalletContextValue = { } -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── // Constants -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── const WalletContext = createContext(null) @@ -99,9 +98,9 @@ const lastFaucetAutoClaimAt = new Map() const HEARTBEAT_INTERVAL_HW_MS = 8_000 const HEARTBEAT_INTERVAL_SW_MS = 30_000 -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── // Provider -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── export function WalletProvider({ children }: { children: ReactNode }) { if (typeof window !== "undefined") { @@ -118,7 +117,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { const [isXBull, setIsXBull] = useState(false) const [qrReceiveOpen, setQrReceiveOpen] = useState(false) - /** True when user deliberately disconnected — blocks session restoration. */ + /** True when user deliberately disconnected — blocks session restoration. */ const userDisconnectedRef = useRef(false) /** Spike 628: true once the biometric gate passed (or was skipped) this session. */ const biometricUnlockedRef = useRef(false) @@ -132,7 +131,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { const [albedoPrepBusy, setAlbedoPrepBusy] = useState(false) const [albedoPrepError, setAlbedoPrepError] = useState(null) - // Network passphrase mismatch alert — shown when Ledger is connected but the + // Network passphrase mismatch alert — shown when Ledger is connected but the // kit's active network doesn't match TESTNET_PASSPHRASE. const [networkMismatch, setNetworkMismatch] = useState(null) const [ledgerReviewModal, setLedgerReviewModal] = useState<{ @@ -151,7 +150,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { }) }, []) - // ── helpers ────────────────────────────────────────────────────────────── + // ── helpers ────────────────────────────────────────────────────────────── /** Sync module-type flags from localStorage (mirrors kit persisted selection). */ const syncModuleFlags = useCallback(() => { @@ -214,17 +213,17 @@ export function WalletProvider({ children }: { children: ReactNode }) { const { networkPassphrase } = await kit.getNetwork() if (networkPassphrase && networkPassphrase !== TESTNET_PASSPHRASE) { setNetworkMismatch( - `Ledger está conectado a "${networkPassphrase.slice(0, 48)}…" pero la app espera la testnet. Abrí la app Stellar en tu Ledger y seleccioná la red correcta (Test SDF Network). / Ledger is on the wrong network. Open the Stellar app on your Ledger and switch to the correct network.`, + `Ledger está conectado a "${networkPassphrase.slice(0, 48)}…" pero la app espera la testnet. Abrí la app Stellar en tu Ledger y seleccioná la red correcta (Test SDF Network). / Ledger is on the wrong network. Open the Stellar app on your Ledger and switch to the correct network.`, ) } else { setNetworkMismatch(null) } } catch { - // Not critical — leave any previous mismatch shown. + // Not critical — leave any previous mismatch shown. } }, []) - // ── session refresh ──────────────────────────────────────────────────────── + // ── session refresh ──────────────────────────────────────────────────────── const refresh = useCallback((): Promise => { const run = async (): Promise => { @@ -260,7 +259,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { }) }, []) - // ── heartbeat ───────────────────────────────────────────────────────────── + // ── heartbeat ───────────────────────────────────────────────────────────── /** * Start a recurring heartbeat that re-verifies the active wallet is still @@ -281,9 +280,9 @@ export function WalletProvider({ children }: { children: ReactNode }) { try { const { address: current } = await kit.getAddress() if (!current || current !== addr) { - // Session expired or wallet changed — surface a hint. + // Session expired or wallet changed — surface a hint. setHint( - "Wallet sesión expirada. Reconectá tu wallet. / Wallet session expired. Please reconnect.", + "Wallet sesión expirada. Reconectá tu wallet. / Wallet session expired. Please reconnect.", ) setAddress(null) clearInterval(heartbeatTimerRef.current!) @@ -294,7 +293,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { if (isHardwareWalletSelected()) { setAddress(null) setHint( - "Ledger desconectado. Volvé a enchufar el dispositivo y reconectá. / Ledger disconnected. Re-plug the device and reconnect.", + "Ledger desconectado. Volvé a enchufar el dispositivo y reconectá. / Ledger disconnected. Re-plug the device and reconnect.", ) } clearInterval(heartbeatTimerRef.current!) @@ -312,7 +311,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { } }, []) - // ── effects ─────────────────────────────────────────────────────────────── + // ── effects ─────────────────────────────────────────────────────────────── /** Spike 628: OS biometric passed — apply the sealed session. */ const handleBiometricUnlocked = useCallback( @@ -397,7 +396,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { }, [startHeartbeat, stopHeartbeat, gateBiometric]) /** - * HID device disconnect events — WebUSB fires `connect`/`disconnect` events + * HID device disconnect events — WebUSB fires `connect`/`disconnect` events * on `navigator.usb`. When the Ledger is physically unplugged the heartbeat * will catch it, but we also listen here for an immediate UX response. */ @@ -413,7 +412,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { stopHeartbeat() setAddress(null) setHint( - "Ledger desconectado (USB). Volvé a enchufar y reconectá. / Ledger disconnected (USB). Re-plug and reconnect.", + "Ledger desconectado (USB). Volvé a enchufar y reconectá. / Ledger disconnected (USB). Re-plug and reconnect.", ) } @@ -454,8 +453,6 @@ export function WalletProvider({ children }: { children: ReactNode }) { autoFundedWalletsRef.current.add(address) const controller = new AbortController() - const controller = new AbortController() - const autoClaimGenesis = async () => { const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) const postReward = async (reward: string) => { @@ -568,7 +565,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { void syncAlbedoTxPrep(address) }, [address, syncAlbedoTxPrep]) - // ── actions ─────────────────────────────────────────────────────────────── + // ── actions ─────────────────────────────────────────────────────────────── const connect = useCallback((): Promise => { const run = async (): Promise => { @@ -606,7 +603,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { const pe = parseError(e) setAddress(null) stopHeartbeat() - if (pe.code !== -1) { + if (Number(pe.code) !== -1) { setHint(pe.message || "Wallet connection failed") } } finally { @@ -684,7 +681,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { setIsXBull(false) }, [stopHeartbeat]) - // ── context value ───────────────────────────────────────────────────────── + // ── context value ───────────────────────────────────────────────────────── const value = useMemo( () => ({ @@ -725,7 +722,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { ) - // ── render ──────────────────────────────────────────────────────────────── + // ── render ──────────────────────────────────────────────────────────────── return ( <> @@ -733,7 +730,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { - {/* ── Network passphrase mismatch alert (Ledger on wrong network) ──── */} + {/* ── Network passphrase mismatch alert (Ledger on wrong network) ──── */} {networkMismatch && address && isHardwareWallet && (
{/* Ledger icon indicator */} - ⬡ + ⬡

- Ledger — red incorrecta.{" "} + Ledger — red incorrecta.{" "} {networkMismatch}

@@ -766,7 +763,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { )} - {/* ── Albedo implicit-tx permission banner ─────────────────────────── */} + {/* ── Albedo implicit-tx permission banner ─────────────────────────── */} {address && albedoTxPrep === "needed" && !networkMismatch && (

- Albedo: concedé permiso de firma en esta pestaña (una vez). Sin esto, el navegador suele - bloquear el diálogo tras cargar Horizon o armar Soroban (incl. trustline PHASELQ). /{" "} + Albedo: concedé permiso de firma en esta pestaña (una vez). Sin esto, el navegador suele + bloquear el diálogo tras cargar Horizon o armar Soroban (incl. trustline PHASELQ). /{" "} Grant signing once so wallet dialogs are not blocked after Horizon/Soroban (including PHASELQ trustline). @@ -809,7 +806,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { .finally(() => setAlbedoPrepBusy(false)) }} > - {albedoPrepBusy ? "…" : "Permitir firma / Allow signing"} + {albedoPrepBusy ? "…" : "Permitir firma / Allow signing"}

@@ -844,9 +841,9 @@ export function WalletProvider({ children }: { children: ReactNode }) { } -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── // Hook -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── export function useWallet() { const ctx = useContext(WalletContext) @@ -856,13 +853,12 @@ export function useWallet() { return ctx } -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── // Internal types -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── /** Minimal shape of the WebUSB manager (navigator.usb). */ interface USBManager extends EventTarget { addEventListener(type: "connect" | "disconnect", listener: EventListenerOrEventListenerObject): void removeEventListener(type: "connect" | "disconnect", listener: EventListenerOrEventListenerObject): void } - diff --git a/components/web-vitals-reporter.tsx b/components/web-vitals-reporter.tsx index 61347e6a..7d546659 100644 --- a/components/web-vitals-reporter.tsx +++ b/components/web-vitals-reporter.tsx @@ -2,6 +2,12 @@ import { useEffect } from "react" +declare global { + interface Window { + dataLayer?: Record[] + } +} + export interface WebVitalsMetric { name: "LCP" | "FID" | "CLS" | "TTFB" | "INP" value: number diff --git a/contracts/mock-token/Cargo.lock b/contracts/mock-token/Cargo.lock index 80960c04..2ec9e5ea 100644 --- a/contracts/mock-token/Cargo.lock +++ b/contracts/mock-token/Cargo.lock @@ -543,9 +543,9 @@ checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" [[package]] name = "ethnum" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" [[package]] name = "ff" diff --git a/contracts/mock-token/src/lib.rs b/contracts/mock-token/src/lib.rs index 756c76c0..75b77424 100644 --- a/contracts/mock-token/src/lib.rs +++ b/contracts/mock-token/src/lib.rs @@ -535,13 +535,14 @@ mod test { #[test] fn test_initialize() { let env = Env::default(); + env.mock_all_auths(); let admin = Address::generate(&env); - let token = MockToken; + let client = MockTokenClient::new(&env, &env.register(MockToken, ())); let name = String::from_str(&env, "Mock Token"); let symbol = String::from_str(&env, "MOCK"); - token.initialize(env.clone(), admin, 7, name, symbol).unwrap(); - assert!(MockToken::is_initialized(env)); + client.initialize(&admin, &7, &name, &symbol); + assert!(client.is_initialized()); } } diff --git a/contracts/mock-token/test_snapshots/test/test_initialize.1.json b/contracts/mock-token/test_snapshots/test/test_initialize.1.json new file mode 100644 index 00000000..f7215e5b --- /dev/null +++ b/contracts/mock-token/test_snapshots/test/test_initialize.1.json @@ -0,0 +1,261 @@ +{ + "generators": { + "address": 2, + "nonce": 0 + }, + "auth": [ + [], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "durability": "persistent", + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "Initialized" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "Initialized" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "Metadata" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "Metadata" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "decimal" + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "symbol": "name" + }, + "val": { + "string": "Mock Token" + } + }, + { + "key": { + "symbol": "symbol" + }, + "val": { + "string": "MOCK" + } + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "TotalSupply" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "TotalSupply" + } + ] + }, + "durability": "persistent", + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": null + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/phase-protocol/Cargo.lock b/contracts/phase-protocol/Cargo.lock index 8f3f0ce0..a380b7ff 100644 --- a/contracts/phase-protocol/Cargo.lock +++ b/contracts/phase-protocol/Cargo.lock @@ -543,9 +543,9 @@ checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" [[package]] name = "ethnum" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" [[package]] name = "ff" diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 15cc590a..d3ca08f3 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -187,6 +187,7 @@ settlement verifier and must not accept unsigned base64 payloads. | `phase-143` | `GET/POST /api/notifications/winback` | AI-driven churn and winback: dormancy tier classification, tailored return hooks, randomized experiment cohorts, and 7-day fatigue caps | `404` disabled | | `phase-144` | `GET/POST /api/governance/ve-lock` | ve-PHASELQ time-locked governance escrow: decaying lock weight (`1-208` weeks), pro-rata treasury fee-share distribution, and claims | `404` disabled | | `phase-145` | `GET/POST /api/governance/gauge` | Gauge voting for emissions: ve-weight-proportional votes directing weekly quest budget allocations across collections | `404` disabled | +| `phase-api-versioning` | `/api/*` (middleware), `GET /api/versions` | Version negotiation: `X-API-Version` pins, `Accept-Version` picks the first supported id, none means current. Echoes `X-API-Version` + `Vary`; unknown → `406 {error:"UNSUPPORTED_API_VERSION",requested,supported}`, sunset → `410 API_VERSION_SUNSET`; `Deprecation`/`Sunset`/`Link` from `ROUTE_DEPRECATIONS`. `/api/versions?format=openapi` serves the contract | No version headers; `/api/versions` `404` | ### 5.3 Signal concurrency contract @@ -419,6 +420,7 @@ NEXT_PUBLIC_FEATURE_PHASE_158=1 NEXT_PUBLIC_FEATURE_PHASE_159=1 NEXT_PUBLIC_FEATURE_PHASE_160=1 NEXT_PUBLIC_FEATURE_PHASE_161=1 +NEXT_PUBLIC_FEATURE_PHASE_API_VERSIONING=1 # Server-only aliases also accepted: FEATURE_PHASE_104, etc. ``` @@ -435,6 +437,10 @@ Agent tuning (all optional, server-only): Rollback: unset the var or set `0` and restart. No ledger migration to revert; off-chain stores remain but are ignored when flag off. See `PROJECT_ARCHITECTURE.md` §10. +| Flag | Scope | Rollback | +|---|---|---| +| `phase-api-versioning` | API versioning (spike 663): middleware negotiation, version echo, deprecation headers, `/api/versions`. Contract lives in `lib/api-contract.ts`; `npm run api:contract -- --write` regenerates `docs/api/openapi.json` + `changelog.json` | Unset, restart. Headers stop, `/api/versions` 404s. The CI gate is source-only and stays | + --- ## 10. Security and Operations @@ -479,7 +485,7 @@ Contract commands are documented in [`contracts/README.md`](../contracts/README. `.github/workflows/ci.yml` runs on every PR and push to `main`: - `contracts` — compiles and tests both Soroban crates (`mock-token`, `phase-protocol`) against `wasm32-unknown-unknown`. -- `web` — `npm run lint`, `npm run typecheck` (both currently non-blocking pending a cleanup of pre-existing errors — see the job for the tracked count), and `npm run build`. +- `web` — `npm run api:contract` (blocking; diffs the generated OpenAPI contract against the target branch snapshot, exit 1 on an unversioned breaking change, 2 on a stale snapshot), `npm run lint` (non-blocking: 286 pre-existing errors), `npm run typecheck` and `npm test` (both blocking), and `npm run build`. - `deploy-staging` — on push to `main` only, behind a `staging` GitHub environment: builds + optimizes `phase-protocol` and runs `scripts/deploy-phase-sep50.ts` against testnet using repo secrets/vars. --- diff --git a/docs/api/changelog.json b/docs/api/changelog.json new file mode 100644 index 00000000..173b0578 --- /dev/null +++ b/docs/api/changelog.json @@ -0,0 +1,13 @@ +[ + { + "version": "2026-10-17", + "date": "2026-10-17", + "changes": [ + { + "type": "added", + "breaking": false, + "note": "First versioned contract. Baseline of the routes listed in lib/api-contract.ts as they behaved at spike 663." + } + ] + } +] diff --git a/docs/api/openapi.json b/docs/api/openapi.json new file mode 100644 index 00000000..f9740972 --- /dev/null +++ b/docs/api/openapi.json @@ -0,0 +1,2296 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "PHASE API", + "version": "2026-10-17", + "x-api-versions": [ + { + "id": "2026-10-17", + "released": "2026-10-17", + "status": "current" + } + ] + }, + "paths": { + "/api/faucet": { + "get": { + "operationId": "faucet.status", + "summary": "Faucet eligibility and last claim for a wallet", + "tags": [ + "faucet" + ], + "parameters": [ + { + "name": "walletAddress", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + }, + "412": { + "description": "HTTP 412", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "502": { + "description": "HTTP 502", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + } + }, + "x-state-changing": false, + "x-signing": "none" + }, + "post": { + "operationId": "faucet.claim", + "summary": "Claim the testnet PHASELQ faucet reward", + "tags": [ + "faucet" + ], + "parameters": [ + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + }, + "202": { + "description": "HTTP 202", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "description": "HTTP 403", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "HTTP 404", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + }, + "409": { + "description": "HTTP 409", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + }, + "412": { + "description": "HTTP 412", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + }, + "429": { + "description": "HTTP 429", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "HTTP 500", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + }, + "502": { + "description": "HTTP 502", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + }, + "503": { + "description": "HTTP 503", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + } + }, + "x-state-changing": true, + "x-signing": "none", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletAddress": {}, + "userAddress": {}, + "reward": {}, + "referralCode": {}, + "turnstileToken": {}, + "hashcashProof": {} + } + } + } + } + } + } + }, + "/api/claim-bounty": { + "post": { + "operationId": "bounty.claim", + "summary": "Bounty claim; proxies the faucet claim and mirrors its status", + "tags": [ + "faucet" + ], + "parameters": [ + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "202": { + "description": "HTTP 202", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "403": { + "description": "HTTP 403", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "404": { + "description": "HTTP 404", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + }, + "409": { + "description": "HTTP 409", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "412": { + "description": "HTTP 412", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "429": { + "description": "HTTP 429", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "500": { + "description": "HTTP 500", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "502": { + "description": "HTTP 502", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "503": { + "description": "HTTP 503", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + } + }, + "x-state-changing": true, + "x-signing": "none", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletAddress": {}, + "userAddress": {}, + "reward": {} + } + } + } + } + } + } + }, + "/api/soroban-rpc": { + "get": { + "operationId": "rpc.health", + "summary": "Method guard; GET is not supported", + "tags": [ + "rpc" + ], + "parameters": [ + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "405": { + "description": "HTTP 405", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + } + }, + "x-state-changing": false, + "x-signing": "none" + }, + "post": { + "operationId": "rpc.call", + "summary": "Soroban JSON-RPC proxy with failover. JSON-RPC errors arrive as HTTP 200 with an `error` member", + "tags": [ + "rpc" + ], + "parameters": [ + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + }, + "413": { + "description": "HTTP 413", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + }, + "429": { + "description": "HTTP 429", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + }, + "503": { + "description": "HTTP 503", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + } + }, + "x-state-changing": false, + "x-signing": "none", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jsonrpc": {}, + "id": {}, + "method": {}, + "params": {} + } + } + } + } + } + } + }, + "/api/classic-liq": { + "get": { + "operationId": "liq.status", + "summary": "Classic LIQ claim status for a wallet", + "tags": [ + "settlement" + ], + "parameters": [ + { + "name": "walletAddress", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + }, + "502": { + "description": "HTTP 502", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "503": { + "description": "HTTP 503", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + } + }, + "x-state-changing": false, + "x-signing": "none" + }, + "post": { + "operationId": "liq.claim", + "summary": "Send the classic LIQ reward to a wallet with an existing trustline", + "tags": [ + "settlement" + ], + "parameters": [ + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + }, + "409": { + "description": "HTTP 409", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "412": { + "description": "HTTP 412", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "502": { + "description": "HTTP 502", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "503": { + "description": "HTTP 503", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + } + }, + "x-state-changing": true, + "x-signing": "none", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletAddress": {}, + "userAddress": {} + } + } + } + } + } + } + }, + "/api/classic-liq/trustline": { + "get": { + "operationId": "liq.trustline.status", + "summary": "Trustline submission status", + "tags": [ + "settlement" + ], + "parameters": [ + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "view", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "404": { + "description": "HTTP 404", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + }, + "500": { + "description": "HTTP 500", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + } + }, + "x-state-changing": false, + "x-signing": "none" + }, + "post": { + "operationId": "liq.trustline.submit", + "summary": "Submit a client-signed changeTrust XDR (single or batch)", + "tags": [ + "settlement" + ], + "parameters": [ + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + }, + "409": { + "description": "HTTP 409", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "422": { + "description": "HTTP 422", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "502": { + "description": "HTTP 502", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + } + }, + "x-state-changing": true, + "x-signing": "wallet", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signedXdr": {}, + "signedXdrs": {} + } + } + } + } + } + } + }, + "/api/nft-listings": { + "get": { + "operationId": "listings.list", + "summary": "Paginated NFT listings", + "tags": [ + "market" + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + } + }, + "x-state-changing": false, + "x-signing": "none" + }, + "post": { + "operationId": "listings.write", + "summary": "Create or cancel a listing", + "tags": [ + "market" + ], + "parameters": [ + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "202": { + "description": "HTTP 202", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + }, + "413": { + "description": "HTTP 413", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + } + }, + "x-state-changing": true, + "x-signing": "wallet", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tokenId": {}, + "collectionId": {}, + "priceStroops": {}, + "seller": {}, + "cancelId": {} + } + } + } + } + } + } + }, + "/api/market": { + "get": { + "operationId": "market.list", + "summary": "Market listings with filters", + "tags": [ + "market" + ], + "parameters": [ + { + "name": "collection", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "drop", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "exclude_ap", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "seller", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + } + }, + "x-state-changing": false, + "x-signing": "none" + }, + "post": { + "operationId": "market.create", + "summary": "Create a market listing", + "tags": [ + "market" + ], + "parameters": [ + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "HTTP 201", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + } + }, + "x-state-changing": true, + "x-signing": "wallet", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token_id": {}, + "collection_id": {}, + "seller_wallet": {}, + "name": {}, + "image": {}, + "price_phaselq": {}, + "accepts_offers": {}, + "min_offer": {}, + "creator_wallet": {}, + "royalty_bps": {}, + "suggestion_id": {}, + "lang": {} + } + } + } + } + } + } + }, + "/api/signals": { + "get": { + "operationId": "signals.list", + "summary": "Signal feed", + "tags": [ + "signals" + ], + "parameters": [ + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + } + }, + "x-state-changing": false, + "x-signing": "none" + }, + "post": { + "operationId": "signals.create", + "summary": "Publish a SEP-53 signed signal", + "tags": [ + "signals" + ], + "parameters": [ + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "404": { + "description": "HTTP 404", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + } + }, + "x-state-changing": true, + "x-signing": "wallet", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "wallet": {}, + "title": {}, + "body": {}, + "type": {}, + "channel": {}, + "signature": {}, + "timestamp": {}, + "nft_token_id": {}, + "nft_collection_id": {}, + "nft_name": {}, + "nft_image": {}, + "poll_options": {}, + "poll_closes_at": {}, + "scheduled_for": {} + } + } + } + } + } + } + }, + "/api/x402": { + "get": { + "operationId": "x402.resource", + "summary": "x402 local shim resource. 503 unless X402_LOCAL_SHIM_ENABLED=true; 402 without payment", + "tags": [ + "settlement" + ], + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "401": { + "description": "HTTP 401", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "402": { + "description": "HTTP 402", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + }, + "503": { + "description": "HTTP 503", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + } + }, + "x-state-changing": false, + "x-signing": "none" + }, + "post": { + "operationId": "x402.facilitate", + "summary": "x402 local shim facilitator. 503 unless X402_LOCAL_SHIM_ENABLED=true", + "tags": [ + "settlement" + ], + "parameters": [ + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "401": { + "description": "HTTP 401", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + }, + "503": { + "description": "HTTP 503", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + } + }, + "x-state-changing": false, + "x-signing": "none", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "payment_token": {}, + "user_address": {} + } + } + } + } + } + } + }, + "/api/x402/verify": { + "post": { + "operationId": "x402.verify", + "summary": "Verify an x402 payment token", + "tags": [ + "settlement" + ], + "parameters": [ + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + }, + "409": { + "description": "HTTP 409", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "503": { + "description": "HTTP 503", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + } + }, + "x-state-changing": false, + "x-signing": "none", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "payment_token": {} + } + } + } + } + } + } + }, + "/api/x402/supported": { + "get": { + "operationId": "x402.supported", + "summary": "Supported x402 schemes and networks", + "tags": [ + "settlement" + ], + "parameters": [ + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + } + }, + "x-state-changing": false, + "x-signing": "none" + } + }, + "/api/ipfs": { + "get": { + "operationId": "ipfs.status", + "summary": "IPFS pinning configuration status", + "tags": [ + "ipfs" + ], + "parameters": [ + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + } + }, + "x-state-changing": false, + "x-signing": "none" + }, + "post": { + "operationId": "ipfs.pin", + "summary": "Pin an uploaded file to IPFS", + "tags": [ + "ipfs" + ], + "parameters": [ + { + "name": "retries", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "baseDelayMs", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + }, + "502": { + "description": "HTTP 502", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "503": { + "description": "HTTP 503", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + } + }, + "x-state-changing": true, + "x-signing": "none", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": {} + } + } + } + } + } + } + }, + "/api/metadata/{id}": { + "get": { + "operationId": "metadata.get", + "summary": "SEP-50 token metadata JSON", + "tags": [ + "metadata" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "c", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "m", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "404": { + "description": "HTTP 404", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + }, + "502": { + "description": "HTTP 502", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + } + }, + "x-state-changing": false, + "x-signing": "none" + }, + "options": { + "operationId": "metadata.options", + "summary": "CORS preflight", + "tags": [ + "metadata" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "HTTP 204", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + } + }, + "x-state-changing": false, + "x-signing": "none" + } + }, + "/api/versions": { + "get": { + "operationId": "versions.get", + "summary": "Supported versions, deprecations, changelog, or the OpenAPI document (?format=openapi). 404 unless phase-api-versioning is on", + "tags": [ + "meta" + ], + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "404": { + "description": "HTTP 404", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + } + }, + "x-state-changing": false, + "x-signing": "none" + } + }, + "/api/wallet/phase-nfts": { + "get": { + "operationId": "wallet.nfts", + "summary": "PHASE NFTs owned by a wallet", + "tags": [ + "wallet" + ], + "parameters": [ + { + "name": "address", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "X-API-Version", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + { + "name": "Accept-Version", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "400": { + "description": "HTTP 400", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + }, + "406": { + "description": "Unsupported API version (phase-api-versioning)" + }, + "500": { + "description": "HTTP 500", + "headers": { + "X-API-Version": { + "schema": { + "type": "string" + }, + "description": "Negotiated version (phase-api-versioning)" + } + } + } + }, + "x-state-changing": false, + "x-signing": "none" + } + } + } +} diff --git a/docs/investigations/main-baseline-2026-10-17.md b/docs/investigations/main-baseline-2026-10-17.md new file mode 100644 index 00000000..8d959587 --- /dev/null +++ b/docs/investigations/main-baseline-2026-10-17.md @@ -0,0 +1,56 @@ +# Main baseline repair (2026-10-17) + +Branch `chore/fix-main-baseline`, cut from `main` at `74308a0`. + +| Check | Before | After | +|---|---|---| +| `tsc --noEmit` | 120 errors | 7, all in upstream's `lib/__tests__/xbull-adapter.test.ts` (below) | +| `vitest run` | 32 failed / 351 passed, 1 file not loading | 564 passed / 9 failed (77 files, after merging upstream to `11cb3bc`; upstream alone: 54 failed) | +| `next build` | failed, 12 errors | passes (3 file-tracing warnings, below) | +| `npm run lint` | could not start (no `eslint-config-next`) | runs: 286 errors, 106 warnings, all pre-existing | +| `cargo test` mock-token | failed to build | 1 passed | +| `cargo test` phase-protocol | failed to build | **still fails**: see "Blocked" | + +## What was wrong + +- **Dropped union members.** `PhaseFeatureFlag` lost `phase-146`..`phase-153`, `phase-editions`, `phase-timed-drops`. `SecurityCounterName` lost about 50 counters. That accounted for 94 of the 120 tsc errors. +- **Encoding damage (0d14b9a).** Several files were re-encoded as cp1252-decoded UTF-8 and given a BOM plus `// @ts-nocheck`. Spanish UI strings shipped as `Transacción` and `…`, and `●` / `”` were lost. Repaired. +- **Incomplete refactor (60001c5, #33).** `fusion-chamber.tsx` imported ten `components/chamber/*` modules. Four were never committed and five were one-word `placeholder` files. The four used ones are recreated verbatim from the deleted code. The placeholders are removed. +- **Merge losses.** The collab-split fan-out in the offers route, `IpfsDisplayImg`'s `fallback` prop, `washScreenReasonForEdition`, `viewerSignatureShort`, and the four b89c4f2 tables (`exhibitions`, `price_suggestions`, `moderation_queue`, `quest_arcs`) were all restored from the commits that introduced them. +- **Every page crashed in the browser.** `lib/stellar-wallet-kit.ts` (loaded by the wallet provider on every route) imported `randomUUID` from `node:crypto`, which the client polyfill doesn't provide, so hydration threw `randomUUID is not a function`. It now uses `globalThis.crypto.randomUUID()` (the code path is browser-only). Found with a headless Chromium smoke test. +- **No wallet signature could ever verify (SEP-53).** SEP-53 has the wallet sign `SHA256("Stellar Signed Message:\n" + message)`. Signals, world roles, avatar ownership and gated-preview viewer auth all verified the raw prefixed bytes instead, so a real Freighter signature was always rejected ("Invalid signature: payload not signed by this wallet", found during the spike 662 live proof). The tests passed because they signed the same raw bytes. All four sites now go through `verifySep53Signature`, which is tested against the official SEP-53 vectors. `tests/viewer-authorization.test.ts` also used a wallet address unrelated to its signing seed, so it could never pass; fixed. +- **Real bugs found on the way:** an SSRF gap in `validateWebhookUrl` (`127.0.0.1`, `172.16/12` and others accepted), settle verification in `lib/stellar.ts` that could never succeed (meta v4 unread, `ContractEventV0.contractId()` gone), artist attestation never reading `true`, and a winback route calling a function that never existed. + +## Blocked: phase-protocol error discriminants (needs a maintainer decision) + +`contracts/phase-protocol/src/lib.rs` has 36 errors because three merged spikes each numbered new `PhaseError` variants from 18 upward, and the merges kept only one set: + +| Spike / commit | Variants | Discriminants | +|---|---|---| +| Economy 694/697/698/699 (on main now) | `CoinAlreadyRegistered` … `InvalidSchedule` | 18–39 | +| Spikes 672–675, 3e4b562 | `EditionSupplyExhausted`, `DropNotLive`, `InvalidSplitPayees`, `DutchQuoteStale` | 18–21 | +| Raffle, dd3e75c | `RaffleNotFound` … `RaffleMintGated` | 18–33 | + +The `DataKey` variants from 3e4b562 (`EditionCounter`, `EditionCap`, `SplitPayees`, `DropWindow`, `DutchConfig`) and the raffle ones are missing too. `lib/phase-protocol.ts:spikeContractErrorCode` still maps `#18`..`#21` to the edition codes, which now collide with `Coin*` on main. + +The original values exist in history but conflict, so restoring them means choosing new numbers for two of the three sets. That changes on-chain error codes and the TS mapping, so it was not done here. A maintainer needs to decide the final numbering (and whether any of these WASMs is already deployed with its numbering). + +## Upstream merge to 11cb3bc (2026-10-20) + +Upstream main at `11cb3bc` has 152 tsc errors and 54 failing tests on its own. After merging it here: 7 tsc errors and 9 failing tests, all in code added by spikes 623, 624, 628, 629 and the moderation export. Fixed in the merge: `phase-170`..`phase-173` were used but never registered as flags, the `qrFlows` / `walletConnectResilience` / `xbullParity` / `ledgerClearSigning` copy blocks had no type, `legalHolds` / `termsConsents` / `legalDocuments` were missing from the data paths, the fusion chamber lost its QR scanner imports, `@ts-nocheck` and mojibake came back in `wallet-provider.tsx` and `fusion-chamber.tsx`, plus `Uint8Array` typing in `biometric-unlock.ts` and `window.dataLayer`. + +Left for the owning spikes (logic bugs, not merge damage): + +- `xbull-adapter.test.ts` (spike 624): imports five exports the adapter never defines and expects `USER_REJECTED` where the adapter returns `REJECTED`. The test and the module disagree, so one of them has to be rewritten by the author. 5 failing tests, 7 tsc errors. +- `moderation-audit-redaction.test.ts`: the regulator export still contains the raw reporter wallet. Privacy bug. +- `walletconnect-resilience.test.ts` (spike 623): session topic is not persisted. +- `biometric-unlock.test.ts` (spike 628): a user cancel surfaces as "no sealed envelope" instead of `CANCELLED`. +- `wallet-history.test.ts` (spike 629): stale-on-error serves `fresh`. + +## Also found, not fixed + +- **Flag collision:** b89c4f2 used `phase-142`/`phase-143` for the curator and pricing oracle, while `lib/feature-flags.ts` documents `phase-142` as AI thumbnails and `phase-143` as churn/winback. Both features read the same env var. +- **Other 3e4b562 losses:** most of its `app/api/market/route.ts` edition/split changes and its doc rows are gone. Restoring them is feature work, not a baseline fix. +- **Lint:** 286 pre-existing errors, so the CI lint step stays non-blocking. +- **CSP blocks the ticker:** `connect-src` doesn't allow `api.coingecko.com` or `timeapi.io`, which `components/system-ticker.tsx` fetches, so XLM price and EST time never load in the browser. Widening the CSP is a security call for a maintainer. +- **Build tracing warnings:** `lib/settlement-store.ts` makes Next trace the whole project from `/api/x402/verify`. diff --git a/docs/spikes/663-api-versioning-deprecation-policy-findings.md b/docs/spikes/663-api-versioning-deprecation-policy-findings.md new file mode 100644 index 00000000..f9eaa399 --- /dev/null +++ b/docs/spikes/663-api-versioning-deprecation-policy-findings.md @@ -0,0 +1,75 @@ +# Spike 663 — Machine-Readable API Versioning, Changelog and Deprecation Policy Findings (Issue #526) + +## What the issue claimed + +The public API has no version prefix, no `Accept-Version` / `X-API-Version` header and no sunset mechanism, so compatibility rests on convention. Flag-gated extensions change response shapes without negotiation, and the architecture doc requires doc updates but defines no deprecation lifecycle. The goal: version the HTTP surface with a published changelog and enforced deprecation windows. That includes a machine-readable manifest (OpenAPI generated from zod), CI checks for breaking changes, `Sunset` headers, `410 Gone` after the window, a changelog backfilled from git history, and dual-version serving during migrations. + +## What the code actually did at spike time + +- No route read `Accept-Version` or `X-API-Version`, and no response carried a version. +- No `Deprecation`, `Sunset` or `Link: rel="deprecation"` header existed. +- There was no OpenAPI document. Shapes lived only in the handlers, and only some routes validate with zod (`classic-liq/trustline`, `ipfs`); most parse bodies by hand. +- `lib/api-observability.ts:createApiRequestContext` existed, but no listed route used it. +- Several `/api` routes set `Vary: Authorization` (gated metadata, IPFS, OG) so shared caches can't leak gated responses. + +## Delivered in this PR (phase-api-versioning) + +1. **Contract source** (`lib/api-contract.ts`): one typed entry per operation (24, across 15 paths) with params, body fields, the status codes each handler returns (read per handler), `stateChanging` and `signing`. +2. **Negotiation** (`lib/api-versioning.ts`, wired in `middleware.ts` for `/api/*`, preflights excluded). `X-API-Version` pins an exact id and wins over `Accept-Version`, which takes the first supported id from a list. No header means the current version. Unknown or malformed gives `406 UNSUPPORTED_API_VERSION` with the supported list. Past its sunset gives `410 API_VERSION_SUNSET`. +3. **Headers on every listed route**: `X-API-Version` echo; `Deprecation: @` (RFC 9745), `Sunset: ` (RFC 8594) and `Link` with `rel="deprecation"` / `rel="successor-version"` for deprecated versions or routes (`ROUTE_DEPRECATIONS`, path templates). `Vary` is a superset of every token an `/api` route uses (`API_VARY_TOKENS`), so the middleware can't drop a route's `Vary: Authorization`. A test scans `app/api` to keep it complete. +4. **Handler visibility**: middleware forwards the negotiated id. `createApiRequestContext` logs it as `apiVersion` (known ids only, so the label set stays closed) and echoes it. +5. **Machine-readable docs**: `docs/api/openapi.json` (OpenAPI 3.1, with `x-state-changing`, `x-signing`, `x-api-versions`) and `docs/api/changelog.json`, rendered by `npm run api:contract -- --write`. `GET /api/versions` serves them, and returns 404 when the flag is off. +6. **Breaking-change check** (`npm run api:contract`, blocking CI step): diffs the generated contract against the target branch's snapshot. It catches removed operations, params, body fields, 2xx statuses and response headers, newly required params, and versions removed before their sunset. A break passes only with a new current version plus a changelog entry flagged `breaking: true`. Exit codes: 0 ok, 1 breaking, 2 stale snapshot, 64 bad args. + +## Spike questions (from the issue) + +1. **Minimal testnet loop, and which listed files can break it?** Negotiation sits in front of every route in the loop (faucet, `classic-liq`/trustline, `soroban-rpc`, `x402`, `metadata/[id]`, `market`). With the flag on, `middleware.ts` and `lib/api-versioning.ts` are the single point that can break all of them at once: a bad current version or a throwing negotiation would 406 or 500 every call. That's why negotiation is pure, unit-tested and defaults to the current version when no header is sent. Route files only fail on their own as before. `npm run proof:663` exercises every listed route through the middleware. +2. **Replay and idempotency per write.** Unchanged: negotiation adds no state and doesn't touch the contract `settled` flag, `used-settlements.json`, `viewer_jti`, `faucetPending` or version CAS. A double submit after a serverless timeout reaches the handler exactly as before. A 406/410 happens before the handler, so a rejected version can never half-apply a write. +3. **Contract-ID drift, issuer rotation, gateway failover.** Not affected and not fenced by this spike; the existing checks in the handlers still apply. What versioning adds is a fence for **shape drift**: a client pinned to a version gets a 406/410 instead of silently receiving a different shape. +4. **409/429/412 taxonomy.** Untouched in the handlers. This spike adds `406 UNSUPPORTED_API_VERSION` (unknown or malformed id, with `supported`) and `410 API_VERSION_SUNSET`. Both are typed JSON and logged as `api_version_rejected` with closed fields only. `Retry-After`, `ETag`/`If-Match` and vector-clock semantics are unchanged. UI recovery isn't needed because first-party clients send no version header and always get the current one. +5. **Migration and rollback.** No WASM, schema or sidecar change. Rollback: unset the flag and restart. Negotiation stops, version headers disappear, `/api/versions` returns 404, and nothing is persisted. The CI gate only reads source files, so it stays. + +## Acceptance criteria status + +| Criterion | Status | +|---|---| +| End-to-end testnet proof with tx hashes and contract IDs | `npm run proof:663` proves headers and negotiation on a deployment (read-only). Tx hashes don't apply: negotiation never writes on-chain. Placeholders are below. | +| Concurrency proof | Not applicable: no new writes. | +| Abuse proof (replay, tampered input; no PII in labels) | Malformed or oversized version headers → typed 406. The requested value is never logged. The `x-phase-api-version` a client sends is overwritten by middleware and only known ids are logged. | +| Resilience proof | Not applicable to header negotiation. The handlers' failover is unchanged. | +| i18n, `#13`, docs, flag row | No new UI strings. `#13` handling is untouched. Architecture and technical docs updated with a flag row and rollback. | +| No regression with flags off | tsc 0 errors, vitest green. With the flag off, middleware returns the same response as before (tested). | + +## Lab results (production build, cloud container) + +`next build && next start` of `feat/docs-api-playground` (which contains this branch), run on 2026-10-18. + +- **Flag on (`FEATURE_PHASE_API_VERSIONING=1`, then named `FEATURE_PHASE_163`):** `npm run proof:663` passed 15/15. `/api/versions` returned 200 with current `2026-10-17`, the OpenAPI document has 15 paths, all ten listed routes echo `X-API-Version` with the full `Vary` superset, `X-API-Version` pinning and `Accept-Version` lists work, and `1999-01-01` gets a typed `406 UNSUPPORTED_API_VERSION`. +- **Gated cache key kept:** `/api/metadata/1` responds with `Vary: Accept-Version, X-API-Version, Authorization, Accept-Language, X-Phase-Lang`. +- **Flag off:** the proof reports `/api/versions` → 404 as expected. `GET /api/market` with `X-API-Version: nope` returns 200 with no `X-API-Version` header, which is the pre-spike behavior. + +A deployed preview run (checklist below) still confirms the same on Vercel's edge. + +## Live proof checklist (run by the maintainer) + +1. Deploy this branch to a preview with `FEATURE_PHASE_API_VERSIONING=1` (no other env needed for this proof). +2. `npm run proof:663 -- --base-url https://`. Every line should say PASS, and the run writes `.data/proofs/663-*.json`. +3. Check gated caching on a real token: `curl -sI "https:///api/metadata/?c="` must still include `Authorization` in `Vary` and the route's CORS headers. Take `TOKEN_ID` and `CONTRACT_ID` from your deployment's env, not from code. +4. Unset `FEATURE_PHASE_API_VERSIONING`, redeploy, and rerun step 2. The first check should fail with "returned 404", and no `X-API-Version` header should appear. +5. Fill in: + +| Item | Value | +|---|---| +| Preview URL | `<>` | +| Current version reported by `/api/versions` | `<>` | +| `Vary` on `/api/metadata/` (flag on) | `<>` | +| Proof file (flag on) | `<>` | +| Proof file (flag off) | `<>` | + +## Follow-up / out of scope for this PR + +- **OpenAPI generated from zod.** The contract is a hand-written typed list because most listed routes have no zod schema. Generating from zod means adding schemas to those handlers first. +- **Changelog backfilled from git history.** `changelog.json` starts at `2026-10-17`, and earlier route changes aren't reconstructed. +- **Dual-version serving.** There's only one version today. The negotiation and the forwarded `x-phase-api-version` are the hook handlers would branch on, but no handler serves two shapes yet. +- **Response-shape diffing.** The contract records field names and statuses, not full response schemas, so a changed response body isn't caught. +- **Route adoption of `createApiRequestContext`.** Only routes that use it log `apiVersion`; none of the listed routes do yet. diff --git a/docs/spikes/PR_663.md b/docs/spikes/PR_663.md new file mode 100644 index 00000000..532f790d --- /dev/null +++ b/docs/spikes/PR_663.md @@ -0,0 +1,46 @@ +# feat(api): version negotiation, deprecation headers and a breaking-change gate (spike 663) + +## Summary + +Versions the public HTTP surface. Middleware negotiates `X-API-Version` / `Accept-Version` on `/api/*`, echoes the version, and answers unknown versions with a typed `406` and retired ones with `410`. Deprecated routes get RFC 9745 `Deprecation` and RFC 8594 `Sunset` headers. A machine-readable OpenAPI contract and changelog are generated from one source, and a blocking CI step stops unversioned breaking changes. Runtime behavior is behind `phase-api-versioning` (default off). + +## Changes + +- `lib/api-contract.ts`: typed contract for 24 operations; statuses read per handler. +- `lib/api-versioning.ts`: negotiation, `versionResponseHeaders`, `API_VARY_TOKENS`, OpenAPI 3.1 builder, `diffContracts` and `checkContract`. +- `middleware.ts`: negotiation for `/api/*` (preflights excluded). The flag off returns the same response as before. +- `lib/api-observability.ts`: logs and echoes the negotiated version (known ids only). +- `GET /api/versions`: summary, or the OpenAPI document with `?format=openapi`. +- `docs/api/openapi.json`, `docs/api/changelog.json` via `npm run api:contract -- --write`. +- `.github/workflows/ci.yml`: blocking contract gate against the target branch snapshot. +- `scripts/proof-api-versioning-663.ts` (`npm run proof:663`): read-only live proof. + +## Behavior notes + +- `Vary` is a superset of every token an `/api` route sets, including `Authorization`, so gated responses keep their cache key. A test scans `app/api` to keep the list complete. +- The requested version value is never logged. Rejections log only `error` and `status`. + +## Flag and rollback + +`FEATURE_PHASE_API_VERSIONING` / `NEXT_PUBLIC_FEATURE_PHASE_API_VERSIONING`, default off. Rollback: unset and restart. No negotiation or version headers, `/api/versions` returns 404, nothing persisted. The CI gate is source-only and stays on. + +This flag was `phase-163` until upstream assigned 163 to network resolution. It is now a named flag so it cannot collide again. + +## Tests + +- `lib/__tests__/api-versioning.test.ts` (19): negotiation precedence, 406/410, RFC header formats, path-template deprecations, the Vary superset scan, contract coverage, snapshot freshness, diff and gate rules, and middleware flag off/on. +- `tsc --noEmit`: 0 errors. `vitest run`: green with flags off. Locally, `npm run proof:663` passed all 15 checks against `next dev` with the flag on. + +## Live proof + +Production build run: 15/15 checks pass with the flag on. With the flag off, `/api/versions` is 404 and no version headers appear. `/api/metadata/[id]` keeps `Authorization` in `Vary`. Details are in the spike doc under "Lab results". No preview deploy; proofs were run against a local production build and `next dev`. + +## Follow-up + +OpenAPI generated from zod schemas, a changelog backfilled from git history, dual-version serving, response-shape diffing, and route adoption of `createApiRequestContext`. Details are in the spike doc. + +## Depends on + +#574 (`chore/fix-main-baseline`). Merge that first; until it merges, this diff also shows its commits. `feat/docs-api-playground` (#525) builds on this branch. + +Closes PHASE-STELLAR/Phase#526 diff --git a/lib/__tests__/api-versioning.test.ts b/lib/__tests__/api-versioning.test.ts new file mode 100644 index 00000000..a740fb10 --- /dev/null +++ b/lib/__tests__/api-versioning.test.ts @@ -0,0 +1,235 @@ +import fs from "node:fs" +import path from "node:path" +import { NextRequest } from "next/server" +import { afterEach, describe, expect, it } from "vitest" +import { API_OPERATIONS } from "@/lib/api-contract" +import { + API_CHANGELOG, + API_VARY_TOKENS, + buildOpenApiDocument, + checkContract, + currentApiVersion, + diffContracts, + negotiateApiVersion, + pathMatchesTemplate, + versionResponseHeaders, + type ApiVersion, + type OpenApiDocument, +} from "@/lib/api-versioning" +import { middleware } from "@/middleware" + +const h = (init: Record) => new Headers(init) +const V: ApiVersion[] = [ + { id: "2026-01-01", released: "2026-01-01", status: "deprecated", deprecatedAt: "2026-06-01", sunsetAt: "2027-01-01" }, + { id: "2026-10-17", released: "2026-10-17", status: "current" }, +] +const NOW = new Date("2026-10-17T12:00:00Z") + +describe("phase-api-versioning negotiation", () => { + it("defaults to the current version without headers", () => { + expect(negotiateApiVersion(h({}), { versions: V, now: NOW })).toEqual({ + ok: true, + version: "2026-10-17", + source: "default", + }) + }) + + it("X-API-Version pins and wins over Accept-Version", () => { + const r = negotiateApiVersion(h({ "X-API-Version": "2026-01-01", "Accept-Version": "2026-10-17" }), { + versions: V, + now: NOW, + }) + expect(r).toEqual({ ok: true, version: "2026-01-01", source: "x-api-version" }) + }) + + it("Accept-Version takes the first supported entry", () => { + const r = negotiateApiVersion(h({ "Accept-Version": "1999-01-01, 2026-10-17" }), { versions: V, now: NOW }) + expect(r).toEqual({ ok: true, version: "2026-10-17", source: "accept-version" }) + }) + + it("unknown or malformed versions get 406 with the supported list", () => { + for (const bad of ["v2", "2030-01-01", "