diff --git a/.env.local.example b/.env.local.example index 5ce48d8e..20b3d3c4 100644 --- a/.env.local.example +++ b/.env.local.example @@ -208,3 +208,25 @@ NEXT_PUBLIC_CLASSIC_LIQ_ISSUER= # phase-124: metadata version migration tool v1→v2 (scripts/reset-phase --migrate-metadata) # NEXT_PUBLIC_FEATURE_PHASE_124=1 # FEATURE_PHASE_124=1 + +# Spikes 620/621/630/631. All off by default. Rebuild after public flag changes. +NEXT_PUBLIC_FEATURE_PHASE_174=0 +FEATURE_PHASE_174=0 +NEXT_PUBLIC_FEATURE_PHASE_175=0 +FEATURE_PHASE_175=0 +NEXT_PUBLIC_FEATURE_PHASE_176=0 +FEATURE_PHASE_176=0 +NEXT_PUBLIC_FEATURE_PHASE_177=0 +FEATURE_PHASE_177=0 +# Verified native app association identifiers; empty arrays are served until configured. +PHASE_ANDROID_PACKAGE= +PHASE_ANDROID_SHA256_CERTS= +PHASE_IOS_APP_ID= +# KYC adapter. Mock is accepted only on testnet. Never put these keys in NEXT_PUBLIC_*. +PHASE_KYC_MODE=off +PHASE_KYC_THRESHOLD_STROOPS=10000000000 +PHASE_KYC_HASH_KEY= +PHASE_KYC_WEBHOOK_SECRET= +PHASE_SANCTIONS_URL= +PHASE_SANCTIONS_API_KEY= +PHASE_KYC_MOCK_BLOCKED_WALLETS= diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index ac5bb603..dd11df65 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -212,6 +212,11 @@ is a fixed-size digest of the canonical payload, keeping it under wallet | `phase-167` | `NEXT_PUBLIC_FEATURE_PHASE_167` / `FEATURE_PHASE_167` | WalletConnect v2 mobile session resilience (`lib/walletconnect-resilience.ts`): configurable relay URL, multi-chain support, session persistence, chain-switch guard, and mobile deep-link return. Spike 623 (issue #486) | off | Unset var, restart — WalletConnect banner and reconnect actions disabled, kit falls back to default single-session behavior | | `phase-168` | `NEXT_PUBLIC_FEATURE_PHASE_168` / `FEATURE_PHASE_168` | xBull wallet full support and parity (`lib/xbull-adapter.ts`): Freighter-difference matrix, xBull module detection, sign result normalization, trustline error translation, and badge surfacing. Spike 624 (issue #487) | off | Unset var, restart — xBull transactions fall through generic kit handlers, badge hidden | | `phase-169` | `NEXT_PUBLIC_FEATURE_PHASE_169` / `FEATURE_PHASE_169` | Ledger blind-signing to clear-signing review for Soroban (`lib/ledger-soroban-decoder.ts`): invokeHostFunction pre-image decode, verification code, on-device review modal, Gate #13 preemptive validation, and hardware error humanizer. Spike 625 (issue #488) | off | Unset var, restart — Ledger transactions fall back to default blind-signing flow without preflight review modal | +| `phase-174` | `NEXT_PUBLIC_FEATURE_PHASE_174` / `FEATURE_PHASE_174` | Mobile Forge/Chamber viewport, controls sheet, focus and touch layout (#483) | off | Unset both vars and restart; no stored layout state | +| `phase-175` | `NEXT_PUBLIC_FEATURE_PHASE_175` / `FEATURE_PHASE_175` | Validated inbound offer/quest/collection links with wallet-connect intent retention (#484) | off | Unset both vars and restart; session intent inert; association endpoints return 404 | +| `phase-176` | `NEXT_PUBLIC_FEATURE_PHASE_176` / `FEATURE_PHASE_176` | Amount-aware KYC and fresh sanctions adapter before credits (#493); additionally requires PHASE_KYC_MODE | off | Unset both vars and restart; additive credential tables remain inert | +| `phase-177` | `NEXT_PUBLIC_FEATURE_PHASE_177` / `FEATURE_PHASE_177` | Durable transaction evidence and signed CSV export (#494) | off | Unset both vars and restart; additive evidence remains inert; export returns 404 | + @@ -262,3 +267,13 @@ Collection metadata for explorer verification badges: - relevant API docs - Contract/address changes require synchronized env and docs updates. - Flag-gated features must document rollback in this table and in `docs/TECHNICAL.md` § Feature Flags. + +## 13) Mobile inbound links, compliance, and reporting (spikes 620/621/630/631) + +Mobile UI follows `visualViewport` with a `100dvh` fallback. The Chamber controls use a modal sheet on phones; desktop retains the grid. Inbound links pass a strict, same-origin intent schema and a review screen before navigation. Session intent has a 15-minute lifetime. A suggested recipient is untrusted context, never authority to sign or transfer. `/link/collection/N` rewrites to Chamber with its collection parameter preserved for metadata generation. Custom URI input is supported at `/link`; native scheme registration belongs to the native host. + +Compliance is a server pre-credit gate, independent of denial lists, Turnstile, sybil scoring and 2FA. Live sanctions results must be fresh; provider outage returns 503 with Retry-After. Identity callbacks use signed exact bytes, time bounds, SQLite event uniqueness and version checks; only keyed hashes of wallets/provider subjects are persisted. No identity documents are accepted. Mock mode is testnet-only. This is an integration adapter, not a declaration of regulatory compliance. + +Tax evidence is additive and append-only in SQLite. Amounts are integer stroops stored as text. Offer decisions and their unverified reporting evidence commit in one immediate transaction; acceptance alone is never labeled as an on-chain payment. CSV uses signed, expiring, one-use export authorization, a sequence snapshot and 50-row pages. Missing cost basis and FX are explicit, not inferred as zero. This testnet export is not a filed tax form. No contracts, addresses or WASM are changed. + +See [implementation and acceptance evidence](docs/spikes/620-621-630-631-integration.md) for file decisions, remaining deployment prerequisites and validation limits. diff --git a/app/.well-known/apple-app-site-association/route.ts b/app/.well-known/apple-app-site-association/route.ts new file mode 100644 index 00000000..bb5f474b --- /dev/null +++ b/app/.well-known/apple-app-site-association/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server" +import { isFeatureEnabled } from "@/lib/feature-flags" +export function GET() { + if (!isFeatureEnabled("phase-175")) return new NextResponse(null, { status: 404 }) + const appID = process.env.PHASE_IOS_APP_ID + return NextResponse.json({ applinks: { apps: [], details: appID && /^[A-Z0-9]{10}\.[a-zA-Z0-9.-]+$/.test(appID) ? [{ appID, paths: ["/link/*"] }] : [] } }) +} diff --git a/app/.well-known/assetlinks.json/route.ts b/app/.well-known/assetlinks.json/route.ts new file mode 100644 index 00000000..48d1467e --- /dev/null +++ b/app/.well-known/assetlinks.json/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server" +import { isFeatureEnabled } from "@/lib/feature-flags" +export function GET() { + if (!isFeatureEnabled("phase-175")) return new NextResponse(null, { status: 404 }) + const packageName = process.env.PHASE_ANDROID_PACKAGE + const fingerprints = (process.env.PHASE_ANDROID_SHA256_CERTS ?? "").split(",").map(s => s.trim()).filter(s => /^([A-F0-9]{2}:){31}[A-F0-9]{2}$/.test(s)) + return NextResponse.json(packageName && fingerprints.length ? [{ relation: ["delegate_permission/common.handle_all_urls"], target: { namespace: "android_app", package_name: packageName, sha256_cert_fingerprints: fingerprints } }] : []) +} diff --git a/app/api/claim-bounty/route.ts b/app/api/claim-bounty/route.ts index 9dc84d58..7d285365 100644 --- a/app/api/claim-bounty/route.ts +++ b/app/api/claim-bounty/route.ts @@ -56,11 +56,11 @@ export async function POST(request: NextRequest): Promise ({ error: `HTTP ${upstream.status}` }))) as ClaimBountyResponse - return NextResponse.json(upstreamJson, { status: upstream.status }) + return NextResponse.json(upstreamJson, { status: upstream.status, headers: { "Cache-Control": "private, no-store", ...(upstream.headers.get("retry-after") ? { "Retry-After": upstream.headers.get("retry-after")! } : {}) } }) } diff --git a/app/api/classic-liq/route.ts b/app/api/classic-liq/route.ts index 55503a95..2e35538b 100644 --- a/app/api/classic-liq/route.ts +++ b/app/api/classic-liq/route.ts @@ -1,3 +1,6 @@ +import { recordTaxEvent } from "@/lib/tax-report" +import { complianceGate } from "@/lib/compliance" +import { decimalToStroops } from "@/lib/decimal-amount" import { mkdir, readFile, writeFile, open, unlink } from "node:fs/promises" import path from "node:path" import { NextRequest, NextResponse } from "next/server" @@ -203,6 +206,9 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "walletAddress debe ser cuenta Stellar G válida." }, { status: 400 }) } + const complianceBlock = await complianceGate(wallet, decimalToStroops(config.amount), "bootstrap") + if (complianceBlock) return complianceBlock + const status = await walletStatus(wallet, config.asset) if (!status.accountExists) { return NextResponse.json( @@ -234,6 +240,7 @@ export async function POST(req: NextRequest) { ) } + let submittedHash: string | null = null try { const server = new Horizon.Server(HORIZON_URL) const source = await server.loadAccount(config.issuerKp.publicKey()) @@ -253,6 +260,8 @@ export async function POST(req: NextRequest) { .build() tx.sign(config.issuerKp) const submit = await server.submitTransaction(tx) + submittedHash = submit.hash + recordTaxEvent({ eventKey: `bootstrap:${submit.hash}`, wallet, kind: "reward", asset: `${config.asset.code}:${config.asset.issuer}`, amountStroops: decimalToStroops(config.amount), txHash: submit.hash, occurredAt: Date.now(), verified: true }) return NextResponse.json({ ok: true, hash: submit.hash, @@ -260,6 +269,7 @@ export async function POST(req: NextRequest) { amount: config.amount, }) } catch (e) { + if (submittedHash) return NextResponse.json({ pending: true, hash: submittedHash, code: "REPORTING_RECONCILIATION_REQUIRED" }, { status: 202 }) await releaseClassicFundClaim(wallet) logHorizonSubmitError("classic-liq POST payment", e) return NextResponse.json( diff --git a/app/api/classic-liq/trustline/route.ts b/app/api/classic-liq/trustline/route.ts index 382ca129..a96e50fa 100644 --- a/app/api/classic-liq/trustline/route.ts +++ b/app/api/classic-liq/trustline/route.ts @@ -1,3 +1,6 @@ +import { complianceGate } from "@/lib/compliance" +import { Transaction, TransactionBuilder } from "@stellar/stellar-sdk" +import { resolveStellarNetwork } from "@/lib/stellar-network" import { NextRequest, NextResponse } from "next/server" import { z } from "zod" import { logHorizonSubmitError } from "@/lib/stellar" @@ -68,6 +71,22 @@ export async function POST(req: NextRequest) { const isBatch = (parsed.data.signedXdrs?.length ?? 0) > 0 const signedXdrs = isBatch ? parsed.data.signedXdrs!.map((x) => x.trim()) : [parsed.data.signedXdr!.trim()] + if (isFeatureEnabled("phase-176")) { + for (const signedXdr of signedXdrs) { + let transaction + try { transaction = TransactionBuilder.fromXDR(signedXdr, resolveStellarNetwork().passphrase) } + catch { return NextResponse.json({ code: "INVALID_XDR" }, { status: 400 }) } + if (!(transaction instanceof Transaction) || transaction.operations.some(op => op.type !== "changeTrust")) { + return NextResponse.json({ code: "TRUSTLINE_OPERATIONS_ONLY" }, { status: 400 }) + } + const wallets = new Set([transaction.source, ...transaction.operations.map(op => op.source ?? transaction.source)]) + for (const wallet of wallets) { + const block = await complianceGate(wallet, "0", "trustline") + if (block) return block + } + } + } + // phase-119: optional CID integrity verification (additive, does not block legacy callers). // Only applies to the single-XDR legacy path. if (!isBatch && isPhase119Enabled() && parsed.data.cid) { diff --git a/app/api/compliance/webhook/route.ts b/app/api/compliance/webhook/route.ts new file mode 100644 index 00000000..504c9209 --- /dev/null +++ b/app/api/compliance/webhook/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server" +import { applyVerificationEvent, verifyComplianceWebhook } from "@/lib/compliance" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { readBoundedBody } from "@/lib/bounded-body" + +export const runtime = "nodejs" +export async function POST(request: NextRequest) { + const headers = { "Cache-Control": "private, no-store" } + if (!isFeatureEnabled("phase-176") || !["mock", "live"].includes(process.env.PHASE_KYC_MODE ?? "off")) return NextResponse.json({ code: "DISABLED" }, { status: 404, headers }) + let body: string + try { body = await readBoundedBody(request, 8192) } + catch { return NextResponse.json({ code: "BODY_TOO_LARGE" }, { status: 413, headers }) } + if (!verifyComplianceWebhook(body, request.headers.get("x-phase-timestamp") ?? "", request.headers.get("x-phase-signature") ?? "")) return NextResponse.json({ code: "INVALID_SIGNATURE" }, { status: 401, headers }) + try { return NextResponse.json({ ok: true, ...applyVerificationEvent(JSON.parse(body)) }, { headers }) } + catch (error) { + if (error instanceof Error && "current_version" in error) return NextResponse.json({ code: "VERSION_CONFLICT", current_version: error.current_version }, { status: 409, headers }) + return NextResponse.json({ code: "INVALID_EVENT" }, { status: 400, headers }) + } +} diff --git a/app/api/faucet/route.ts b/app/api/faucet/route.ts index 5eee91d5..6b4d8d7e 100644 --- a/app/api/faucet/route.ts +++ b/app/api/faucet/route.ts @@ -1,3 +1,5 @@ +import { recordTaxEvent } from "@/lib/tax-report" +import { complianceGate } from "@/lib/compliance" import { NextRequest, NextResponse } from "next/server" import { mkdir, readFile, writeFile } from "node:fs/promises" import path from "node:path" @@ -86,7 +88,7 @@ type WalletClaims = { dailyAt?: number quests?: Record /** Mint ya enviado; reutilizamos el hash para seguir el poll sin reenviar (serverless timeout). */ - faucetPending?: { hash: string; reward: RewardType; at: number } + faucetPending?: { hash: string; reward: RewardType; at: number; amountStroops?: string; tokenContract?: string } } type FaucetClaims = Record @@ -147,30 +149,28 @@ function claimsFilePath() { * Wraps file I/O in withFileLock to serialize overlapping POSTs to same wallet. */ async function readClaims(): Promise { - return withFileLock(claimsFilePath(), async () => { - try { - const raw = await readFile(claimsFilePath(), "utf8") - const parsed = JSON.parse(raw) as Record - if (!parsed || typeof parsed !== "object") return {} - const normalized: FaucetClaims = {} - for (const [wallet, value] of Object.entries(parsed)) { - if (typeof value === "number") { - normalized[wallet] = { genesisAt: value } - continue - } - if (!value || typeof value !== "object") continue - const fp = value.faucetPending - let faucetPending: WalletClaims["faucetPending"] - if (fp && typeof fp === "object" && typeof fp.hash === "string" && typeof fp.at === "number") { - const r = await parseRewardType(fp.reward) - faucetPending = { hash: fp.hash, reward: r, at: fp.at } - } - normalized[wallet] = { - genesisAt: typeof value.genesisAt === "number" ? value.genesisAt : undefined, - dailyAt: typeof value.dailyAt === "number" ? value.dailyAt : undefined, - quests: value.quests && typeof value.quests === "object" ? value.quests : {}, - faucetPending, - } + try { + const raw = await readFile(claimsFilePath(), "utf8") + const parsed = JSON.parse(raw) as Record + if (!parsed || typeof parsed !== "object") return {} + const normalized: FaucetClaims = {} + for (const [wallet, value] of Object.entries(parsed)) { + if (typeof value === "number") { + normalized[wallet] = { genesisAt: value } + continue + } + if (!value || typeof value !== "object") continue + const fp = value.faucetPending + let faucetPending: WalletClaims["faucetPending"] + if (fp && typeof fp === "object" && typeof fp.hash === "string" && typeof fp.at === "number") { + const r = await parseRewardType(fp.reward) + faucetPending = { hash: fp.hash, reward: r, at: fp.at, amountStroops: typeof fp.amountStroops === "string" ? fp.amountStroops : undefined, tokenContract: typeof fp.tokenContract === "string" ? fp.tokenContract : undefined } + } + normalized[wallet] = { + genesisAt: typeof value.genesisAt === "number" ? value.genesisAt : undefined, + dailyAt: typeof value.dailyAt === "number" ? value.dailyAt : undefined, + quests: value.quests && typeof value.quests === "object" ? value.quests : {}, + faucetPending, } return normalized } catch { @@ -389,7 +389,8 @@ function abortArcClaim(reward: RewardType) { if (holdId) releaseRefill(holdId) } -async function markClaim(wallet: string, reward: RewardType, txHash?: string) { +async function markClaim(wallet: string, reward: RewardType, txHash?: string, amountStroops?: string, occurredAt?: number, tokenContract?: string) { + if (txHash && amountStroops && tokenContract) recordTaxEvent({ eventKey: `faucet:${txHash}`, wallet, kind: "reward", asset: tokenContract, amountStroops, txHash, occurredAt: occurredAt ?? Date.now(), verified: true }) if (txHash) await settleArcClaim(wallet, reward, txHash) questProgressCache.delete(wallet) const claims = await readClaims() @@ -691,6 +692,14 @@ export async function POST(req: NextRequest) { ) } + let approvedAmountStroops = await rewardAmountStroops(reward) + if (reward === "daily" && isStreakMultiplierEnabled()) { + const streak = await getStreakInfo(userAddress) + if (streak.multiplier > 1) approvedAmountStroops = applyStreakMultiplier(approvedAmountStroops, streak.multiplier) + } + const complianceBlock = await complianceGate(userAddress, approvedAmountStroops, "faucet") + if (complianceBlock) return complianceBlock + const trustlineBlock = await preflightClassicTrustlineForMint(userAddress) if (trustlineBlock) return trustlineBlock @@ -836,7 +845,7 @@ export async function POST(req: NextRequest) { const pendingHash = row.faucetPending.hash const out = await pollSubmittedMint(server, pendingHash) if (out.outcome === "SUCCESS") { - await markClaim(userAddress, reward, pendingHash) + await markClaim(userAddress, reward, pendingHash, row.faucetPending.amountStroops, row.faucetPending.at, row.faucetPending.tokenContract) let streakInfo: StreakInfo | undefined if (reward === "daily") { const recorded = await recordDailyClaim(userAddress) @@ -939,13 +948,7 @@ export async function POST(req: NextRequest) { } const c = new Contract(tokenId) // phase-131: apply streak multiplier to daily reward amount - let effectiveAmountStroops = await rewardAmountStroops(reward) - if (reward === "daily" && isStreakMultiplierEnabled()) { - const streak = await getStreakInfo(userAddress) - if (streak.multiplier > 1) { - effectiveAmountStroops = applyStreakMultiplier(effectiveAmountStroops, streak.multiplier) - } - } + const effectiveAmountStroops = approvedAmountStroops const amountSc = nativeToScVal(BigInt(effectiveAmountStroops), { type: "i128" }) const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -981,13 +984,13 @@ export async function POST(req: NextRequest) { liveClaims = await readClaims() liveClaims[userAddress] = { ...(liveClaims[userAddress] ?? {}), - faucetPending: { hash, reward, at: Date.now() }, + faucetPending: { hash, reward, at: Date.now(), amountStroops: effectiveAmountStroops, tokenContract: tokenId }, } await writeClaims(liveClaims) const out = await pollSubmittedMint(server, hash) if (out.outcome === "SUCCESS") { - await markClaim(userAddress, reward, hash) + await markClaim(userAddress, reward, hash, effectiveAmountStroops, liveClaims[userAddress].faucetPending?.at, tokenId) let streakInfo: StreakInfo | undefined if (reward === "daily") { const recorded = await recordDailyClaim(userAddress) diff --git a/app/api/links/resolve/route.ts b/app/api/links/resolve/route.ts new file mode 100644 index 00000000..8fc59939 --- /dev/null +++ b/app/api/links/resolve/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { parsePhaseLink } from "@/lib/deep-links" +import { getListing, getOffers } from "@/lib/market-store" +import { getQuestRegistry } from "@/lib/quest-registry" +import { incSecurityCounter } from "@/lib/security-counters" + +export const runtime = "nodejs" +export async function GET(request: NextRequest) { + const headers = { "Cache-Control": "private, no-store" } + if (!isFeatureEnabled("phase-175")) return NextResponse.json({ code: "DISABLED" }, { status: 404, headers }) + let intent + try { intent = parsePhaseLink(request.nextUrl.searchParams.get("link") ?? "", request.nextUrl.origin) } + catch { + incSecurityCounter("deep_link_rejected") + return NextResponse.json({ code: "INVALID_LINK" }, { status: 400, headers }) + } + try { + let href: string + let context: Record = {} + if (intent.kind === "offer") { + const listing = await getListing(intent.id) + const offer = (await getOffers(intent.id)).find(o => o.id === intent.offerId) + if (!listing || listing.status !== "active" || !offer || offer.status !== "pending" || offer.expires_at <= Date.now()) { + return NextResponse.json({ code: "INTENT_UNAVAILABLE" }, { status: 410, headers }) + } + href = `/dashboard?listing=${encodeURIComponent(intent.id)}&offer=${encodeURIComponent(intent.offerId!)}` + context = { seller: listing.seller_wallet, buyer: offer.buyer_wallet, amount: offer.amount_phaselq, status: offer.status } + } else if (intent.kind === "quest") { + const quest = (await getQuestRegistry()).quests.find(q => q.id === intent.id && q.enabled) + if (!quest) return NextResponse.json({ code: "INTENT_UNAVAILABLE" }, { status: 410, headers }) + href = `/forge?quest=${encodeURIComponent(intent.id)}` + context = { questId: quest.id } + } else href = `/chamber?collection=${intent.id}` + // Recipient/referral values are review context, never transaction authority. + incSecurityCounter("deep_link_resolved") + return NextResponse.json({ intent, href, context }, { headers }) + } catch { + return NextResponse.json({ code: "INTENT_LOOKUP_UNAVAILABLE" }, { status: 503, headers: { ...headers, "Retry-After": "5" } }) + } +} diff --git a/app/api/market/[id]/offers/[offer_id]/route.ts b/app/api/market/[id]/offers/[offer_id]/route.ts index ec629c15..b2829107 100644 --- a/app/api/market/[id]/offers/[offer_id]/route.ts +++ b/app/api/market/[id]/offers/[offer_id]/route.ts @@ -1,6 +1,9 @@ +import { isFeatureEnabled } from "@/lib/feature-flags" +import { verifySignalSignature } from "@/lib/viewer-signature" import { NextRequest, NextResponse } from "next/server" import { StrKey } from "@stellar/stellar-sdk" import { + decideOfferAtomic, getListing, getOffers, updateOfferStatus, @@ -8,9 +11,6 @@ import { isPhase140Enabled, computeRoyaltySplit, recordRoyaltyPayout, - isPhaseCollabSplitsEnabled, - computeCollabSplits, - recordCollabSplits, } from "@/lib/market-store" import { createNotification } from "@/lib/notification-store" import { isPricingOracleEnabled, markSuggestionSold } from "@/lib/pricing-oracle" @@ -18,7 +18,7 @@ import { isPricingOracleEnabled, markSuggestionSold } from "@/lib/pricing-oracle export const runtime = "nodejs" export const dynamic = "force-dynamic" -type ActionBody = { action?: unknown; wallet?: unknown } +type ActionBody = { action?: unknown; wallet?: unknown; timestamp?: unknown; signature?: unknown } export async function POST( request: NextRequest, @@ -39,6 +39,14 @@ export async function POST( return NextResponse.json({ error: "action must be accept or reject" }, { status: 400 }) } + const atomic = isFeatureEnabled("phase-175") || isFeatureEnabled("phase-177") + if (atomic) { + const timestamp = body.timestamp + if (typeof timestamp !== "number" || !Number.isSafeInteger(timestamp) || Math.abs(Date.now() - timestamp) > 300_000 || typeof body.signature !== "string" || + !(await verifySignalSignature(wallet, { title: `market:${id}:${offer_id}:${action}`, body: wallet, timestamp }, body.signature))) { + return NextResponse.json({ code: "INVALID_SIGNATURE" }, { status: 401 }) + } + } const listing = await getListing(id) if (!listing) return NextResponse.json({ error: "Listing not found" }, { status: 404 }) if (listing.seller_wallet !== wallet) return NextResponse.json({ error: "Forbidden" }, { status: 403 }) @@ -49,13 +57,17 @@ export async function POST( if (offer.status !== "pending") return NextResponse.json({ error: "Offer not pending" }, { status: 409 }) const newStatus = action === "accept" ? "accepted" : "rejected" - const updated = await updateOfferStatus(offer_id, newStatus) + let updated + try { updated = atomic ? decideOfferAtomic(id, offer_id, wallet, action) : await updateOfferStatus(offer_id, newStatus) } + catch (error) { + return NextResponse.json({ code: "OFFER_CONFLICT", current_status: error instanceof Error && "current_status" in error ? error.current_status : "unavailable" }, { status: 409 }) + } // If accepted, mark listing as sold let royalty: Awaited> | null = null let splits: Awaited> | null = null if (action === "accept") { - await soldListing(id) + if (!atomic) await soldListing(id) // phase-143: time-to-sale for the suggestion this listing was priced from. if (isPricingOracleEnabled()) { @@ -93,5 +105,5 @@ export async function POST( .catch(() => { /* silent */ }) } - return NextResponse.json({ ok: true, offer: updated, ...(royalty ? { royalty } : {}), ...(splits ? { splits } : {}) }) + return NextResponse.json({ ok: true, offer: updated, ...(royalty ? { royalty } : {}) }) } diff --git a/app/api/tax/export/route.ts b/app/api/tax/export/route.ts new file mode 100644 index 00000000..bdf36d33 --- /dev/null +++ b/app/api/tax/export/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { taxExportSchema, verifyTaxExport, taxCsv, taxExportRateLimited } from "@/lib/tax-report" +import { resolveStellarNetwork } from "@/lib/stellar-network" +import { getDb } from "@/lib/sqlite-db" +import { incSecurityCounter } from "@/lib/security-counters" +import { readBoundedBody } from "@/lib/bounded-body" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" +export const maxDuration = 60 +export async function POST(request: NextRequest) { + const headers = { "Cache-Control": "private, no-store" } + if (!isFeatureEnabled("phase-177")) return NextResponse.json({ code: "DISABLED" }, { status: 404, headers }) + if (resolveStellarNetwork().network !== "testnet") return NextResponse.json({ code: "TESTNET_ONLY" }, { status: 503, headers }) + let raw: { claims?: unknown; signature?: string } + try { raw = JSON.parse(await readBoundedBody(request, 4096)) } catch { return NextResponse.json({ code: "INVALID_JSON" }, { status: 400, headers }) } + if (!raw || typeof raw !== "object" || typeof raw.signature !== "string") return NextResponse.json({ code: "INVALID_REQUEST" }, { status: 400, headers }) + const parsed = taxExportSchema.safeParse(raw.claims) + if (!parsed.success) return NextResponse.json({ code: "INVALID_REQUEST" }, { status: 400, headers }) + const claims = parsed.data + if (!verifyTaxExport(claims, resolveStellarNetwork().passphrase, raw.signature ?? "")) return NextResponse.json({ code: "INVALID_SIGNATURE" }, { status: 401, headers }) + if (taxExportRateLimited(claims.wallet)) return NextResponse.json({ code: "RATE_LIMITED" }, { status: 429, headers: { ...headers, "Retry-After": String(60 - Math.floor(Date.now() / 1000) % 60) } }) + const db = getDb() + db.prepare("DELETE FROM tax_export_nonces WHERE expires_at({ + pull(controller) { + try { const next = iterator.next(); if (next.done) controller.close(); else controller.enqueue(encoder.encode(next.value)) } + catch (error) { controller.error(error) } + }, cancel() { iterator.return(undefined) }, + }) + incSecurityCounter("tax_exported") + return new Response(stream, { headers: { ...headers, "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": `attachment; filename="phase-testnet-${claims.wallet}-${claims.from.slice(0,10)}-${claims.policy}.csv"`, "X-Tax-Snapshot": String(snapshot), "X-Content-Type-Options": "nosniff" } }) +} diff --git a/app/api/x402/settle/route.ts b/app/api/x402/settle/route.ts index bc717378..6f8458e9 100644 --- a/app/api/x402/settle/route.ts +++ b/app/api/x402/settle/route.ts @@ -1,3 +1,5 @@ +import { complianceGate } from "@/lib/compliance" +import { isFeatureEnabled } from "@/lib/feature-flags" import { NextRequest, NextResponse } from "next/server" import { REQUIRED_AMOUNT, tokenContractIdForServer } from "@/lib/phase-protocol" @@ -33,6 +35,9 @@ function isSatisfiedPayment(payload: LocalX402Token | null): boolean { } export async function POST(request: NextRequest) { + if (isFeatureEnabled("phase-176") && process.env.PHASE_KYC_MODE === "live") { + return NextResponse.json({ code: "DEMO_SHIM_FORBIDDEN" }, { status: 503 }) + } if (!localShimEnabled()) { return NextResponse.json({ error: "x402 local shim disabled" }, { status: 503 }) } @@ -45,6 +50,8 @@ export async function POST(request: NextRequest) { } const payload = decodeX402Token(token) + const block = await complianceGate(body.user_address ?? "", String(payload?.amount ?? ""), "x402") + if (block) return block if (!isSatisfiedPayment(payload)) { return NextResponse.json({ error: "Invalid or insufficient x402 payment token" }, { status: 401 }) } diff --git a/app/api/x402/verify/route.ts b/app/api/x402/verify/route.ts index ba50e779..3b899fc2 100644 --- a/app/api/x402/verify/route.ts +++ b/app/api/x402/verify/route.ts @@ -1,3 +1,5 @@ +import { complianceGate } from "@/lib/compliance" +import { isFeatureEnabled } from "@/lib/feature-flags" import { NextRequest, NextResponse } from "next/server" import { REQUIRED_AMOUNT } from "@/lib/phase-protocol" import { isSettlementUsed } from "@/lib/settlement-store" @@ -32,15 +34,20 @@ function isMaisonedPayment(payload: LocalX402Token | null): boolean { } export async function POST(request: NextRequest) { + if (isFeatureEnabled("phase-176") && process.env.PHASE_KYC_MODE === "live") { + return NextResponse.json({ code: "DEMO_SHIM_FORBIDDEN" }, { status: 503 }) + } if (!localShimEnabled()) { return NextResponse.json({ error: "x402 local shim disabled" }, { status: 503 }) } try { - const body = (await request.json()) as { payment_token?: string } + const body = (await request.json()) as { payment_token?: string; user_address?: string } const token = body.payment_token?.trim() if (!token) return NextResponse.json({ error: "Missing payment_token" }, { status: 400 }) const payload = decodeX402Token(token) + const block = await complianceGate(body.user_address ?? "", String(payload?.amount ?? ""), "x402") + if (block) return block const verified = isMaisonedPayment(payload) if (verified && typeof payload?.invoice === 'string') { diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index b023f269..b1c73065 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,5 +1,7 @@ "use client" +import { TaxExportPanel } from "@/components/tax-export-panel" +import { IntentTarget } from "@/components/intent-target" import Link from "next/link" import { useRouter } from "next/navigation" import { useCallback, useEffect, useMemo, useState } from "react" @@ -372,6 +374,8 @@ export default function DashboardPage() {
+ + {/* Page title */}

diff --git a/app/forge/page.tsx b/app/forge/page.tsx index 3bcdf7e1..fd648183 100644 --- a/app/forge/page.tsx +++ b/app/forge/page.tsx @@ -1,5 +1,8 @@ "use client" +import { useMobileViewport, useMobileDialogFocus, useIsMobile } from "@/hooks/use-mobile" +import { FreighterConnect } from "@/components/freighter-connect" +import { IntentTarget } from "@/components/intent-target" import Link from "next/link" import dynamic from "next/dynamic" import { createPortal } from "react-dom" @@ -217,6 +220,8 @@ type FaucetQuestBrief = { } export default function ForgePage() { + useMobileViewport() + const mobile = useIsMobile() const { lang } = useLang() const copy = pickCopy(lang) const f = copy.forge @@ -257,6 +262,7 @@ export default function ForgePage() { type ForgeOverlay = null | "rewards" | "protocol" | "lore" | "collection" const [forgeOverlay, setForgeOverlay] = useState(null) + useMobileDialogFocus(!!forgeOverlay, '[aria-labelledby="forge-overlay-title"]') const [faucetBrief, setFaucetBrief] = useState(null) const prevForgeOverlay = useRef(null) @@ -1173,7 +1179,8 @@ export default function ForgePage() { ]) return ( -
+
@@ -1187,6 +1194,7 @@ export default function ForgePage() { {f.title}
+ {mobile && isFeatureEnabled("phase-174") && } playTacticalUiClick()}> {lang === "es" ? "Mundos" : "World"} @@ -1200,6 +1208,7 @@ export default function ForgePage() {
+
header { flex-wrap: wrap; height: auto; flex-shrink: 0; } + [data-mobile-layout] button, [data-mobile-layout] a, .phase-mobile-sheet button, .phase-mobile-sheet a { + min-height: 44px; min-width: 44px; touch-action: manipulation; + } + [data-mobile-layout] input, [data-mobile-layout] textarea, [data-mobile-layout] select { font-size: 16px; } + [data-mobile-layout] .tactical-cockpit-stage, [data-mobile-layout] .tactical-cockpit-stage__content { + overflow-y: auto; overscroll-behavior-y: contain; + } + [data-mobile-layout] .tactical-cockpit-stage__content > div, + [data-mobile-layout] .tactical-cockpit-stage__content > div > div, + [data-mobile-layout] .tactical-cockpit-stage__content > div > div > div { overflow: visible; flex-shrink: 0; } + .phase-mobile-sheet aside { height: auto; overflow: visible; } + .phase-mobile-sheet button { font-size: 12px; } +} +@media (prefers-reduced-motion: reduce) { + [data-mobile-layout] *, .phase-mobile-sheet { animation: none !important; transition: none !important; } +} +@media (max-width: 1023px) { + [data-mobile-layout] button, [data-mobile-layout] a { min-height: 44px; min-width: 44px; } +} +@media (max-width: 767px) { + [data-mobile-layout] p, [data-mobile-layout] label, [data-mobile-layout] button, [data-mobile-layout] a { font-size: 12px; } + [data-mobile-layout] [role="dialog"] { padding-bottom: max(12px, env(safe-area-inset-bottom)); } + [data-mobile-layout] [role="dialog"] > div { max-height: calc(var(--phase-viewport-height, 100dvh) - 24px); } +} diff --git a/app/layout.tsx b/app/layout.tsx index fc9f5875..42018bea 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,5 @@ import type React from "react" -import type { Metadata } from "next" +import type { Viewport, Metadata } from "next" import { IBM_Plex_Sans, IBM_Plex_Mono, Bebas_Neue, Orbitron } from "next/font/google" import { Analytics } from "@vercel/analytics/next" import { AppProviders } from "@/components/app-providers" @@ -7,6 +7,8 @@ import { SmoothScroll } from "@/components/smooth-scroll" import { WebVitalsReporter } from "@/components/web-vitals-reporter" import "./globals.css" +export const viewport: Viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" } + const PRODUCTION_SITE = "https://www.phasee.xyz" function siteMetadataBase(): URL { diff --git a/app/link/page.tsx b/app/link/page.tsx new file mode 100644 index 00000000..6fb146ff --- /dev/null +++ b/app/link/page.tsx @@ -0,0 +1,7 @@ +import { notFound } from "next/navigation" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { LinkEntryForm } from "@/components/link-entry-form" +export default function LinkPage() { + if (!isFeatureEnabled("phase-175")) notFound() + return +} diff --git a/components/chamber/catalog-thumb.tsx b/components/chamber/catalog-thumb.tsx index 81c5028f..078a73ab 100644 --- a/components/chamber/catalog-thumb.tsx +++ b/components/chamber/catalog-thumb.tsx @@ -1,25 +1,7 @@ -"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)} - /> - ) +export function ChamberCatalogThumb({ uri }: { uri: string; collectionId: number }) { + return
+ +
} diff --git a/components/chamber/chamber-chrome.ts b/components/chamber/chamber-chrome.ts index 9919347f..be0ef59d 100644 --- a/components/chamber/chamber-chrome.ts +++ b/components/chamber/chamber-chrome.ts @@ -1,11 +1,4 @@ -// 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" +export const chamberChromeNav = "inline-flex min-h-11 items-center rounded border border-zinc-700 px-3 py-2 text-xs text-zinc-300 hover:border-violet-500 focus-visible:outline-2" +export const chamberChromeNavHere = `${chamberChromeNav} border-violet-500 text-violet-200` +export const chamberChromePrimaryBtn = "inline-flex min-h-11 items-center justify-center rounded border border-cyan-600 px-4 py-3 text-sm text-cyan-100 disabled:opacity-50" +export const chamberChromeRefreshBtn = `${chamberChromeNav} disabled:opacity-50` diff --git a/components/chamber/log-stream.tsx b/components/chamber/log-stream.tsx index 68ca66df..b640db0d 100644 --- a/components/chamber/log-stream.tsx +++ b/components/chamber/log-stream.tsx @@ -1,36 +1,7 @@ -"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} -
- ) - })} -
- - ) +export function ChamberLogStream({ lines, endRef }: { lines: { id: number; text: string }[]; endRef: RefObject }) { + return
+ {lines.map(line =>

{line.text}

)}
+
} diff --git a/components/chamber/phaser-liq-token-link.tsx b/components/chamber/phaser-liq-token-link.tsx index d334c640..e7cf5a87 100644 --- a/components/chamber/phaser-liq-token-link.tsx +++ b/components/chamber/phaser-liq-token-link.tsx @@ -1,45 +1,8 @@ -"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} - - ) +export function PhaserLiqTokenLink({ href, symbol, className }: { href: string; symbol: string; className?: string }) { + return + {symbol} + } diff --git a/components/floating-header.tsx b/components/floating-header.tsx index 784a723d..bff99921 100644 --- a/components/floating-header.tsx +++ b/components/floating-header.tsx @@ -1,6 +1,9 @@ "use client" import { useEffect, useState } from "react" +import { usePathname } from "next/navigation" +import { useIsMobile } from "@/hooks/use-mobile" +import { isFeatureEnabled } from "@/lib/feature-flags" import { AlbedoSunsetBanner } from "@/components/albedo-sunset-banner" import { FreighterConnect } from "@/components/freighter-connect" import { LangToggle } from "@/components/lang-toggle" @@ -39,6 +42,8 @@ function BellButton() { export function FloatingHeader() { const [scrolled, setScrolled] = useState(false) + const pathname = usePathname() + const mobile = useIsMobile() useEffect(() => { const check = () => setScrolled(window.scrollY > TICKER_PX) @@ -47,6 +52,7 @@ export function FloatingHeader() { return () => window.removeEventListener("scroll", check) }, []) + if (mobile && isFeatureEnabled("phase-174") && (pathname === "/forge" || pathname === "/chamber")) return null return (
(functi redraw() }, [redraw]) - const clientToCanvas = (e: React.MouseEvent): [number, number] => { + const clientToCanvas = (e: React.PointerEvent): [number, number] => { const c = canvasRef.current if (!c) return [0, 0] const r = c.getBoundingClientRect() @@ -183,7 +183,9 @@ export const ForgePaintCanvas = forwardRef(functi if (!last || last[0] !== p[0] || last[1] !== p[1]) path.push(p) } - const onDown = (e: React.MouseEvent) => { + const onDown = (e: React.PointerEvent) => { + if (!e.isPrimary || e.button !== 0) return + e.currentTarget.setPointerCapture(e.pointerId) const p = clientToCanvas(e) if (tool === "pencil") { draftRef.current = { tool: "pencil", start: p, current: p, path: [p] } @@ -193,7 +195,7 @@ export const ForgePaintCanvas = forwardRef(functi bump((n) => n + 1) } - const onMove = (e: React.MouseEvent) => { + const onMove = (e: React.PointerEvent) => { if (!draftRef.current) return const p = clientToCanvas(e) draftRef.current.current = p @@ -276,10 +278,11 @@ export const ForgePaintCanvas = forwardRef(functi height={height} className="max-w-full cursor-crosshair touch-none bg-black" style={{ width: "100%", height: "auto", aspectRatio: `${width} / ${height}` }} - onMouseDown={onDown} - onMouseMove={onMove} - onMouseUp={onUp} - onMouseLeave={onLeave} + onPointerDown={onDown} + onPointerMove={onMove} + onPointerUp={onUp} + onPointerCancel={onLeave} + onLostPointerCapture={onLeave} /> {toolRail === "internal" && (

diff --git a/components/fusion-chamber.tsx b/components/fusion-chamber.tsx index 34679ff8..adecf61c 100644 --- a/components/fusion-chamber.tsx +++ b/components/fusion-chamber.tsx @@ -2,6 +2,8 @@ import { albedoImplicitTxAllowed, isAlbedoSelectedInKit } from "@/lib/albedo-intent-client" import { albedoRetiredSignMessage, isAlbedoRetired } from "@/lib/albedo-sunset" +import { useMobileViewport, useMobileDialogFocus } from "@/hooks/use-mobile" +import { MobileControls } from "@/components/mobile-controls" import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react" @@ -111,6 +113,7 @@ function formatPowerBp(bp: number) { export function FusionChamber() { + useMobileViewport() const router = useRouter() const { lang } = useLang() const ch = pickCopy(lang).chamber @@ -158,6 +161,7 @@ export function FusionChamber() { const [claimToWalletBusy, setClaimToWalletBusy] = useState(false) const [logsOpen, setLogsOpen] = useState(false) const [operatorModalOpen, setOperatorModalOpen] = useState(false) + useMobileDialogFocus(operatorModalOpen, '[aria-labelledby="chamber-operator-heading"]') 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). */ @@ -1471,6 +1475,7 @@ export function FusionChamber() { return (

@@ -1568,7 +1573,7 @@ export function FusionChamber() { suppressHydrationWarning > {/* STATUS_MONITOR */} -
) : null} - + {/* THE_REACTOR */}
diff --git a/components/inbound-link-review.tsx b/components/inbound-link-review.tsx new file mode 100644 index 00000000..668df761 --- /dev/null +++ b/components/inbound-link-review.tsx @@ -0,0 +1,71 @@ +"use client" +import { useEffect, useState } from "react" +import { useWallet } from "@/components/wallet-provider" +import { useLang } from "@/components/lang-context" +import { pickCopy } from "@/lib/phase-copy" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { INTENT_KEY, intentPath, parsePhaseLink, restoreIntent, type PhaseIntent } from "@/lib/deep-links" +import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet" + +export function InboundLinkReview() { + const { address, connect, connecting } = useWallet() + const { lang } = useLang() + const copy = pickCopy(lang).integrations + const [intent, setIntent] = useState(null) + const [error, setError] = useState(false) + const [busy, setBusy] = useState(false) + const enabled = isFeatureEnabled("phase-175") + useEffect(() => { + if (!enabled) return + try { + const url = new URL(window.location.href) + // Rewrites preserve the browser's /link/... URL; internal query parameters + // are only visible to Next's server router, not window.location. + const raw = url.pathname.startsWith("/link/") ? url.pathname + url.search : url.searchParams.get("phase_link") + const next = raw ? parsePhaseLink(raw, url.origin) : restoreIntent(sessionStorage.getItem(INTENT_KEY), url.origin) + if (next) sessionStorage.setItem(INTENT_KEY, JSON.stringify(next)) + else sessionStorage.removeItem(INTENT_KEY) + setIntent(next) + } catch { setError(true) } + }, [enabled]) + function dismiss() { + try { sessionStorage.removeItem(INTENT_KEY) } catch { /* private browsing */ } + setIntent(null) + setError(false) + const url = new URL(window.location.href) + url.searchParams.delete("phase_link") + if (url.pathname.startsWith("/link/")) { + url.pathname = intent?.kind === "collection" ? "/chamber" : intent?.kind === "quest" ? "/forge" : "/dashboard" + url.search = intent?.kind === "collection" ? `?collection=${intent.id}` : "" + } + window.history.replaceState(null, "", url.pathname + url.search) + } + async function resume() { + if (!intent || busy) return + if (!address) { await connect(); return } + if (!restoreIntent(JSON.stringify(intent), window.location.origin)) { setError(true); return } + setBusy(true) + try { + const response = await fetch(`/api/links/resolve?link=${encodeURIComponent(intentPath(intent))}`, { cache: "no-store" }) + if (!response.ok) throw new Error("UNAVAILABLE") + const result = await response.json() as { href: string } + if (!/^\/(chamber|dashboard|forge)\?/.test(result.href)) throw new Error("INVALID_TARGET") + dismiss() + window.location.assign(result.href) + } catch { setError(true) } finally { setBusy(false) } + } + if (!enabled) return null + return { if (!open) dismiss() }}> + + {copy.reviewLink} + {copy.reviewLinkHint} + {intent &&
{copy.destination}
{intent.kind === "quest" ? copy.quest : intent.kind === "offer" ? copy.offer : pickCopy(lang).chamber.collectionId} / {intent.id} {intent.offerId}
+ {intent.recipient && <>
{copy.recipient}
{intent.recipient}
} + {intent.ref && <>
{copy.referral}
{intent.ref}
} +
{copy.signingWallet}
{address ?? copy.connect}
} + {error &&

{copy.linkError}

} + + +
+
+} diff --git a/components/intent-target.tsx b/components/intent-target.tsx new file mode 100644 index 00000000..39a6b329 --- /dev/null +++ b/components/intent-target.tsx @@ -0,0 +1,80 @@ +"use client" +import { useEffect, useState } from "react" +import { useWallet } from "@/components/wallet-provider" +import { useLang } from "@/components/lang-context" +import { pickCopy } from "@/lib/phase-copy" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { signSignalPayload } from "@/lib/viewer-signature" + +type Target = { path: string; listing?: string; offer?: string; quest?: string } +export function IntentTarget() { + const { address, connect } = useWallet() + const { lang } = useLang() + const c = pickCopy(lang).integrations + const [target, setTarget] = useState(null) + const [context, setContext] = useState<{ seller?: string; buyer?: string; amount?: number } | null>(null) + const [message, setMessage] = useState("") + const [busy, setBusy] = useState(false) + const [done, setDone] = useState(false) + const [retryIn, setRetryIn] = useState(0) + useEffect(() => { + if (retryIn <= 0) return + const timer = window.setTimeout(() => setRetryIn(n => n - 1), 1000) + return () => window.clearTimeout(timer) + }, [retryIn]) + const enabled = isFeatureEnabled("phase-175") + useEffect(() => { + if (!enabled) return + const params = new URLSearchParams(window.location.search) + const listing = params.get("listing"), offer = params.get("offer"), quest = params.get("quest") + if (listing && offer) setTarget({ listing, offer, path: `/link/offer/${encodeURIComponent(listing)}/${encodeURIComponent(offer)}` }) + else if (quest) setTarget({ quest, path: `/link/quest/${encodeURIComponent(quest)}` }) + }, [enabled]) + useEffect(() => { + if (!target) return + const controller = new AbortController() + void fetch(`/api/links/resolve?link=${encodeURIComponent(target.path)}`, { signal: controller.signal, cache: "no-store" }).then(async response => { + if (!response.ok) throw new Error("UNAVAILABLE") + const body = await response.json() as { context: typeof context } + setContext(body.context) + }).catch(() => { if (!controller.signal.aborted) setMessage(c.linkError) }) + return () => controller.abort() + }, [target, c.linkError]) + async function act(action: "accept" | "reject" | "claim") { + if (!target || busy || done || retryIn > 0 || !context) return + if (!address) { await connect(); return } + setBusy(true); setMessage("") + try { + let response: Response + if (target.quest) response = await fetch("/api/faucet", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ walletAddress: address, reward: target.quest }) }) + else { + const timestamp = Date.now() + const signature = await signSignalPayload({ title: `market:${target.listing}:${target.offer}:${action}`, body: address, timestamp }, address) + response = await fetch(`/api/market/${encodeURIComponent(target.listing!)}/offers/${encodeURIComponent(target.offer!)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ wallet: address, action, timestamp, signature }) }) + } + const result = await response.json() as { code?: string; pending?: boolean } + if (!response.ok) { + const retry = Number(response.headers.get("retry-after")) + if (Number.isFinite(retry) && retry > 0) setRetryIn(Math.min(3600, Math.ceil(retry))) + setMessage(result.code === "KYC_REQUIRED" ? c.kycRequired : result.code === "SANCTIONS_BLOCKED" ? c.sanctionsBlocked : result.code === "SCREENING_UNAVAILABLE" ? c.screeningUnavailable : response.status === 409 ? c.actionConflict : response.status === 412 ? c.requirementsNotMet : response.status === 429 ? c.rateLimited : c.actionFailed) + } else if (result.pending) setMessage(c.pending) + else { setDone(true); setMessage(c.completed) } + } catch { setMessage(c.actionFailed) } finally { setBusy(false) } + } + if (!enabled || !target) return null + return
+

{target.quest ? c.quest : c.offer}

{target.quest ?? `${target.listing} / ${target.offer}`}

+ {context?.amount != null &&

{context.amount} PHASELQ

} + {context?.buyer &&

{c.buyer}: {context.buyer}

} + {context?.seller &&

{c.seller}: {context.seller}

} + {!target.quest &&

{c.offerHint}

} +
+ {!address ? : target.quest ? + : context?.seller === address ? <> + + + : null} +
+ {message &&

{message}

} +
+} diff --git a/components/link-entry-form.tsx b/components/link-entry-form.tsx new file mode 100644 index 00000000..b1376152 --- /dev/null +++ b/components/link-entry-form.tsx @@ -0,0 +1,23 @@ +"use client" +import { useState } from "react" +import { useRouter } from "next/navigation" +import { useLang } from "@/components/lang-context" +import { pickCopy } from "@/lib/phase-copy" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { parsePhaseLink, intentPath } from "@/lib/deep-links" + +export function LinkEntryForm() { + const [raw, setRaw] = useState("") + const [failed, setFailed] = useState(false) + const { lang } = useLang(), router = useRouter() + const c = pickCopy(lang).integrations + if (!isFeatureEnabled("phase-175")) return null + return
+

{c.reviewLink}

{c.reviewLinkHint}

+
{ event.preventDefault(); try { const intent = parsePhaseLink(raw, window.location.origin); window.location.assign(intentPath(intent)) } catch { setFailed(true) } }}> + setRaw(e.target.value)} /> + + +
{failed &&

{c.linkError}

} +
+} diff --git a/components/mobile-controls.tsx b/components/mobile-controls.tsx new file mode 100644 index 00000000..e9e05265 --- /dev/null +++ b/components/mobile-controls.tsx @@ -0,0 +1,25 @@ +"use client" +import { useState, type ReactNode } from "react" +import { useIsMobile } from "@/hooks/use-mobile" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { useLang } from "@/components/lang-context" +import { pickCopy } from "@/lib/phase-copy" +import { Sheet, SheetContent, SheetDescription, SheetTitle, SheetTrigger } from "@/components/ui/sheet" + +export function MobileControls({ children }: { children: ReactNode }) { + const mobile = useIsMobile() + const [open, setOpen] = useState(false) + const { lang } = useLang() + const copy = pickCopy(lang).integrations + if (!isFeatureEnabled("phase-174") || !mobile) return children + return
+ + + + {copy.mobileControls} + {copy.reviewLinkHint} + {children} + + +
+} diff --git a/components/tax-export-panel.tsx b/components/tax-export-panel.tsx new file mode 100644 index 00000000..3f004490 --- /dev/null +++ b/components/tax-export-panel.tsx @@ -0,0 +1,45 @@ +"use client" +import { useState } from "react" +import { useWallet } from "@/components/wallet-provider" +import { useLang } from "@/components/lang-context" +import { pickCopy } from "@/lib/phase-copy" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { taxExportMessage } from "@/lib/tax-export-proof" + +export function TaxExportPanel() { + const { address } = useWallet() + const { lang } = useLang() + const copy = pickCopy(lang).integrations + const [from, setFrom] = useState(`${new Date().getUTCFullYear()}-01-01`) + const [to, setTo] = useState(`${new Date().getUTCFullYear() + 1}-01-01`) + const [policy, setPolicy] = useState<"FIFO" | "HIFO">("FIFO") + const [busy, setBusy] = useState(false) + const [failed, setFailed] = useState(false) + if (!isFeatureEnabled("phase-177") || !address) return null + async function download() { + if (!address || busy || !from || !to || from >= to) return + setBusy(true); setFailed(false) + try { + const claims = { wallet: address, from: new Date(`${from}T00:00:00Z`).toISOString(), to: new Date(`${to}T00:00:00Z`).toISOString(), policy, exp: Math.floor(Date.now() / 1000) + 240, nonce: crypto.randomUUID() } + const { kit } = await import("@/lib/stellar-wallet-kit") + const signed = await kit.signMessage(taxExportMessage(claims, "Test SDF Network ; September 2015"), { address }) as { signedMessage?: string } + if (!signed.signedMessage) throw new Error("NO_SIGNATURE") + const response = await fetch("/api/tax/export", { method: "POST", cache: "no-store", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ claims, signature: signed.signedMessage }) }) + if (!response.ok) throw new Error("EXPORT_FAILED") + const url = URL.createObjectURL(await response.blob()) + const anchor = document.createElement("a") + anchor.href = url; anchor.download = `phase-testnet-${address}-${from}-${policy}.csv` + anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) + } catch { setFailed(true) } finally { setBusy(false) } + } + return
+

{copy.taxTitle}

{copy.taxHint}

+
+ + + + +
+ {failed &&

{copy.taxError}

} +
+} diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx index 51041faf..7da756ee 100644 --- a/components/ui/sheet.tsx +++ b/components/ui/sheet.tsx @@ -48,15 +48,20 @@ function SheetContent({ className, children, side = 'right', + closeLabel = 'Close', + layer = 50, ...props }: React.ComponentProps & { side?: 'top' | 'right' | 'bottom' | 'left' + closeLabel?: string + layer?: number }) { return ( - + {children} - + - Close + {closeLabel} diff --git a/components/ui/use-mobile.tsx b/components/ui/use-mobile.tsx index 4331d5c5..0399c386 100644 --- a/components/ui/use-mobile.tsx +++ b/components/ui/use-mobile.tsx @@ -1,19 +1,2 @@ -import * as React from 'react' - -const MOBILE_BREAKPOINT = 768 - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener('change', onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener('change', onChange) - }, []) - - return !!isMobile -} +"use client" +export { useIsMobile } from "@/hooks/use-mobile" diff --git a/components/wallet-provider.tsx b/components/wallet-provider.tsx index 54aa10de..bf462a49 100644 --- a/components/wallet-provider.tsx +++ b/components/wallet-provider.tsx @@ -39,6 +39,7 @@ import { TESTNET_PASSPHRASE, } from "@/lib/stellar-wallet-kit" import { cn } from "@/lib/utils" +import { InboundLinkReview } from "@/components/inbound-link-review" import { QrCodeDialog } from "@/components/qr-code-dialog" import { WalletConnectBanner } from "@/components/walletconnect-banner" import { LedgerClearSigningModal } from "@/components/ledger-clear-signing-modal" @@ -725,8 +726,8 @@ export function WalletProvider({ children }: { children: ReactNode }) { // ── render ──────────────────────────────────────────────────────────────── return ( - <> - {children} + + {children} @@ -736,7 +737,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { role="alert" aria-live="assertive" /* Spike 627: clear the soft keyboard (visualViewport inset published by lib/freighter-mobile). */ - style={{ paddingBottom: "calc(0.75rem + var(--phase-keyboard-inset, 0px))" }} + style={{ paddingBottom: "calc(max(0.75rem, env(safe-area-inset-bottom)) + var(--phase-keyboard-inset, 0px))" }} className={cn( "fixed bottom-0 left-0 right-0 z-[400] border-t border-red-500/50 bg-background/95 px-4 py-3 text-sm shadow-lg backdrop-blur-sm", )} @@ -768,7 +769,7 @@ export function WalletProvider({ children }: { children: ReactNode }) {
)} - + ) } diff --git a/components/web-vitals-reporter.tsx b/components/web-vitals-reporter.tsx index 7d546659..4bb9b95a 100644 --- a/components/web-vitals-reporter.tsx +++ b/components/web-vitals-reporter.tsx @@ -39,6 +39,7 @@ export function WebVitalsReporter() { const metrics: Partial> = {} const observeMetric = (name: string, value: number, delta: number, id: string) => { + if (!Number.isFinite(value)) return const rating = getRating(name, value) const metric: WebVitalsMetric = { name: name as any, value, rating, delta, id } @@ -66,7 +67,8 @@ export function WebVitalsReporter() { observeMetric("LCP", (entry as any).renderTime || (entry as any).loadTime, 0, entry.startTime as unknown as string) } if (entry.entryType === "first-input") { - observeMetric("FID", (entry as any).processingDuration, (entry as any).processingDuration, entry.startTime as unknown as string) + const delay = (entry as PerformanceEventTiming).processingStart - entry.startTime + observeMetric("FID", delay, delay, String(entry.startTime)) } if (entry.entryType === "layout-shift") { if (!(entry as any).hadRecentInput) { diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 8698fe47..31e724e5 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -697,3 +697,15 @@ Flag: `phase-169` (`NEXT_PUBLIC_FEATURE_PHASE_169` / `FEATURE_PHASE_169`). Defau + + +## Spikes 620/621/630/631 — mobile, inbound links, screening and CSV + +Flags `phase-174` through `phase-177` default off. Set literal public flags at build time for browser features; server-only flags cannot enable browser UI. Rollback requires unsetting both public/server flags and rebuilding/restarting. New SQLite tables are additive and stay inert when their features are off; do not delete reporting evidence during rollback. No contract deployment, token migration, issuer rotation or gateway change is part of these spikes. + +- Mobile layout: one responsive breakpoint at 768px; `useMobileViewport` publishes the actual viewport height and cleans up listeners. Radix sheets provide focus management and Escape handling; custom Forge/Chamber overlays trap phone focus and restore the opener. Pointer capture keeps drawing inside the canvas without stealing page scroll elsewhere. +- Link capture: `phase://quest/ID`, `stellar://phase/quest/ID`, and same-origin `/link/quest/ID` share a parser. Offer paths contain listing and offer IDs; collection IDs are positive safe integers. Arbitrary callbacks, other origins, duplicate parameters and invalid G addresses are rejected. Capture survives wallet connection in sessionStorage without initiating a claim or signing. Target actions revalidate server-side. Browser fallback `/link` accepts pasted custom URIs; SEP-7 `pay`/`tx` requests are deliberately rejected rather than reinterpreted as Soroban intents. +- Compliance: `PHASE_KYC_MODE=off|mock|live`, threshold in exact stroops. Each protected request screens against the configured HTTPS adapter (3s timeout, no redirect/cache). A result older than one hour or already expired fails closed. Webhook signing format is HMAC-SHA256(timestamp + '.' + raw UTF-8 body), with millisecond timestamps and 5-minute skew. Providers must bind subjects to wallets before invoking this operator adapter. `expectedVersion` starts at zero; stale callbacks return 409/current_version. 2FA and identity verification remain separate. +- Reporting: confirmed faucet/bootstrap receipts and unverified marketplace agreements enter `tax_events` with stable event keys. No historical basis/FX reconstruction is invented. Export requires a SEP-53 signature binding wallet, network, UTC range, policy, expiry and nonce. Private no-store responses carry a safe attachment filename and X-Tax-Snapshot. Each CSV record carries a chained recomputation hash. Pages use `(occurred_at, seq)` ordering at a frozen maximum sequence; same-time records cannot fall between pages. Wash/unverified records remain visibly flagged and excluded from proceeds/gains/income. + +API request/response details and outstanding live proof requirements are in [mobile integration API](api/mobile-integration.md) and [spike evidence](spikes/620-621-630-631-integration.md). diff --git a/docs/api/mobile-integration.md b/docs/api/mobile-integration.md new file mode 100644 index 00000000..9ff19d13 --- /dev/null +++ b/docs/api/mobile-integration.md @@ -0,0 +1,40 @@ +# Mobile integration APIs + +All feature flags default off. Error codes are stable machine values; new UI translates through `pickCopy(lang).integrations` (English/Spanish). Responses containing identity/reporting context use `private, no-store`. + +## Incoming links — phase-175 + +`GET /api/links/resolve?link=` returns `{intent, href, context}`. Accepted kinds: `collection/`, `quest/`, `offer//`. Optional `ref` is an eight-character referral code; `recipient` must pass Stellar address checksum validation. Neither parameter authorizes an action. Codes: disabled 404, malformed 400, missing/expired/decided offer or unknown quest 410. Existing collection metadata and its canonical URL remain generated by Chamber. + +`GET /.well-known/assetlinks.json` / `apple-app-site-association` return configured app associations with JSON/CORS headers. Empty configuration produces empty associations; it does not prove OS verification. Native application signing fingerprints and app IDs are deployment inputs. `/link` is the pasted-URI browser fallback. + +When phase-175 or phase-177 is active, deciding an offer requires `{wallet, action, timestamp, signature}`. Sign the existing signal-proof payload `{title: "market:::", body: wallet, timestamp}` using `signSignalPayload`. Five-minute time bounds apply. The decision is a SQLite transaction; an overlapping writer receives 409/current_status. Offer acceptance records an agreement, not NFT transfer or chain settlement. Rejected/expired/self-dealing offers cannot enter the new acceptance path. + +## Compliance — phase-176 + +The faucet (including claim-bounty delegation), classic bootstrap, trustline relay, and x402 demo verification/settlement use `complianceGate` before credit or submission. Reward thresholds use the server-computed amount including daily multipliers, never a client-provided amount. Batch trustlines screen every operation source and reject non-changeTrust operations while enabled. Demo x402 shims are forbidden in live mode; unsigned demo tokens cannot establish verified live payments. + +| Status | Code | Recovery | +| --- | --- | --- | +| 400 | INVALID_AMOUNT / INVALID_WALLET | Correct input; no credit occurred | +| 403 | KYC_REQUIRED | Complete provider verification; retry unchanged claim | +| 451 | SANCTIONS_BLOCKED | No credit or automatic retry | +| 503 | SCREENING_UNAVAILABLE | Honor Retry-After: 60; keep user input | + +`POST PHASE_SANCTIONS_URL` receives `{wallet, operation, network}` with server bearer authorization. Expected strict result: `{sanctions: "clear"|"blocked", checkedAt: , expiresAt: }`. No stale-on-error cache or client overrides. Use a provider-owned adapter for vendor-specific APIs; there is no bundled OFAC feed or vendor contract. + +`POST /api/compliance/webhook` requires `x-phase-timestamp` and `x-phase-signature` over exact raw bytes. Payload: + +```json +{"eventId":"provider_event_001","wallet":"","subject":"provider subject ID","status":"verified","expiresAt":1800000000000,"expectedVersion":0} +``` + +Unknown fields (including identity documents) are rejected. Success: `{ok:true,replay:false,version:1}`. Identical event ID redelivery is idempotent with `replay:true`; stale expectedVersion returns 409/current_version. Invalid signature is 401; invalid event is 400. Persisted credentials contain keyed hashes, status, expiry and version, not raw wallet/subject/PII. Provider integration must prove wallet binding before posting a verified status. Hash-key rotation requires re-verification; webhook-key rotation requires coordination with the adapter. + +## CSV — phase-177, testnet only + +`POST /api/tax/export` body is `{claims, signature}`. Claims: `{wallet, from, to, policy:"FIFO"|"HIFO", exp:, nonce:<16–128 character unique ID>}`. `from/to` are UTC ISO strings, `to` exclusive, maximum ten years. Sign `taxExportMessage(claims, testnetPassphrase)` via wallet `signMessage`; server verifies the SEP-53 envelope. A signature expires within five minutes and is consumed once. Altered wallet/range/policy/network fails 401; replay is 409; invalid range is 400. Flag off is 404; mainnet is 503. Signed exports are limited to 60 per minute per wallet; 429 includes Retry-After. Bodies are bounded at 4 KiB (export/provider responses) and 8 KiB (webhooks), even without Content-Length. + +Success: streamed RFC 4180 CSV, safe attachment filename, X-Tax-Snapshot, no-store. Numeric values are canonical decimal PHASELQ, independent of UI locale; arbitrary cells receive formula neutralization. CSV columns include event ID, UTC time, kind, asset, amount, basis, proceeds, gain, royalty income, receipt hash, flags, policy, recomputation hash. Missing basis/FX remain blank; unverified and wash-flagged rows do not contribute proceeds or royalty income. Confirmed reward quantities do not imply an invented fiat FMV. FIFO/HIFO apply only to recorded indivisible NFT lots; this is not general fungible-asset lot accounting or a completed 1099-DA form. + +The export covers evidence recorded after enabling phase-177. Historical marketplace agreements without receipts, contributor allocations without payouts, treasury refills and AI infrastructure estimates are not personal taxable proceeds. No automatic backfill guesses acquisition basis. CSV retry signs a fresh nonce against the new snapshot. diff --git a/docs/spikes/620-621-630-631-integration.md b/docs/spikes/620-621-630-631-integration.md new file mode 100644 index 00000000..08289a03 --- /dev/null +++ b/docs/spikes/620-621-630-631-integration.md @@ -0,0 +1,175 @@ +# Spikes 620 / 621 / 630 / 631: implementation and acceptance evidence + +Issues: #483, #484, #493, #494. Flags: phase-174, phase-175, phase-176, phase-177 respectively. All default off. This document distinguishes implemented behavior from deployment acceptance. **Live acceptance is not complete; do not treat this branch as a completed production KYC system or tax product.** + +## Implemented closed loops + +- Link → strict parser → session capture → wallet connection → explicit review → live target lookup → offer/quest/collection screen. Offer decisions require a signed, bounded-time seller proof while the new flow is enabled. Navigation never auto-signs, accepts, claims, transfers or calls the faucet. +- Phone viewport → readable targets → scrollable cockpit → controls sheet → focus trap/Escape → opener restored. Prompt state remains in the page while sheets open/close. Pointer capture supports touch drawing without disabling scrolling outside the canvas. +- Claim → server-computed effective amount → fresh sanctions adapter → amount threshold → persisted provider credential → existing payout path. Turnstile, governance denial, sybil and claim replay defenses remain independent. The claim-bounty alias delegates to the same gate and preserves Retry-After. +- Confirmed faucet/bootstrap receipt or marketplace agreement → durable SQLite evidence → signed export authorization → frozen sequence snapshot → bounded CSV pages → downloaded artifact. Agreements are explicitly unverified; missing basis and fiat values are never fabricated. + +## Replay, ordering, errors, and clocks + +| Surface | Authority and retry behavior | +| --- | --- | +| Incoming links | Versioned session record; 15-minute wall-clock expiry; strict reparse on restore; review followed by fresh server lookup. It grants no mutation capability. | +| Offer decisions | Signed seller proof, five-minute clock window, BEGIN IMMEDIATE with current pending/active/expiry checks. Offer + listing + reporting records commit together. A second decision returns 409/current_status. Every other pending offer for a sold listing is rejected. | +| KYC webhooks | Exact-byte timestamped HMAC, unique event ID, expectedVersion CAS in BEGIN IMMEDIATE. Retry of an event ID is idempotent; another stale event returns 409/current_version. Expired credentials cannot satisfy a large claim. | +| Sanctions | Live HTTPS response must be fresh and not expired. No process-local or stale-on-error clearance. 3s timeout; unavailable/malformed/stale screening is 503/Retry-After:60. Sanctions block is 451; missing identity is 403/KYC_REQUIRED. | +| Tax records | Unique source event key; different content under an existing key is a conflict. Immutable sequence plus `(occurred_at,seq)` pagination separates wall-clock ordering from durable insertion order. Verified receipts require a hash. | +| Exports | Wallet/network/range/policy/expiry/nonce bound SEP-53 proof. Signature is verified before consuming a nonce; atomic nonce uniqueness rejects replay (409). 60 exports/minute per authenticated wallet; 429 carries Retry-After. | +| Existing faucet | Existing pending receipt + cooldown/claim rules remain authoritative. Pending state now also preserves exact amount and token contract for reporting after a restart. A reporting failure after confirmed bootstrap does not release the payout claim for duplicate funding. | +| Existing CRDT / JSON paths | No new writes to signal drafts, vector clocks, referral attribution or sidecar queues. Their existing merge/CAS/locking rules remain unchanged. | + +No new endpoint invents ETag/If-Match semantics for resources without versions. Existing signal 409/412, CRDT state-vector and parent-version contracts remain unchanged. New UI keeps inputs on failure and honors numeric Retry-After. Gate #13 normalization/signing code is preserved. + +## Contract drift and storage migration + +No WASM upload, upgrade, redeploy, issuer change or contract-ID change is needed. New listings record the configured contract ID instead of resolving it again at export time; old listings without provenance use an explicit unknown-contract asset key. Pending faucet receipts retain the token contract and exact paid amount. Historical pending records without those fields are not assigned guessed amounts or assets. Existing `get_config`, classic issuer/SAC validation, `token_uri?c=` and gateway health/read fallbacks remain in their original boundaries. + +SQLite adds `compliance_credentials`, `compliance_webhooks`, `tax_events`, `tax_export_nonces`, `tax_export_rate`, indexes, and an additive nullable `listings.contract_id` column. Schema creation/migration is idempotent and serialized with BEGIN IMMEDIATE; the busy timeout is configured before the WAL transition so concurrent first-open processes do not bypass it. No destructive migration/backfill is performed. SQLite busy timeout is bounded at five seconds. As with existing SQLite storage, multi-instance consistency requires a shared durable database/volume; Vercel per-instance tmp storage is not a shared database. This branch does not claim to solve that deployment limitation. + +Rollback: unset both server and public feature variables, rebuild/restart. New routes return 404 where applicable, the existing pages stop mounting new flows, and evidence/credentials stay inert on disk. Do not remove audit history on rollback. Public association deployment changes also need cache expiry (five minutes). + +## Required-file decisions + +Every file named in the four issues is accounted for below. Files intentionally unchanged are not padded with unrelated edits. + +### #484 / spike 621 + +| File | Decision | +| --- | --- | +| middleware.ts | Changed: validated-intent landing rewrites; original browser path is captured; collection parameter retained. | +| next.config.mjs | Changed: association JSON/CORS/cache headers. | +| app/layout.tsx | Changed: viewport-fit cover; metadataBase/canonical rules retained. | +| app/chamber/page.tsx | Unchanged: existing collection search parameter and server OG generation remain the correct canonical route; rewrite preserves collection. | +| components/fusion-chamber.tsx | Changed: mobile composition and compile blockers; existing collection handler receives the rewritten collection. | +| app/forge/page.tsx | Changed: targeted quest view plus mobile controls. | +| app/collection/[id]/page.tsx | Unchanged: token preview/metadata route, not collection intent identity. Conflating the two numeric IDs would navigate to the wrong asset. | +| components/wallet-provider.tsx | Changed: review inside wallet context and persistence before connection; repaired pre-existing context/duplicate-declaration blockers. | +| lib/stellar-wallet-kit.ts | Changed: browser-native nonce generation; all wallet signing remains client-side. | +| lib/phase-protocol.ts | Unchanged: target navigation reuses its configured contract readers; no new chain operation or hardcoded ID. | +| lib/referral-quest.ts | Unchanged: link ref is reviewed, not auto-attributed or rewarded. Existing referral checks must remain authoritative on explicit claims. | +| lib/quest-registry.ts | Unchanged: resolver calls getQuestRegistry and rejects disabled/missing quests; no registry migration needed. | +| lib/market-store.ts | Changed: atomic guarded offer decisions and immutable contract provenance. | +| lib/phase-copy.ts | Changed: complete EN/ES review/action/error copy. | +| lib/viewer-signature.ts | Changed: repaired missing presentation helper; secure signature verification/domain derivation retained and reused for seller actions. | +| app/dashboard/page.tsx | Changed: targeted offer review/action view. | + +### #483 / spike 620 + +| File | Decision | +| --- | --- | +| app/forge/page.tsx | Changed: visual viewport, local phone wallet control, focus management, target panel. | +| components/fusion-chamber.tsx | Changed: phone controls sheet and viewport; removed imports of unused placeholder components/nonexistent hook. | +| app/chamber/page.tsx | Unchanged: Suspense/server metadata boundary already delegates layout to FusionChamber. | +| hooks/use-mobile.ts | Changed: canonical breakpoint, viewport listener cleanup and custom-dialog focus containment. | +| components/ui/use-mobile.tsx | Changed: re-export canonical hook; eliminates divergent breakpoint implementations. | +| components/ui/sheet.tsx | Changed: explicit layer/close-label options and accessible close target. | +| components/ui/drawer.tsx | Unchanged: no mounted Drawer in Forge/Chamber. New controls use Radix Sheet to preserve Escape/focus behavior. | +| app/globals.css | Changed: gated viewport, safe area, typography, scroll containment, touch targets, reduced motion. | +| app/layout.tsx | Changed: safe-area-compatible viewport; zoom remains enabled. | +| components/chamber/settlement-wizard.tsx | Unchanged: file is literal placeholder, unused by the running page. Removed its unused import rather than creating a parallel settlement flow. | +| components/chamber/artifact-viewer.tsx | Unchanged: literal placeholder, unused. Actual viewer is phase-artifact-visualizer; layout is controlled by its parent. | +| components/chamber/terminal-header.tsx | Unchanged: literal placeholder, unused. Actual header is in FusionChamber. | +| components/chamber/energy-gauge-display.tsx | Unchanged: literal placeholder, unused. Actual gauge is in FusionChamber. | +| components/trustline-button.tsx | Unchanged: mounted existing signing/control reused; scoped phone CSS supplies target sizing without changing trustline authorization. | +| components/wallet-provider.tsx | Changed: safe bottom banners and context ownership. | +| lib/phase-copy.ts | Changed: bilingual sheet labels. | +| components/floating-header.tsx | Changed: hide overlapping floating wallet toolbar on phone cockpit pages, which supply their own controls. | +| components/forge-paint-canvas.tsx | Changed: primary pointer capture, touch/pen support, cancellation cleanup. | + +Four missing imported Chamber support modules were restored to make the existing page runnable. A pre-existing WebVitals first-input calculation was corrected because actual touch interactions threw in the observer. These are required page-validation repairs, not new product tracks. + +### #493 / spike 630 + +| File | Decision | +| --- | --- | +| app/api/faucet/route.ts | Changed: gate exact effective reward before credit; preserve receipt provenance. | +| app/api/claim-bounty/route.ts | Changed: preserve authorization and Retry-After through delegation; faucet owns screening. | +| app/api/classic-liq/route.ts | Changed: screen before reserving/bootstrap payment; retain claim after confirmed-chain reporting failures. | +| app/api/classic-liq/trustline/route.ts | Changed: screen every signed operation source, constrain relay to trustline operations while enabled. | +| app/api/x402/settle/route.ts | Changed: gate mock path; forbid demo settlement in live mode. | +| app/api/x402/verify/route.ts | Changed: same gate/fail-closed live shim rule. | +| lib/faucet-deny-list.ts | Unchanged: independent governance veto, never overwritten by provider screening. | +| lib/sybil-resistance.ts | Unchanged: wallet-risk heuristic remains independent; a verified credential does not bypass it. | +| lib/faucet-turnstile.ts | Unchanged: bot proof is not identity proof; existing verifier remains in place. | +| lib/distributor-ledger.ts | Unchanged: treasury reservation accounting is downstream of the credit gate. | +| lib/profile-2fa.ts | Unchanged: 2FA is not KYC; no identity credentials flow through in-band codes. | +| lib/viewer-authorization.ts | Unchanged: NFT-view permission is not verified identity. New webhook has its own signed provider boundary. | +| lib/api-observability.ts | Changed: redact identity metadata when compliance enabled. | +| lib/security-counters.ts | Changed: closed result/event names only; no wallet/subject/text labels. | +| lib/env-validation.ts | Changed: KYC mode/network/secret/HTTPS checks. | +| lib/feature-flags.ts | Changed: default-off registration and literal public reads for Next bundling. | +| middleware.ts | Changed for link routing only. Explicitly not used for amount screening: Edge middleware cannot safely derive effective server reward amounts or inspect every signed batch source. | + +New lib/compliance.ts and signed webhook API own the provider adapter and CAS. Existing secret-redaction keys now include identity-document fields. Live clearance requires a configured provider-owned sanctions adapter; no undocumented vendor API is assumed. + +### #494 / spike 631 + +| File | Decision | +| --- | --- | +| lib/sqlite-db.ts | Changed: additive tables/indexes and contract provenance column. | +| lib/market-store.ts | Changed: decision + unverified evidence in one transaction. | +| lib/settlement-store.ts | Unchanged: hash-only replay markers lack amounts/ownership; they cannot establish acquisition basis. | +| lib/contributor-ledger.ts | Unchanged: allocation credits are not realized payouts or creator income. | +| lib/cost-attribution-ledger.ts | Unchanged: infrastructure estimates are not wallet acquisition costs; not silently converted to tax basis. | +| lib/distributor-ledger.ts | Unchanged: treasury liquidity is not a user's personal proceeds ledger. | +| app/api/market/route.ts | Unchanged: listing creation delegates to createListing, which now stamps contract provenance; listing itself is not a taxable sale. | +| app/api/market/[id]/offers/route.ts | Unchanged: pending offer creation is not proceeds; getOffers supports live intent lookup. | +| app/api/market/[id]/offers/[offer_id]/route.ts | Changed: signed authorization, atomic decision, deterministic conflict response; removed pre-existing undefined splits reference. | +| app/api/market/offers-by-buyer/route.ts | Unchanged: presentation read, not settlement evidence. | +| app/api/market/collections/[collection_id]/offer-book/route.ts | Unchanged: aggregate read; wash exclusion remains in original store queries. | +| app/api/classic-liq/route.ts | Changed: confirmed bootstrap quantity evidence, no invented fiat FMV. | +| app/api/faucet/route.ts | Changed: confirmed receipt recording and recoverable pending amount/contract. | +| lib/server-data-paths.ts | Unchanged: uses existing sqliteDb path; no second reporting sidecar. | +| lib/json-store.ts | Unchanged: no competing JSON source of tax truth. | +| lib/phase-copy.ts | Changed: bilingual export UI and explicit limitations. | + +## Evidence and remaining acceptance + +Automated commands/results are recorded below after the final verification run. Browser checks exercise 360/390/768/1024 widths, sheet Escape/focus restoration, stripped-query session recovery, reduced motion, prompt retention and a shortened viewport. Desktop Chrome mobile emulation is not a substitute for real iOS Safari/Android wallet-return tests. + +**Still required before closing all issue acceptance checkboxes:** + +1. Public testnet receipt chain covering Friendbot → trustline → fund → act → verify → index → UI, with transaction hashes and the actual deployment IDs. This checkout contains only `.env.local.example`, not configured signer/provider credentials. No receipt hashes are invented in this document. +2. A configured native iOS/Android host application and verified signing IDs/fingerprints; an association JSON endpoint by itself cannot establish OS universal-link verification or register a custom scheme. +3. Provider sandbox wallet-binding ceremony, real sanctions adapter integration/freshness/revocation exercises, credential/hash-key rotation, retention policy, and durable shared deployment storage. Mock CI success is not vendor/OFAC integration evidence. +4. External historical receipt/FX/basis reconciliation and general fungible lot accounting/1099-DA mapping if those are required for closing #494. Current export is deliberately labeled testnet evidence, not a completed filing product. +5. Full upstream baseline repair or maintainer acceptance of existing failures. Untouched upstream main at 11cb3bc9 reproduces 18 failed files / 54 failed tests / 153 TypeScript errors. The baseline also could not render the requested pages before the targeted compile/context repairs in this branch. +6. Live primary-RPC/Horizon/gateway failure injection and real device wallet returns. Existing failover/bulkhead tests plus new provider-failure tests are local evidence, not a claim that a live dependency was killed. + +These external requirements keep the PR in draft. The implementation does not alter contracts or signal/CRDT storage merely to manufacture irrelevant migration or concurrency claims. + + +## Recorded local validation — 2026-10-20 + +| Check | Result | +| --- | --- | +| Four new Vitest suites | **41 passed**; includes separate Node-process KYC writers, rollback/metric consistency, malformed inputs, exact-money lot ordering, streaming pagination, signatures and route gating. | +| New + existing concurrency/signature/gateway/bulkhead suites | Initial 11-file focused run: **120 passed**; subsequent new tests also passed. | +| Full suite, `npm test -- --maxWorkers=2` | **555 passed, 54 failed (609 tests)**. Same 54 failures / 18 failing files as pristine upstream; no added failing tests. | +| Pristine upstream full suite | **514 passed, 54 failed (568 tests)** at 11cb3bc9. | +| `npm run typecheck` | **149 pre-existing diagnostics**, down from 153; normalized comparison against upstream finds no added diagnostics. | +| `npm run lint` | Cannot execute: repository does not install ESLint (`eslint: not found`). No lint success claimed. | +| `npm run build` | Blocked by unchanged `app/api/notifications/winback/route.ts` importing nonexistent `addNotification`; no build success claimed. | +| Browser widths | Forge and Chamber at **360, 390, 768, 1024**: no horizontal overflow or page errors. | +| Browser interactions | Sheet focus/escape/opener restoration, stripped-query intent recovery, preserved prompt and 500px visual viewport: passed; reduced-motion setting enabled. | +| All flags off | Both existing pages return 200 without the new layout. Link entry, resolver, CSV, webhook and association APIs return 404. No browser page errors. | +| Fresh-database cross-process migration + storage regression checks | **64 passed** across five suites after serializing schema initialization. | +| Whitespace | `git diff --check` passes. | + +[Browser machine results](evidence/mobile-integration/browser-results.json). Representative screenshots: [Forge phone](evidence/mobile-integration/forge-360.png), [Chamber phone](evidence/mobile-integration/chamber-360.png), [Forge desktop](evidence/mobile-integration/forge-1024.png), [Chamber desktop](evidence/mobile-integration/chamber-1024.png). + +Reproduce browser checks against a local flag-enabled dev server with: + +```bash +# Install Playwright in an isolated temporary directory; no dependency/lockfile changes required. +npm install --prefix /tmp/phase-browser playwright --no-audit --no-fund +NEXT_PUBLIC_FEATURE_PHASE_174=1 NEXT_PUBLIC_FEATURE_PHASE_175=1 NEXT_PUBLIC_FEATURE_PHASE_177=1 npm run dev -- --hostname 127.0.0.1 --port 3100 +# In another terminal, with Chrome available: +PHASE_PLAYWRIGHT_MODULE=/tmp/phase-browser/node_modules/playwright/index.mjs node scripts/check-mobile-integration.mjs +``` + +Snapshots and results default to `/tmp/phase-mobile-validation`. This script does not connect or sign with any wallet. `PHASE_CHROME_PATH`, `PHASE_SMOKE_BASE_URL`, and `PHASE_SMOKE_OUTPUT` override the executable, local URL and output directory. diff --git a/docs/spikes/evidence/mobile-integration/browser-results.json b/docs/spikes/evidence/mobile-integration/browser-results.json new file mode 100644 index 00000000..874f3090 --- /dev/null +++ b/docs/spikes/evidence/mobile-integration/browser-results.json @@ -0,0 +1,60 @@ +[ + { + "route": "/forge", + "width": 360, + "horizontalOverflow": false, + "pageErrors": 0 + }, + { + "route": "/chamber", + "width": 360, + "horizontalOverflow": false, + "pageErrors": 0 + }, + { + "route": "/forge", + "width": 390, + "horizontalOverflow": false, + "pageErrors": 0 + }, + { + "route": "/chamber", + "width": 390, + "horizontalOverflow": false, + "pageErrors": 0 + }, + { + "route": "/forge", + "width": 768, + "horizontalOverflow": false, + "pageErrors": 0 + }, + { + "route": "/chamber", + "width": 768, + "horizontalOverflow": false, + "pageErrors": 0 + }, + { + "route": "/forge", + "width": 1024, + "horizontalOverflow": false, + "pageErrors": 0 + }, + { + "route": "/chamber", + "width": 1024, + "horizontalOverflow": false, + "pageErrors": 0 + }, + { + "sheetFocus": true, + "escape": true, + "restoredOpener": true, + "persistedIntent": true, + "strippedQueryRecovery": true, + "promptPreserved": true, + "resizedViewport": true, + "pageErrors": 0 + } +] \ No newline at end of file diff --git a/docs/spikes/evidence/mobile-integration/chamber-1024.png b/docs/spikes/evidence/mobile-integration/chamber-1024.png new file mode 100644 index 00000000..52c8070b Binary files /dev/null and b/docs/spikes/evidence/mobile-integration/chamber-1024.png differ diff --git a/docs/spikes/evidence/mobile-integration/chamber-360.png b/docs/spikes/evidence/mobile-integration/chamber-360.png new file mode 100644 index 00000000..c03c0144 Binary files /dev/null and b/docs/spikes/evidence/mobile-integration/chamber-360.png differ diff --git a/docs/spikes/evidence/mobile-integration/forge-1024.png b/docs/spikes/evidence/mobile-integration/forge-1024.png new file mode 100644 index 00000000..23ee415d Binary files /dev/null and b/docs/spikes/evidence/mobile-integration/forge-1024.png differ diff --git a/docs/spikes/evidence/mobile-integration/forge-360.png b/docs/spikes/evidence/mobile-integration/forge-360.png new file mode 100644 index 00000000..6a6584a2 Binary files /dev/null and b/docs/spikes/evidence/mobile-integration/forge-360.png differ diff --git a/hooks/use-mobile.ts b/hooks/use-mobile.ts index 4331d5c5..fbceb8b6 100644 --- a/hooks/use-mobile.ts +++ b/hooks/use-mobile.ts @@ -1,4 +1,6 @@ +'use client' import * as React from 'react' +import { isFeatureEnabled } from '@/lib/feature-flags' const MOBILE_BREAKPOINT = 768 @@ -17,3 +19,46 @@ export function useIsMobile() { return !!isMobile } + +export function useMobileViewport() { + React.useEffect(() => { + if (!isFeatureEnabled('phase-174')) return + const viewport = window.visualViewport + let frame = 0 + const update = () => { + cancelAnimationFrame(frame) + frame = requestAnimationFrame(() => { + document.documentElement.style.setProperty('--phase-viewport-height', `${viewport?.height ?? window.innerHeight}px`) + }) + } + update() + viewport?.addEventListener('resize', update) + window.addEventListener('resize', update) + return () => { + cancelAnimationFrame(frame) + viewport?.removeEventListener('resize', update) + window.removeEventListener('resize', update) + document.documentElement.style.removeProperty('--phase-viewport-height') + } + }, []) +} + +export function useMobileDialogFocus(active: boolean, selector: string) { + React.useEffect(() => { + if (!active || !isFeatureEnabled('phase-174') || !window.matchMedia('(max-width: 767px)').matches) return + const dialog = document.querySelector(selector) + if (!dialog) return + const previous = document.activeElement as HTMLElement | null + const focusables = () => Array.from(dialog.querySelectorAll('button:not([disabled]), a[href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex="0"]')).filter(el => el.getClientRects().length > 0) + focusables()[0]?.focus() + const trap = (event: KeyboardEvent) => { + if (event.key !== 'Tab') return + const items = focusables(), first = items[0], last = items[items.length - 1] + if (!first) { event.preventDefault(); return } + if (event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))) { event.preventDefault(); last?.focus() } + else if (!event.shiftKey && (document.activeElement === last || !dialog.contains(document.activeElement))) { event.preventDefault(); first.focus() } + } + document.addEventListener('keydown', trap, true) + return () => { document.removeEventListener('keydown', trap, true); previous?.focus() } + }, [active, selector]) +} diff --git a/lib/__tests__/compliance.test.ts b/lib/__tests__/compliance.test.ts new file mode 100644 index 00000000..2bee07f3 --- /dev/null +++ b/lib/__tests__/compliance.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createHmac } from "node:crypto" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { resolve } from "node:path" +import { pathToFileURL } from "node:url" +import { Keypair } from "@stellar/stellar-sdk" +import { resetDbForTests, getDb } from "@/lib/sqlite-db" +import { applyVerificationEvent, screenCompliance, verifyComplianceWebhook, complianceGate } from "@/lib/compliance" +import { resetSecurityCounters, getSecurityCounter } from "@/lib/security-counters" + +let dir: string +const wallet = Keypair.random().publicKey() +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "phase-compliance-")) + vi.stubEnv("PHASE_SERVER_DATA_DIR", dir) + vi.stubEnv("FEATURE_PHASE_176", "1") + vi.stubEnv("PHASE_KYC_MODE", "mock") + vi.stubEnv("STELLAR_NETWORK", "testnet") + vi.stubEnv("PHASE_KYC_HASH_KEY", "h".repeat(32)) + vi.stubEnv("PHASE_KYC_WEBHOOK_SECRET", "s".repeat(32)) + vi.stubEnv("PHASE_KYC_THRESHOLD_STROOPS", "100") + resetDbForTests(); resetSecurityCounters() +}) +afterEach(() => { resetDbForTests(); rmSync(dir, { recursive: true, force: true }); vi.unstubAllEnvs(); vi.unstubAllGlobals() }) +describe("amount-aware compliance gate", () => { + it("does nothing with its flag off, including malformed legacy inputs", async () => { + vi.stubEnv("FEATURE_PHASE_176", "0") + await expect(screenCompliance("", "", "faucet")).resolves.toBeUndefined() + }) + it("allows small drips but requires verified credentials at the exact threshold on every credit path", async () => { + await expect(screenCompliance(wallet, "99", "faucet")).resolves.toBeUndefined() + for (const operation of ["faucet", "bounty", "bootstrap", "x402"] as const) await expect(screenCompliance(wallet, "100", operation)).rejects.toMatchObject({ code: "KYC_REQUIRED", status: 403 }) + applyVerificationEvent({ eventId: "event_123456", wallet, subject: "provider-subject", status: "verified", expiresAt: Date.now() + 60_000, expectedVersion: 0 }) + await expect(screenCompliance(wallet, "100", "faucet")).resolves.toBeUndefined() + const rows = getDb().prepare("SELECT * FROM compliance_credentials").all() + expect(JSON.stringify(rows)).not.toContain(wallet) + expect(JSON.stringify(rows)).not.toContain("provider-subject") + }) + it("serializes overlapping updates, exposes versions, and deduplicates redelivery", async () => { + const event = { eventId: "event_123456", wallet, subject: "subject", status: "verified", expiresAt: Date.now() + 60_000, expectedVersion: 0 } + expect(applyVerificationEvent(event)).toEqual({ replay: false, version: 1 }) + expect(applyVerificationEvent(event)).toEqual({ replay: true, version: 1 }) + expect(() => applyVerificationEvent({ ...event, eventId: "event_654321" })).toThrow("VERSION_CONFLICT") + applyVerificationEvent({ ...event, eventId: "event_654321", expectedVersion: 1, status: "revoked" }) + await expect(screenCompliance(wallet, "100", "faucet")).rejects.toMatchObject({ code: "KYC_REQUIRED" }) + expect(getSecurityCounter("compliance_webhook_replay")).toBe(1) + }) + it("rejects an overlapping writer in a separate Node process with the committed version", async () => { + const startAt = Date.now() + 1200 + const event = { wallet, subject: "subject", status: "verified", expiresAt: Date.now() + 60_000, expectedVersion: 0 } + const moduleUrl = pathToFileURL(resolve("lib/compliance.ts")).href + const run = promisify(execFile) + const outcomes = await Promise.all(["event_process_1", "event_process_2"].map(eventId => run(process.execPath, ["--import", "tsx", "--input-type=module", "-e", ` + import complianceModule from ${JSON.stringify(moduleUrl)}; + const { applyVerificationEvent } = complianceModule; + await new Promise(r => setTimeout(r, Math.max(0, ${startAt} - Date.now()))); + try { const result = applyVerificationEvent(${JSON.stringify({ ...event, eventId })}); console.log(JSON.stringify({ok:true,...result})); } + catch (error) { console.log(JSON.stringify({ok:false,current_version:error.current_version,...(error.current_version === undefined ? {reason:error.message} : {})})); } + `], { env: { ...process.env, PHASE_SERVER_DATA_DIR: dir, FEATURE_PHASE_176: "1", PHASE_KYC_MODE: "mock", PHASE_KYC_HASH_KEY: "h".repeat(32) } }))) + const results = outcomes.map(result => JSON.parse(result.stdout.trim())) + expect(results.filter(result => result.ok)).toHaveLength(1) + expect(results.find(result => !result.ok)).toEqual({ ok: false, current_version: 1 }) + }, 10_000) + it("blocks sanctioned wallets even below the threshold and on trustlines", async () => { + vi.stubEnv("PHASE_KYC_MOCK_BLOCKED_WALLETS", wallet) + const response = await complianceGate(wallet, "0", "trustline") + expect(response?.status).toBe(451) + expect(getSecurityCounter("compliance_denied")).toBe(1) + }) + it("fails closed on live provider outage, stale responses and redirects without leaking request details", async () => { + vi.stubEnv("PHASE_KYC_MODE", "live"); vi.stubEnv("PHASE_SANCTIONS_URL", "https://screen.example/check"); vi.stubEnv("PHASE_SANCTIONS_API_KEY", "test-key") + const fetchMock = vi.fn().mockRejectedValue(new Error("secret PII provider detail")) + vi.stubGlobal("fetch", fetchMock) + const response = await complianceGate(wallet, "1", "faucet") + expect(response?.status).toBe(503); expect(response?.headers.get("retry-after")).toBe("60") + expect(await response?.text()).not.toContain("PII") + fetchMock.mockResolvedValue(new Response(JSON.stringify({ sanctions: "clear", checkedAt: Date.now() - 7200_000, expiresAt: Date.now() + 1000 }))) + await expect(screenCompliance(wallet, "1", "faucet")).rejects.toMatchObject({ code: "SCREENING_UNAVAILABLE" }) + expect(fetchMock.mock.calls[0][1]).toMatchObject({ cache: "no-store", redirect: "error" }) + }) + it("validates webhook timestamp, exact bytes and signature; extra PII fields are rejected", () => { + const body = '{"eventId":"event_123456"}', timestamp = String(Date.now()) + const signature = createHmac("sha256", "s".repeat(32)).update(`${timestamp}.${body}`).digest("hex") + expect(verifyComplianceWebhook(body, timestamp, signature)).toBe(true) + expect(verifyComplianceWebhook(body + " ", timestamp, signature)).toBe(false) + expect(verifyComplianceWebhook(body, String(Date.now() - 600_000), signature)).toBe(false) + expect(() => applyVerificationEvent({ eventId: "event_123456", wallet, subject: "subject", status: "verified", expectedVersion: 0, expiresAt: Date.now() + 60_000, dob: "private" })).toThrow() + }) + it("never permits mock screening on mainnet", async () => { + vi.stubEnv("STELLAR_NETWORK", "mainnet"); vi.stubEnv("PHASE_MAINNET_CUTOVER_ACK", "1") + await expect(screenCompliance(wallet, "1", "faucet")).rejects.toMatchObject({ code: "SCREENING_UNAVAILABLE" }) + }) +}) diff --git a/lib/__tests__/deep-links.test.ts b/lib/__tests__/deep-links.test.ts new file mode 100644 index 00000000..cd73fc67 --- /dev/null +++ b/lib/__tests__/deep-links.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest" +import { Keypair } from "@stellar/stellar-sdk" +import { parsePhaseLink, restoreIntent, INTENT_TTL_MS, intentPath } from "@/lib/deep-links" + +const origin = "https://phase.example" +describe("inbound intent validation", () => { + it("normalizes custom, stellar and universal collection links", () => { + for (const url of ["phase://collection/42", "stellar://phase/collection/42", `${origin}/link/collection/42`]) { + expect(parsePhaseLink(url, origin, 100)).toMatchObject({ kind: "collection", id: "42", capturedAt: 100 }) + } + }) + it("retains reviewed context across disconnect, reload, and a stripped query", () => { + const recipient = Keypair.random().publicKey() + const intent = parsePhaseLink(`phase://offer/listing-1/offer-2?ref=ABCDEFGH&recipient=${recipient}`, origin, 100) + expect(restoreIntent(JSON.stringify(intent), origin, 200)).toEqual(intent) + expect(parsePhaseLink(intentPath(intent), origin, 100)).toEqual(intent) + }) + it("expires persisted intents and rejects future timestamps", () => { + const intent = parsePhaseLink("phase://quest/quest_first_settle", origin, 100) + expect(restoreIntent(JSON.stringify(intent), origin, 100 + INTENT_TTL_MS)).toBeNull() + expect(restoreIntent(JSON.stringify(intent), origin, 99)).toBeNull() + expect(restoreIntent('{"version":1,"kind":"quest","id":"../escape"}', origin)).toBeNull() + }) + it.each([ + "https://evil.example/link/collection/1", "javascript:alert(1)", "//evil.example/link/quest/foo", + "stellar://pay?destination=GAAA", "phase://collection/0", "phase://collection/1junk", "phase://collection/9007199254740993", + "phase://offer/one", "phase://quest/foo/extra", "phase://quest/foo?recipient=GAAA", "phase://quest/foo?ref=bad", + "phase://quest/foo?ref=ABCDEFGH&ref=ABCDEFGH", "phase://quest/foo?redirect=https://evil.example", "phase://quest/foo#sign", + ])("rejects tampered or unsupported links: %s", raw => expect(() => parsePhaseLink(raw, origin)).toThrow()) +}) diff --git a/lib/__tests__/integration-routes.test.ts b/lib/__tests__/integration-routes.test.ts new file mode 100644 index 00000000..dcf857ed --- /dev/null +++ b/lib/__tests__/integration-routes.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { NextRequest } from "next/server" +import { Keypair } from "@stellar/stellar-sdk" +import { createHmac } from "node:crypto" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { resetDbForTests } from "@/lib/sqlite-db" +import { POST as exportRoute } from "@/app/api/tax/export/route" +import { POST as webhookRoute } from "@/app/api/compliance/webhook/route" +import { GET as resolveRoute } from "@/app/api/links/resolve/route" +import { taxExportMessage } from "@/lib/tax-export-proof" +import { recordTaxEvent } from "@/lib/tax-report" + +let dir: string +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "phase-routes-")); vi.stubEnv("PHASE_SERVER_DATA_DIR", dir); vi.stubEnv("STELLAR_NETWORK", "testnet"); resetDbForTests() + for (const id of [175,176,177]) { vi.stubEnv(`FEATURE_PHASE_${id}`, "0"); vi.stubEnv(`NEXT_PUBLIC_FEATURE_PHASE_${id}`, "0") } +}) +afterEach(() => { resetDbForTests(); rmSync(dir, { recursive: true, force: true }); vi.unstubAllEnvs() }) +const post = (url: string, body: unknown, headers: Record = {}) => new NextRequest(`https://phase.example${url}`, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(body) }) + +describe("new route boundaries", () => { + it("bounds bodies and returns deterministic errors for null or oversized export input", async () => { + vi.stubEnv("FEATURE_PHASE_177", "1"); vi.stubEnv("FEATURE_PHASE_176", "1"); vi.stubEnv("PHASE_KYC_MODE", "mock") + expect((await exportRoute(post("/api/tax/export", null))).status).toBe(400) + expect((await exportRoute(post("/api/tax/export", { data: "x".repeat(8192) }))).status).toBe(400) + expect((await webhookRoute(post("/api/compliance/webhook", { data: "x".repeat(8192) }))).status).toBe(413) + }) + it("returns 404 for every new behavior when flags are off", async () => { + expect((await exportRoute(post("/api/tax/export", {}))).status).toBe(404) + expect((await webhookRoute(post("/api/compliance/webhook", {}))).status).toBe(404) + expect((await resolveRoute(new NextRequest("https://phase.example/api/links/resolve?link=phase://collection/1"))).status).toBe(404) + }) + it("authenticates the export, streams an attachment privately, and rejects replay", async () => { + vi.stubEnv("FEATURE_PHASE_177", "1") + const key = Keypair.random(), wallet = key.publicKey(), network = "Test SDF Network ; September 2015" + const claims = { wallet, from: "2026-01-01T00:00:00.000Z", to: "2027-01-01T00:00:00.000Z", policy: "FIFO" as const, exp: Math.floor(Date.now()/1000)+240, nonce: "nonce_abcdefghijklmnop" } + recordTaxEvent({ eventKey: "receipt", wallet, kind: "reward", asset: "PHASELQ", amountStroops: "10000000", txHash: "a".repeat(64), occurredAt: Date.parse(claims.from)+100, verified: true }) + const signature = key.sign(Buffer.from(`Stellar Signed Message:\n${taxExportMessage(claims, network)}`)).toString("base64") + const response = await exportRoute(post("/api/tax/export", { claims, signature })) + expect(response.status).toBe(200) + expect(response.headers.get("content-disposition")).toContain(wallet) + expect(response.headers.get("cache-control")).toBe("private, no-store") + expect(await response.text()).toContain('"receipt"') + expect((await exportRoute(post("/api/tax/export", { claims, signature }))).status).toBe(409) + expect((await exportRoute(post("/api/tax/export", { claims: { ...claims, wallet: Keypair.random().publicKey() }, signature }))).status).toBe(401) + }) + it("returns deterministic webhook version conflicts and idempotent retry results", async () => { + vi.stubEnv("FEATURE_PHASE_176", "1"); vi.stubEnv("PHASE_KYC_MODE", "mock"); vi.stubEnv("PHASE_KYC_HASH_KEY", "h".repeat(32)); vi.stubEnv("PHASE_KYC_WEBHOOK_SECRET", "s".repeat(32)) + const event = { eventId: "event_abcdefgh", wallet: Keypair.random().publicKey(), subject: "provider-subject", status: "verified", expectedVersion: 0, expiresAt: Date.now()+60_000 } + async function send(value: unknown) { + const timestamp = String(Date.now()), signature = createHmac("sha256", "s".repeat(32)).update(`${timestamp}.${JSON.stringify(value)}`).digest("hex") + return webhookRoute(post("/api/compliance/webhook", value, { "x-phase-timestamp": timestamp, "x-phase-signature": signature })) + } + expect((await send(event)).status).toBe(200) + expect(await (await send(event)).json()).toMatchObject({ replay: true, version: 1 }) + const conflict = await send({ ...event, eventId: "event_different" }) + expect(conflict.status).toBe(409); expect(await conflict.json()).toMatchObject({ current_version: 1 }) + }) + it("rejects unsafe links and unknown quests without taking payment actions", async () => { + vi.stubEnv("FEATURE_PHASE_175", "1") + for (const link of ["phase://collection/3?redirect=evil", "https://evil.example/link/quest/quest_1"]) { + expect((await resolveRoute(new NextRequest(`https://phase.example/api/links/resolve?link=${encodeURIComponent(link)}`))).status).toBe(400) + } + expect((await resolveRoute(new NextRequest("https://phase.example/api/links/resolve?link=phase://quest/missing"))).status).toBe(410) + const response = await resolveRoute(new NextRequest("https://phase.example/api/links/resolve?link=phase://quest/quest_connect_wallet")) + expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ href: "/forge?quest=quest_connect_wallet" }) + }) +}) diff --git a/lib/__tests__/tax-report.test.ts b/lib/__tests__/tax-report.test.ts new file mode 100644 index 00000000..c7855337 --- /dev/null +++ b/lib/__tests__/tax-report.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { Keypair } from "@stellar/stellar-sdk" +import { getDb, resetDbForTests } from "@/lib/sqlite-db" +import { recordTaxEvent, taxCsv, csvCell, verifyTaxExport, taxExportRateLimited } from "@/lib/tax-report" +import { taxExportMessage, type TaxExportRequest } from "@/lib/tax-export-proof" +import { decimalToStroops, formatStroops } from "@/lib/decimal-amount" +import { createListing, createOffer, decideOfferAtomic, getListing } from "@/lib/market-store" +import { resetSecurityCounters, getSecurityCounter } from "@/lib/security-counters" + +const key = Keypair.random(), buyer = Keypair.random().publicKey(), wallet = key.publicKey() +const txHash = "a".repeat(64) +const start = Date.parse("2026-01-01T00:00:00Z") +const input: TaxExportRequest = { wallet, from: "2026-01-01T00:00:00.000Z", to: "2027-01-01T00:00:00.000Z", policy: "FIFO", exp: Math.floor(Date.now()/1000)+240, nonce: "abcdefghijklmnop" } +let dir: string +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "phase-tax-")); vi.stubEnv("PHASE_SERVER_DATA_DIR", dir); vi.stubEnv("FEATURE_PHASE_177", "1"); resetDbForTests() +}) +afterEach(() => { resetDbForTests(); rmSync(dir, { recursive: true, force: true }); vi.unstubAllEnvs() }) +const event = { eventKey: "test:1", wallet, kind: "reward" as const, asset: "PHASELQ", amountStroops: "123456789", occurredAt: start+100, verified: true, txHash } +const snapshot = () => (getDb().prepare("SELECT COALESCE(MAX(seq),0) AS seq FROM tax_events").get() as { seq: number }).seq + +describe("durable transaction exports", () => { + it("limits signed exports per wallet and resets at the minute boundary", () => { + for (let i = 0; i < 60; i++) expect(taxExportRateLimited(wallet, 120_000)).toBe(false) + expect(taxExportRateLimited(wallet, 120_000)).toBe(true) + expect(taxExportRateLimited(buyer, 120_000)).toBe(false) + expect(taxExportRateLimited(wallet, 180_000)).toBe(false) + }) + it("stores once across restart and rejects conflicting idempotency keys", () => { + recordTaxEvent(event); resetDbForTests(); recordTaxEvent(event) + expect(snapshot()).toBe(1) + expect(() => recordTaxEvent({ ...event, amountStroops: "1" })).toThrow("TAX_EVENT_CONFLICT") + }) + it("performs no writes when disabled", () => { + vi.stubEnv("FEATURE_PHASE_177", "0"); recordTaxEvent(event); expect(snapshot()).toBe(0) + }) + it("never substitutes zero for unknown basis and excludes unverified/wash activity from proceeds", () => { + recordTaxEvent({ ...event, kind: "disposal" }) + recordTaxEvent({ ...event, eventKey: "unverified", kind: "disposal", verified: false }) + recordTaxEvent({ ...event, eventKey: "wash", kind: "royalty", washFlagged: true }) + const csv = [...taxCsv(input, snapshot())].join("") + expect(csv).toContain("missing_basis") + expect(csv).toContain('"12.3456789","","12.3456789",""') + expect(csv).toContain("wash_excluded") + expect(csv).toContain("unverified") + }) + it("reconstructs pre-range acquisition basis and separates royalty income", () => { + recordTaxEvent({ ...event, eventKey: "acq", asset: "contract:1", kind: "acquisition", occurredAt: start-100, basisStroops: "10000000" }) + recordTaxEvent({ ...event, eventKey: "sale", asset: "contract:1", kind: "disposal", amountStroops: "25000000" }) + recordTaxEvent({ ...event, eventKey: "royalty", kind: "royalty", amountStroops: "5000000" }) + const csv = [...taxCsv(input, snapshot())].join("") + expect(csv).toContain('"2.5000000","1.0000000","2.5000000","1.5000000",""') + expect(csv).toContain('"0.5000000","","","","0.5000000"') + }) + it("orders equal-time evidence by sequence and selects HIFO with exact big-integer costs", () => { + recordTaxEvent({ ...event, eventKey: "low-lot", asset: "contract:2", kind: "acquisition", basisStroops: "90071992547409930" }) + recordTaxEvent({ ...event, eventKey: "high-lot", asset: "contract:2", kind: "acquisition", basisStroops: "90071992547409931" }) + recordTaxEvent({ ...event, eventKey: "disposal", asset: "contract:2", kind: "disposal", amountStroops: "90071992547409932" }) + expect([...taxCsv(input, snapshot())].join("")).toContain('"0.0000002"') + expect([...taxCsv({ ...input, policy: "HIFO" }, snapshot())].join("")).toContain('"0.0000001"') + }) + it("streams beyond a page with deterministic hashes, stable ordering, snapshot cutoff and wallet isolation", () => { + for (let i=0; i<121; i++) recordTaxEvent({ ...event, eventKey: `page:${i}`, occurredAt: start+100+(i%3) }) + const cutoff = snapshot() + const csv = [...taxCsv(input, cutoff)] + recordTaxEvent({ ...event, eventKey: "later" }) + recordTaxEvent({ ...event, eventKey: "otherwallet", wallet: buyer }) + expect(csv).toHaveLength(122) + expect([...taxCsv(input, cutoff)]).toEqual(csv) + expect(csv.join("")).not.toContain("later") + expect(csv.join("")).not.toContain("otherwallet") + expect([...taxCsv(input, cutoff)]).toEqual(csv) + }) + it("uses precise stroops and quotes multiline/formula fields", () => { + expect(decimalToStroops("9007199254740993.1234567")).toBe("90071992547409931234567") + expect(formatStroops("-12345678")).toBe("-1.2345678") + expect(() => decimalToStroops("1e10")).toThrow() + expect(() => decimalToStroops("0.00000001")).toThrow() + expect(csvCell('=SUM(1,2)\n"x"')).toBe('"\'=SUM(1,2)\n""x"""') + }) + it("binds export signatures to the wallet, network, range, policy and nonce", () => { + const network = "Test SDF Network ; September 2015" + const signature = key.sign(Buffer.from(`Stellar Signed Message:\n${taxExportMessage(input, network)}`)).toString("base64") + expect(verifyTaxExport(input, network, signature)).toBe(true) + for (const changed of [{ ...input, policy: "HIFO" as const }, { ...input, wallet: buyer }, { ...input, nonce: "differentnonce123" }]) expect(verifyTaxExport(changed, network, signature)).toBe(false) + expect(verifyTaxExport(input, "mainnet", signature)).toBe(false) + expect(verifyTaxExport(input, network, signature, input.exp * 1000)).toBe(false) + }) + it("commits a single offer decision and tax evidence under overlapping writers", async () => { + const listing = await createListing({ token_id: 1, collection_id: 1, seller_wallet: wallet, price_phaselq: 1, accepts_offers: true }) + const offer = await createOffer({ listing_id: listing.id, buyer_wallet: buyer, amount_phaselq: 2 }) + const results = await Promise.allSettled([Promise.resolve().then(() => decideOfferAtomic(listing.id, offer.id, wallet, "accept")), Promise.resolve().then(() => decideOfferAtomic(listing.id, offer.id, wallet, "accept"))]) + expect(results.filter(r => r.status === "fulfilled")).toHaveLength(1) + expect(results.filter(r => r.status === "rejected")).toHaveLength(1) + expect((await getListing(listing.id))?.status).toBe("sold") + expect(getDb().prepare("SELECT * FROM tax_events").all()).toHaveLength(2) + expect(getDb().prepare("SELECT * FROM tax_events WHERE verified=1").all()).toHaveLength(0) + }) + it("rolls back the listing, offer and metrics if reporting evidence conflicts", async () => { + const listing = await createListing({ token_id: 2, collection_id: 1, seller_wallet: wallet, price_phaselq: 1, accepts_offers: true }) + const offer = await createOffer({ listing_id: listing.id, buyer_wallet: buyer, amount_phaselq: 2 }) + recordTaxEvent({ ...event, eventKey: `offer:${offer.id}:buyer`, wallet: buyer, verified: false }) + resetSecurityCounters() + expect(() => decideOfferAtomic(listing.id, offer.id, wallet, "accept")).toThrow("TAX_EVENT_CONFLICT") + expect((await getListing(listing.id))?.status).toBe("active") + expect(getDb().prepare("SELECT status FROM offers WHERE id=?").get(offer.id)).toMatchObject({ status: "pending" }) + expect(getDb().prepare("SELECT * FROM tax_events").all()).toHaveLength(1) + expect(getSecurityCounter("tax_event_recorded")).toBe(0) + }) +}) diff --git a/lib/api-observability.ts b/lib/api-observability.ts index 6535f5e8..1b461c36 100644 --- a/lib/api-observability.ts +++ b/lib/api-observability.ts @@ -1,3 +1,5 @@ +import { redactSecrets } from "@/lib/x402-dead-letter" +import { isFeatureEnabled } from "@/lib/feature-flags" import { randomUUID } from "node:crypto" import { NextRequest, NextResponse } from "next/server" @@ -36,7 +38,7 @@ export function createApiRequestContext(request: NextRequest, route: string) { route, method: request.method, correlationId, - ...metadata, + ...(isFeatureEnabled("phase-176") ? redactSecrets(metadata) as ApiMetadata : metadata), } const line = JSON.stringify(payload) diff --git a/lib/bounded-body.ts b/lib/bounded-body.ts new file mode 100644 index 00000000..6c46f516 --- /dev/null +++ b/lib/bounded-body.ts @@ -0,0 +1,17 @@ +/** Bound memory even when Content-Length is absent or dishonest. */ +export async function readBoundedBody(message: Request | Response, maxBytes: number): Promise { + const reader = message.body?.getReader() + if (!reader) return "" + const decoder = new TextDecoder() + let size = 0, text = "" + try { + while (true) { + const next = await reader.read() + if (next.done) break + size += next.value.byteLength + if (size > maxBytes) { await reader.cancel(); throw new Error("BODY_TOO_LARGE") } + text += decoder.decode(next.value, { stream: true }) + } + return text + decoder.decode() + } finally { reader.releaseLock() } +} diff --git a/lib/compliance.ts b/lib/compliance.ts new file mode 100644 index 00000000..5126caa6 --- /dev/null +++ b/lib/compliance.ts @@ -0,0 +1,126 @@ +import { createHmac, timingSafeEqual } from "node:crypto" +import { StrKey } from "@stellar/stellar-sdk" +import { NextResponse } from "next/server" +import { z } from "zod" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { getDb } from "@/lib/sqlite-db" +import { resolveStellarNetwork } from "@/lib/stellar-network" +import { incSecurityCounter } from "@/lib/security-counters" +import { readBoundedBody } from "@/lib/bounded-body" + +export type ComplianceOperation = "faucet" | "bounty" | "bootstrap" | "trustline" | "x402" +export type ComplianceCode = "KYC_REQUIRED" | "SANCTIONS_BLOCKED" | "SCREENING_UNAVAILABLE" | "INVALID_AMOUNT" | "INVALID_WALLET" +export class ComplianceError extends Error { + constructor(public code: ComplianceCode, public status: number) { super(code) } +} +const providerResult = z.object({ sanctions: z.enum(["clear", "blocked"]), checkedAt: z.number().int(), expiresAt: z.number().int() }).strict() +export const verificationEvent = z.object({ + eventId: z.string().regex(/^[a-zA-Z0-9_-]{8,128}$/), + wallet: z.string().refine(StrKey.isValidEd25519PublicKey), + subject: z.string().min(1).max(256), + status: z.enum(["verified", "revoked"]), + expiresAt: z.number().int().positive(), + expectedVersion: z.number().int().nonnegative(), +}).strict() + +function config() { + const mode = process.env.PHASE_KYC_MODE ?? "off" + if (!["off", "mock", "live"].includes(mode)) throw new ComplianceError("SCREENING_UNAVAILABLE", 503) + if (mode === "mock" && resolveStellarNetwork().network !== "testnet") throw new ComplianceError("SCREENING_UNAVAILABLE", 503) + return mode +} +function identityKey(value: string) { + const secret = process.env.PHASE_KYC_HASH_KEY + if (!secret || secret.length < 32) throw new ComplianceError("SCREENING_UNAVAILABLE", 503) + return createHmac("sha256", secret).update(value).digest("hex") +} + +export function applyVerificationEvent(raw: unknown): { replay: boolean; version: number } { + if (!isFeatureEnabled("phase-176") || config() === "off") throw new Error("DISABLED") + const event = verificationEvent.parse(raw) + const now = Date.now() + if (event.expiresAt <= now || event.expiresAt > now + 366 * 86400_000) throw new Error("INVALID_EXPIRY") + const walletHash = identityKey(`wallet:${event.wallet}`) + const subjectHash = identityKey(`subject:${event.subject}`) + const db = getDb() + db.exec("BEGIN IMMEDIATE") + try { + const current = db.prepare("SELECT version FROM compliance_credentials WHERE wallet_hash = ?").get(walletHash) as { version: number } | undefined + const version = current?.version ?? 0 + if (db.prepare("SELECT 1 FROM compliance_webhooks WHERE id = ?").get(event.eventId)) { + db.exec("COMMIT") + incSecurityCounter("compliance_webhook_replay") + return { replay: true, version } + } + if (event.expectedVersion !== version) { + throw Object.assign(new Error("VERSION_CONFLICT"), { current_version: version }) + } + db.prepare(`INSERT INTO compliance_credentials(wallet_hash, subject_hash, status, expires_at, version) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(wallet_hash) DO UPDATE SET subject_hash=excluded.subject_hash, + status=excluded.status, expires_at=excluded.expires_at, version=excluded.version`) + .run(walletHash, subjectHash, event.status, event.expiresAt, version + 1) + db.prepare("INSERT INTO compliance_webhooks(id, received_at) VALUES (?, ?)").run(event.eventId, now) + db.exec("COMMIT") + return { replay: false, version: version + 1 } + } catch (error) { db.exec("ROLLBACK"); throw error } +} + +export function verifyComplianceWebhook(body: string, timestamp: string, signature: string): boolean { + const secret = process.env.PHASE_KYC_WEBHOOK_SECRET + if (!secret || secret.length < 32 || !/^\d{13}$/.test(timestamp) || Math.abs(Date.now() - Number(timestamp)) > 300_000 || !/^[a-f0-9]{64}$/.test(signature)) return false + const expected = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest() + return timingSafeEqual(expected, Buffer.from(signature, "hex")) +} + +export async function screenCompliance(wallet: string, amountStroops: string, operation: ComplianceOperation): Promise { + if (!isFeatureEnabled("phase-176")) return + const mode = config() + if (mode === "off") return + if (!StrKey.isValidEd25519PublicKey(wallet)) throw new ComplianceError("INVALID_WALLET", 400) + if (!/^\d{1,38}$/.test(amountStroops)) throw new ComplianceError("INVALID_AMOUNT", 400) + let screening: z.infer + const now = Date.now() + try { + if (mode === "mock") { + const blocked = (process.env.PHASE_KYC_MOCK_BLOCKED_WALLETS ?? "").split(",").includes(wallet) + screening = { sanctions: blocked ? "blocked" : "clear", checkedAt: now, expiresAt: now + 60_000 } + } else { + const endpoint = new URL(process.env.PHASE_SANCTIONS_URL ?? "") + if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password || !process.env.PHASE_SANCTIONS_API_KEY) throw new Error("CONFIG") + const response = await fetch(endpoint, { + method: "POST", redirect: "error", cache: "no-store", signal: AbortSignal.timeout(3000), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.PHASE_SANCTIONS_API_KEY}` }, + body: JSON.stringify({ wallet, operation, network: resolveStellarNetwork().network }), + }) + if (!response.ok) throw new Error("PROVIDER_UNAVAILABLE") + screening = providerResult.parse(JSON.parse(await readBoundedBody(response, 4096))) + } + // No stale-on-error for sanctions: every instance asks the same provider. + if (screening.checkedAt > now + 30_000 || screening.checkedAt < now - 3600_000 || screening.expiresAt <= now || screening.expiresAt <= screening.checkedAt) throw new Error("STALE_SCREENING") + } catch { + incSecurityCounter("compliance_unavailable") + throw new ComplianceError("SCREENING_UNAVAILABLE", 503) + } + if (screening.sanctions === "blocked") { + incSecurityCounter("compliance_denied") + throw new ComplianceError("SANCTIONS_BLOCKED", 451) + } + const threshold = process.env.PHASE_KYC_THRESHOLD_STROOPS ?? "10000000000" + if (!/^\d{1,38}$/.test(threshold) || BigInt(threshold) === 0n) throw new ComplianceError("SCREENING_UNAVAILABLE", 503) + if (BigInt(amountStroops) >= BigInt(threshold)) { + const credential = getDb().prepare("SELECT status, expires_at FROM compliance_credentials WHERE wallet_hash = ?").get(identityKey(`wallet:${wallet}`)) as { status: string; expires_at: number } | undefined + if (!credential || credential.status !== "verified" || credential.expires_at <= now) { + incSecurityCounter("compliance_denied") + throw new ComplianceError("KYC_REQUIRED", 403) + } + } + incSecurityCounter("compliance_allowed") +} + +export async function complianceGate(wallet: string, amount: string, operation: ComplianceOperation): Promise { + try { await screenCompliance(wallet, amount, operation); return null } + catch (error) { + const failure = error instanceof ComplianceError ? error : new ComplianceError("SCREENING_UNAVAILABLE", 503) + return NextResponse.json({ error: failure.code, code: failure.code }, { status: failure.status, headers: { "Cache-Control": "private, no-store", ...(failure.status === 503 ? { "Retry-After": "60" } : {}) } }) + } +} diff --git a/lib/decimal-amount.ts b/lib/decimal-amount.ts new file mode 100644 index 00000000..f760dd5e --- /dev/null +++ b/lib/decimal-amount.ts @@ -0,0 +1,11 @@ +/** Stellar amounts use seven decimal places; never round through floating point. */ +export function decimalToStroops(value: string): string { + if (!/^\d{1,30}(\.\d{1,7})?$/.test(value)) throw new Error("INVALID_AMOUNT") + const [whole, fraction = ""] = value.split(".") + return (BigInt(whole) * 10_000_000n + BigInt(fraction.padEnd(7, "0"))).toString() +} +export function formatStroops(value: string): string { + const n = BigInt(value) + const absolute = n < 0n ? -n : n + return `${n < 0n ? "-" : ""}${absolute / 10_000_000n}.${(absolute % 10_000_000n).toString().padStart(7, "0")}` +} diff --git a/lib/deep-links.ts b/lib/deep-links.ts new file mode 100644 index 00000000..3b2e99ac --- /dev/null +++ b/lib/deep-links.ts @@ -0,0 +1,55 @@ +import { StrKey } from "@stellar/stellar-sdk" + +export type PhaseIntent = { + version: 1 + kind: "collection" | "offer" | "quest" + id: string + offerId?: string + ref?: string + recipient?: string + capturedAt: number +} +export const INTENT_KEY = "phase:inbound-intent:v1" +export const INTENT_TTL_MS = 15 * 60 * 1000 +const identifier = /^[a-zA-Z0-9_-]{1,128}$/ + +export function parsePhaseLink(raw: string, origin: string, now = Date.now()): PhaseIntent { + if (raw.length > 2048) throw new Error("INVALID_LINK") + const url = new URL(raw, origin) + let parts: string[] + if (url.protocol === "phase:") parts = [url.hostname, ...url.pathname.split("/").filter(Boolean)] + else if (url.protocol === "stellar:" && url.hostname === "phase") parts = url.pathname.split("/").filter(Boolean) + else if ((url.protocol === "https:" || url.protocol === "http:") && url.origin === new URL(origin).origin) { + parts = url.pathname.split("/").filter(Boolean) + if (parts.shift() !== "link") throw new Error("INVALID_LINK") + } else throw new Error("INVALID_LINK") + if (url.username || url.password || url.hash) throw new Error("INVALID_LINK") + const [kind, id, offerId] = parts + if (!id || !identifier.test(id) || !["collection", "offer", "quest"].includes(kind)) throw new Error("INVALID_LINK") + if (parts.length !== (kind === "offer" ? 3 : 2)) throw new Error("INVALID_LINK") + if (kind === "collection" && (!/^[1-9]\d*$/.test(id) || !Number.isSafeInteger(Number(id)))) throw new Error("INVALID_LINK") + if (kind === "offer" && (!offerId || !identifier.test(offerId))) throw new Error("INVALID_LINK") + for (const [key] of url.searchParams) { + if (!["ref", "recipient"].includes(key) || url.searchParams.getAll(key).length !== 1) throw new Error("INVALID_LINK") + } + const ref = url.searchParams.get("ref") ?? undefined + const recipient = url.searchParams.get("recipient") ?? undefined + if (ref && !/^[A-Z2-9]{8}$/.test(ref)) throw new Error("INVALID_LINK") + if (recipient && !StrKey.isValidEd25519PublicKey(recipient)) throw new Error("INVALID_LINK") + return { version: 1, kind: kind as PhaseIntent["kind"], id, offerId, ref, recipient, capturedAt: now } +} + +export function intentPath(intent: PhaseIntent): string { + const query = new URLSearchParams() + if (intent.ref) query.set("ref", intent.ref) + if (intent.recipient) query.set("recipient", intent.recipient) + return `/link/${intent.kind}/${encodeURIComponent(intent.id)}${intent.offerId ? `/${encodeURIComponent(intent.offerId)}` : ""}${query.size ? `?${query}` : ""}` +} + +export function restoreIntent(raw: string | null, origin: string, now = Date.now()): PhaseIntent | null { + try { + const value = JSON.parse(raw ?? "null") as PhaseIntent | null + if (!value || value.version !== 1 || !Number.isSafeInteger(value.capturedAt) || value.capturedAt > now || now - value.capturedAt >= INTENT_TTL_MS) return null + return parsePhaseLink(intentPath(value), origin, value.capturedAt) + } catch { return null } +} diff --git a/lib/env-validation.ts b/lib/env-validation.ts index 1dec9b14..0b37a19c 100644 --- a/lib/env-validation.ts +++ b/lib/env-validation.ts @@ -183,6 +183,7 @@ export const serverEnvSchema = z.object({ export function validateClientEnv(rawEnv: Record = process.env): EnvValidationResult { const errors: EnvValidationError[] = [] + const parsed = clientEnvSchema.safeParse(rawEnv) if (!parsed.success) { for (const issue of parsed.error.issues) { @@ -234,8 +235,21 @@ export function validateServerEnv(rawEnv: Record = p * Valida la configuración completa de entorno para PHASE */ export function validatePhaseEnv(): EnvValidationResult { - const errors: EnvValidationError[] = [] const env = process.env || {} + const errors: EnvValidationError[] = [] + if (env.FEATURE_PHASE_176 === "1" || env.NEXT_PUBLIC_FEATURE_PHASE_176 === "1") { + const mode = env.PHASE_KYC_MODE ?? "off" + if (!["off", "mock", "live"].includes(mode) || (mode === "mock" && env.STELLAR_NETWORK === "mainnet")) { + errors.push({ variable: "PHASE_KYC_MODE", issue: "invalid_format", message: "Invalid KYC mode for this network.", hint: "Use off, mock (testnet only), or live." }) + } + if (mode !== "off") for (const key of ["PHASE_KYC_HASH_KEY", "PHASE_KYC_WEBHOOK_SECRET"] as const) { + if ((env[key]?.length ?? 0) < 32) errors.push({ variable: key, issue: "missing", message: "Compliance key must contain at least 32 characters.", hint: "Configure a private server-runtime key." }) + } + if (mode === "live") { + try { if (new URL(env.PHASE_SANCTIONS_URL ?? "").protocol !== "https:") throw new Error() } + catch { errors.push({ variable: "PHASE_SANCTIONS_URL", issue: "invalid_format", message: "HTTPS screening endpoint required.", hint: "Configure the server-side sanctions adapter URL." }) } + } + } const clientRes = validateClientEnv(env) if (!clientRes.valid) errors.push(...clientRes.errors) diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts index 90e52fd3..5fe0d5e7 100644 --- a/lib/feature-flags.ts +++ b/lib/feature-flags.ts @@ -70,6 +70,10 @@ */ export type PhaseFeatureFlag = + | "phase-174" + | "phase-175" + | "phase-176" + | "phase-177" | "phase-66" | "phase-77" @@ -160,6 +164,10 @@ export type PhaseFeatureFlag = const FLAG_ENV_MAP: Record = { + "phase-174": ["NEXT_PUBLIC_FEATURE_PHASE_174", "FEATURE_PHASE_174"], + "phase-175": ["NEXT_PUBLIC_FEATURE_PHASE_175", "FEATURE_PHASE_175"], + "phase-176": ["NEXT_PUBLIC_FEATURE_PHASE_176", "FEATURE_PHASE_176"], + "phase-177": ["NEXT_PUBLIC_FEATURE_PHASE_177", "FEATURE_PHASE_177"], "phase-66": ["NEXT_PUBLIC_FEATURE_PHASE_66", "FEATURE_PHASE_66"], "phase-77": ["NEXT_PUBLIC_FEATURE_PHASE_77", "FEATURE_PHASE_77"], "phase-78": ["NEXT_PUBLIC_FEATURE_PHASE_78", "FEATURE_PHASE_78"], @@ -258,6 +266,14 @@ function isTruthy(v: string | undefined): boolean { } export function isFeatureEnabled(flag: PhaseFeatureFlag): boolean { + // Next.js only inlines public environment values accessed by literal key. + const newFlags: Partial> = { + "phase-174": process.env.NEXT_PUBLIC_FEATURE_PHASE_174, + "phase-175": process.env.NEXT_PUBLIC_FEATURE_PHASE_175, + "phase-176": process.env.NEXT_PUBLIC_FEATURE_PHASE_176, + "phase-177": process.env.NEXT_PUBLIC_FEATURE_PHASE_177, + }; + if (isTruthy(newFlags[flag])) return true; // Public keys are checked first so the same helper works in browser and server code. const keys = FLAG_ENV_MAP[flag] ?? [ `NEXT_PUBLIC_FEATURE_${flag.replace(/-/g, "_").toUpperCase()}`, @@ -288,6 +304,7 @@ export function featureFlagEnvKeys(flag: PhaseFeatureFlag): string[] { export function getEnabledFeatureFlags(): PhaseFeatureFlag[] { // Keep enumeration explicit so dashboards have stable, reviewable flag order. const all: PhaseFeatureFlag[] = [ + "phase-174", "phase-175", "phase-176", "phase-177", "phase-66", "phase-77", "phase-78", diff --git a/lib/market-store.ts b/lib/market-store.ts index 4ef42069..e1d48626 100644 --- a/lib/market-store.ts +++ b/lib/market-store.ts @@ -7,6 +7,9 @@ import { readJsonFile, updateJsonFile } from "@/lib/json-store"; import { serverDataJsonPath } from "@/lib/server-data-paths"; import { getDb } from "@/lib/sqlite-db"; import { incSecurityCounter } from "@/lib/security-counters"; +import { recordTaxEvent } from "@/lib/tax-report"; +import { decimalToStroops } from "@/lib/decimal-amount"; +import { phaseProtocolContractIdForServer } from "@/lib/phase-protocol"; export type ListingStatus = "active" | "sold" | "cancelled"; export type OfferStatus = "pending" | "accepted" | "rejected" | "expired"; @@ -14,6 +17,7 @@ export type OfferStatus = "pending" | "accepted" | "rejected" | "expired"; export type Listing = { id: string; token_id: number; + contract_id?: string; collection_id: number; seller_wallet: string; price_phaselq: number; @@ -208,6 +212,7 @@ export async function getCreatorProfileViewAnalytics( type ListingRow = { id: string; token_id: number; + contract_id: string | null; collection_id: number; seller_wallet: string; price_phaselq: number; @@ -235,6 +240,7 @@ function rowToListing(row: ListingRow): Listing { return { id: row.id, token_id: row.token_id, + contract_id: row.contract_id ?? undefined, collection_id: row.collection_id, seller_wallet: row.seller_wallet, price_phaselq: row.price_phaselq, @@ -438,6 +444,7 @@ export async function createListing( ): Promise { const listing: Listing = { ...data, + contract_id: isFeatureEnabled("phase-177") ? phaseProtocolContractIdForServer() : undefined, id: randomUUID(), listed_at: Date.now(), status: "active", @@ -466,8 +473,8 @@ export async function createListing( (id, token_id, collection_id, seller_wallet, price_phaselq, accepts_offers, min_offer, image, name, listed_at, status, creator_wallet, royalty_bps, edition_number, edition_size, is_artist_proof, supply_mode, - drop_start_at, drop_end_at, dutch_start_price, dutch_floor_price, dutch_end_at, split_payees_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + drop_start_at, drop_end_at, dutch_start_price, dutch_floor_price, dutch_end_at, split_payees_json, contract_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( listing.id, @@ -493,6 +500,7 @@ export async function createListing( listing.dutch_floor_price ?? null, listing.dutch_end_at ?? null, listing.split_payees ? JSON.stringify(listing.split_payees) : null, + listing.contract_id ?? null, ); return listing; } @@ -644,6 +652,42 @@ export async function updateOfferStatus( return rowToOffer(row, Date.now()); } +/** One SQLite transaction owns the decision and its reporting evidence. */ +export function decideOfferAtomic(listingId: string, offerId: string, wallet: string, action: "accept" | "reject") { + const db = getDb() + let taxRecords = 0 + db.exec("BEGIN IMMEDIATE") + try { + const listingRow = db.prepare("SELECT * FROM listings WHERE id=?").get(listingId) as ListingRow | undefined + const offerRow = db.prepare("SELECT * FROM offers WHERE id=? AND listing_id=?").get(offerId, listingId) as OfferRow | undefined + if (!listingRow || !offerRow || listingRow.seller_wallet !== wallet) throw new Error("FORBIDDEN") + const now = Date.now() + if (listingRow.status !== "active" || offerRow.status !== "pending" || offerRow.expires_at <= now || offerRow.buyer_wallet === wallet) { + incSecurityCounter("market_accept_conflict") + throw Object.assign(new Error("OFFER_CONFLICT"), { current_status: offerRow.expires_at <= now ? "expired" : offerRow.status }) + } + const status = action === "accept" ? "accepted" : "rejected" + db.prepare("UPDATE offers SET status=?, decided_at=? WHERE id=? AND status='pending'").run(status, now, offerId) + if (action === "accept") { + db.prepare("UPDATE listings SET status='sold' WHERE id=? AND status='active'").run(listingId) + db.prepare("UPDATE offers SET status='rejected', decided_at=? WHERE listing_id=? AND id<>? AND status='pending'").run(now, listingId, offerId) + if (isFeatureEnabled("phase-177")) { + const listing = rowToListing(listingRow) + const split = isPhase140Enabled() ? computeRoyaltySplit(listing, offerRow.amount_phaselq) : { seller_amount_phaselq: offerRow.amount_phaselq, royalty_amount_phaselq: 0 } + const asset = `${listing.contract_id ?? `unknown-contract:${listing.id}`}:${listing.token_id}` + // Offer acceptance is an agreement, not a chain receipt. Never report it as verified proceeds. + const common = { asset, occurredAt: now, verified: false, washFlagged: !!offerRow.wash_flagged } + taxRecords += Number(recordTaxEvent({ ...common, eventKey: `offer:${offerId}:seller`, wallet, kind: "disposal", amountStroops: decimalToStroops(split.seller_amount_phaselq.toFixed(7)) }, false)) + taxRecords += Number(recordTaxEvent({ ...common, eventKey: `offer:${offerId}:buyer`, wallet: offerRow.buyer_wallet, kind: "acquisition", amountStroops: decimalToStroops(offerRow.amount_phaselq.toFixed(7)) }, false)) + if (listing.creator_wallet && split.royalty_amount_phaselq > 0) taxRecords += Number(recordTaxEvent({ ...common, eventKey: `offer:${offerId}:royalty`, wallet: listing.creator_wallet, kind: "royalty", amountStroops: decimalToStroops(split.royalty_amount_phaselq.toFixed(7)) }, false)) + } + } + db.exec("COMMIT") + if (taxRecords) incSecurityCounter("tax_event_recorded", taxRecords) + return rowToOffer({ ...offerRow, status }, now) + } catch (error) { db.exec("ROLLBACK"); throw error } +} + export async function getOffersByBuyer( buyer_wallet: string, opts: { limit?: number; cursor?: number } = {}, diff --git a/lib/phase-copy.ts b/lib/phase-copy.ts index 693e306e..dba8986f 100644 --- a/lib/phase-copy.ts +++ b/lib/phase-copy.ts @@ -247,9 +247,48 @@ const PHASE_HOST_CONTRACT_UNKNOWN_EN = const PHASE_HOST_CONTRACT_UNKNOWN_ES = "Error de host en contrato PHASE #{code}. Ver contracts/phase-protocol (PhaseError) o el diagnóstico completo del host." +export type IntegrationCopy = { + actionFailed: string + actionConflict: string + requirementsNotMet: string + rateLimited: string + quest: string + offer: string + buyer: string + seller: string + offerHint: string + claim: string + accept: string + reject: string + completed: string + pending: string + reviewLink: string + reviewLinkHint: string + destination: string + recipient: string + referral: string + signingWallet: string + connect: string + continue: string + cancel: string + linkError: string + mobileControls: string + taxTitle: string + taxHint: string + taxDownload: string + taxError: string + startDate: string + endDate: string + policy: string + kycRequired: string + sanctionsBlocked: string + screeningUnavailable: string +} + export const phaseCopy: Record< AppLang, { + integrations: IntegrationCopy nav: { home: string; market: string; forge: string; chamber: string; lang: string } dashboard: { title: string @@ -734,50 +773,50 @@ export const phaseCopy: Record< pricing: PricingCopy moderationQueue: ModerationQueueCopy questArcs: QuestArcCopy - qrFlows: { - receiveTitle: string - scanTitle: string - shareReferral: string - scanPrompt: string - copyUri: string - copied: string - requestAmount: string - scanQr: string - viewQr: string - manualEntry: string - cameraError: string - } - walletConnectResilience: { - title: string - reconnectPrompt: string - chainMismatch: string - openMobileWallet: string - relayDisconnected: string - reconnectedToast: string - } - xbullParity: { - badge: string - signPrompt: string - trustlinePrompt: string - rejected: string - networkMismatch: string - } - ledgerClearSigning: { - title: string - description: string - targetContract: string - hostFunction: string - verificationCode: string - matchCodePrompt: string - acknowledgePrompt: string - approveButton: string - cancelButton: string - blindSigningDisabled: string - gate13RiskAlert: string - } + qrFlows: Record<"receiveTitle" | "scanTitle" | "shareReferral" | "scanPrompt" | "copyUri" | "copied" | "requestAmount" | "scanQr" | "viewQr" | "manualEntry" | "cameraError", string> + walletConnectResilience: Record<"title" | "reconnectPrompt" | "chainMismatch" | "openMobileWallet" | "relayDisconnected" | "reconnectedToast", string> + xbullParity: Record<"badge" | "signPrompt" | "trustlinePrompt" | "rejected" | "networkMismatch", string> + ledgerClearSigning: Record<"title" | "description" | "targetContract" | "hostFunction" | "verificationCode" | "matchCodePrompt" | "acknowledgePrompt" | "approveButton" | "cancelButton" | "blindSigningDisabled" | "gate13RiskAlert", string> } > = { en: { + integrations: { + "actionFailed": "The request failed. Your input is preserved; retry when ready.", + "actionConflict": "This request is already pending or its state has changed. Refresh the status before retrying.", + "requirementsNotMet": "Complete the quest requirements and add the required trustline before claiming.", + "rateLimited": "Too many requests. Wait before retrying.", + "quest": "Quest", + "offer": "Offer", + "buyer": "Buyer", + "seller": "Seller", + "offerHint": "Accepting records the agreement. It does not transfer the NFT or prove an on-chain payment.", + "claim": "Claim", + "accept": "Accept", + "reject": "Reject", + "completed": "Completed", + "pending": "Transaction pending. Keep this screen and retry to verify the outcome.", + "reviewLink": "Review incoming link", + "reviewLinkHint": "Check the destination and full recipient address. Continuing opens the requested screen; transactions always require a separate wallet confirmation.", + "destination": "Destination", + "recipient": "Suggested recipient (untrusted)", + "referral": "Referral code (untrusted)", + "signingWallet": "Connected wallet", + "connect": "Connect wallet", + "continue": "Continue", + "cancel": "Cancel", + "linkError": "This link is invalid, expired, or unavailable.", + "mobileControls": "Chamber controls", + "taxTitle": "Transaction export", + "taxHint": "Testnet records only. Missing basis and FX remain blank; unverified trades are flagged. This is not a tax form or tax advice.", + "taxDownload": "Download CSV", + "taxError": "Export failed. Check your wallet signature and try again.", + "startDate": "From (UTC)", + "endDate": "To (UTC, exclusive)", + "policy": "Lot policy", + "kycRequired": "Identity verification is required for this amount.", + "sanctionsBlocked": "This wallet cannot use this service.", + "screeningUnavailable": "Screening is unavailable. Try again later." + }, nav: { home: "← Home", market: "Market", @@ -1435,6 +1474,43 @@ export const phaseCopy: Record< es: { + integrations: { + "actionFailed": "Falló la solicitud. Conservamos los datos; reintentá cuando puedas.", + "actionConflict": "La solicitud está pendiente o cambió de estado. Actualizá el estado antes de reintentar.", + "requirementsNotMet": "Completá los requisitos de la misión y agregá la línea de confianza antes de reclamar.", + "rateLimited": "Demasiadas solicitudes. Esperá antes de reintentar.", + "quest": "Misión", + "offer": "Oferta", + "buyer": "Comprador", + "seller": "Vendedor", + "offerHint": "Aceptar registra el acuerdo. No transfiere el NFT ni demuestra un pago en cadena.", + "claim": "Reclamar", + "accept": "Aceptar", + "reject": "Rechazar", + "completed": "Completado", + "pending": "Transacción pendiente. Conservá la pantalla y reintentá para verificar el resultado.", + "reviewLink": "Revisar enlace entrante", + "reviewLinkHint": "Verificá el destino y la dirección completa. Continuar abre la pantalla solicitada; las transacciones requieren otra confirmación en la billetera.", + "destination": "Destino", + "recipient": "Destinatario sugerido (no verificado)", + "referral": "Código de referido (no verificado)", + "signingWallet": "Billetera conectada", + "connect": "Conectar billetera", + "continue": "Continuar", + "cancel": "Cancelar", + "linkError": "Este enlace no es válido, venció o no está disponible.", + "mobileControls": "Controles de la cámara", + "taxTitle": "Exportar transacciones", + "taxHint": "Solo registros de testnet. Base y cambio desconocidos quedan vacíos; operaciones no verificadas se marcan. No es un formulario ni asesoramiento fiscal.", + "taxDownload": "Descargar CSV", + "taxError": "Falló la exportación. Verificá la firma y reintentá.", + "startDate": "Desde (UTC)", + "endDate": "Hasta (UTC, exclusivo)", + "policy": "Política de lotes", + "kycRequired": "Este monto requiere verificación de identidad.", + "sanctionsBlocked": "Esta billetera no puede usar este servicio.", + "screeningUnavailable": "Verificación no disponible. Reintentá más tarde." + }, nav: { home: "← Inicio", market: "Mercado", diff --git a/lib/security-counters.ts b/lib/security-counters.ts index f401c8f0..e612d228 100644 --- a/lib/security-counters.ts +++ b/lib/security-counters.ts @@ -117,7 +117,16 @@ export type SecurityCounterName = | "ledger_clear_signing_approved" | "ledger_clear_signing_rejected" | "ledger_blind_signing_blocked" - | "ledger_gate13_preempted"; + | "ledger_gate13_preempted" + | "deep_link_rejected" + | "deep_link_resolved" + | "compliance_allowed" + | "compliance_denied" + | "compliance_unavailable" + | "compliance_webhook_replay" + | "tax_event_recorded" + | "tax_exported" + | "market_accept_conflict"; const counters = new Map() diff --git a/lib/sqlite-db.ts b/lib/sqlite-db.ts index b90877de..44792029 100644 --- a/lib/sqlite-db.ts +++ b/lib/sqlite-db.ts @@ -16,6 +16,32 @@ import { serverDataJsonPath } from "@/lib/server-data-paths"; let db: DatabaseSync | null = null; const SCHEMA = ` +CREATE TABLE IF NOT EXISTS compliance_credentials ( + wallet_hash TEXT PRIMARY KEY, + subject_hash TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('verified', 'revoked')), + expires_at INTEGER NOT NULL, + version INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_compliance_subject ON compliance_credentials(subject_hash); +CREATE TABLE IF NOT EXISTS compliance_webhooks (id TEXT PRIMARY KEY, received_at INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS tax_events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + event_key TEXT NOT NULL UNIQUE, + wallet TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('acquisition','disposal','royalty','reward')), + asset TEXT NOT NULL, + amount_stroops TEXT NOT NULL, + basis_stroops TEXT, + tx_hash TEXT, + occurred_at INTEGER NOT NULL, + verified INTEGER NOT NULL DEFAULT 0, + wash_flagged INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_tax_wallet_time ON tax_events(wallet, occurred_at, seq); +CREATE INDEX IF NOT EXISTS idx_tax_wallet_asset ON tax_events(wallet, asset, occurred_at, seq); +CREATE TABLE IF NOT EXISTS tax_export_nonces (nonce TEXT PRIMARY KEY, expires_at INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS tax_export_rate (wallet TEXT NOT NULL, minute INTEGER NOT NULL, count INTEGER NOT NULL, PRIMARY KEY(wallet, minute)); CREATE TABLE IF NOT EXISTS listings ( id TEXT PRIMARY KEY, token_id INTEGER NOT NULL, @@ -355,6 +381,7 @@ CREATE INDEX IF NOT EXISTS idx_quest_arcs_wallet_status // change. Both are nullable; a listing without them is simply not eligible // for royalty enforcement (phase-140 off, or no creator on file). const LISTING_COLUMNS: Array<{ name: string; ddl: string }> = [ + { name: "contract_id", ddl: "contract_id TEXT" }, { name: "creator_wallet", ddl: "creator_wallet TEXT" }, { name: "royalty_bps", ddl: "royalty_bps INTEGER" }, // Spike 673 (phase-editions): per-collection edition numbering. Nullable; NULL = uneditioned legacy row. @@ -421,40 +448,51 @@ export function getDb(): DatabaseSync { mkdirSync(path.dirname(dbPath), { recursive: true }); db = new DatabaseSync(dbPath); - db.exec("PRAGMA journal_mode = WAL;"); - db.exec("PRAGMA foreign_keys = ON;"); - db.exec(SCHEMA); - ensureListingColumns(db); - ensureOfferColumns(db); - // Idempotent migration for databases created before signature_verified existed. - try { - db.exec( - "ALTER TABLE signals ADD COLUMN signature_verified INTEGER NOT NULL DEFAULT 0;", - ); - } catch { - // Column already present — no-op. - } - try { - db.exec( - "ALTER TABLE signal_replies ADD COLUMN signature_verified INTEGER NOT NULL DEFAULT 0;", - ); - } catch { - // Column already present — no-op. - } + let migrationStarted = false; try { - db.exec( - "ALTER TABLE signals ADD COLUMN version INTEGER NOT NULL DEFAULT 1;", - ); - } catch { - // Column already present — no-op. + db.exec("PRAGMA busy_timeout = 5000;"); + db.exec("PRAGMA journal_mode = WAL;"); + db.exec("PRAGMA foreign_keys = ON;"); + db.exec("BEGIN IMMEDIATE;"); + migrationStarted = true; + db.exec(SCHEMA); + ensureListingColumns(db); + ensureOfferColumns(db); + // Idempotent migration for databases created before signature_verified existed. + try { + db.exec( + "ALTER TABLE signals ADD COLUMN signature_verified INTEGER NOT NULL DEFAULT 0;", + ); + } catch { + // Column already present — no-op. + } + try { + db.exec( + "ALTER TABLE signal_replies ADD COLUMN signature_verified INTEGER NOT NULL DEFAULT 0;", + ); + } catch { + // Column already present — no-op. + } + try { + db.exec( + "ALTER TABLE signals ADD COLUMN version INTEGER NOT NULL DEFAULT 1;", + ); + } catch { + // Column already present — no-op. + } + // The version column originally defaulted to 0, but every consumer of it + // (ETag, If-Match, parent_version) treats 0 as invalid, so a signal that had + // never been edited or upvoted could not be replied to. Normalize the + // sentinel to the same 1-based origin new rows get. + db.exec("UPDATE signals SET version = 1 WHERE version <= 0;"); + db.exec("COMMIT;"); + return db; + } catch (error) { + if (migrationStarted) db.exec("ROLLBACK;"); + db.close(); + db = null; + throw error; } - // The version column originally defaulted to 0, but every consumer of it - // (ETag, If-Match, parent_version) treats 0 as invalid, so a signal that had - // never been edited or upvoted could not be replied to. Normalize the - // sentinel to the same 1-based origin new rows get. - db.exec("UPDATE signals SET version = 1 WHERE version <= 0;"); - - return db; } /** Test-only: drop the cached connection so a fresh one is opened next call. */ diff --git a/lib/stellar-wallet-kit.ts b/lib/stellar-wallet-kit.ts index e06b3820..e5665ece 100644 --- a/lib/stellar-wallet-kit.ts +++ b/lib/stellar-wallet-kit.ts @@ -28,7 +28,6 @@ import { validateLedgerPreflight, mapLedgerHardwareError, } from "@/lib/ledger-soroban-decoder" -import { albedoRetiredSignMessage, isAlbedoRetired } from "@/lib/albedo-sunset" let initialized = false @@ -50,8 +49,7 @@ const WC_PROJECT_ID = function generatePostMessageNonce(): string { if (typeof window === "undefined") return "" - // Browser-only path: node:crypto has no randomUUID in the client polyfill and crashed every page. - const nonce = globalThis.crypto.randomUUID() + const nonce = window.crypto.randomUUID() try { window.localStorage.setItem(LS_POSTMESSAGE_NONCE, nonce) window.localStorage.setItem(LS_POSTMESSAGE_ORIGIN, window.location.origin) diff --git a/lib/tax-export-proof.ts b/lib/tax-export-proof.ts new file mode 100644 index 00000000..286f8bd1 --- /dev/null +++ b/lib/tax-export-proof.ts @@ -0,0 +1,12 @@ +import { StrKey } from "@stellar/stellar-sdk" +import { z } from "zod" + +export const taxExportSchema = z.object({ + wallet: z.string().refine(StrKey.isValidEd25519PublicKey), + from: z.string().datetime(), to: z.string().datetime(), policy: z.enum(["FIFO", "HIFO"]), + exp: z.number().int(), nonce: z.string().regex(/^[a-zA-Z0-9_-]{16,128}$/), +}).strict().refine(v => Date.parse(v.to) > Date.parse(v.from) && Date.parse(v.to) - Date.parse(v.from) <= 10 * 366 * 86400_000) +export type TaxExportRequest = z.infer +export function taxExportMessage(input: TaxExportRequest, network: string): string { + return ["phase-tax:v1", network, input.wallet, input.from, input.to, input.policy, input.exp, input.nonce].join("|") +} diff --git a/lib/tax-report.ts b/lib/tax-report.ts new file mode 100644 index 00000000..f7c04257 --- /dev/null +++ b/lib/tax-report.ts @@ -0,0 +1,101 @@ +import { createHash } from "node:crypto" +import { Keypair, StrKey } from "@stellar/stellar-sdk" +import { taxExportMessage, type TaxExportRequest } from "@/lib/tax-export-proof" +export { taxExportSchema } from "@/lib/tax-export-proof" +import { getDb } from "@/lib/sqlite-db" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { incSecurityCounter } from "@/lib/security-counters" +import { formatStroops } from "@/lib/decimal-amount" + +export type TaxEvent = { + eventKey: string; wallet: string; kind: "acquisition" | "disposal" | "royalty" | "reward" + asset: string; amountStroops: string; basisStroops?: string | null; txHash?: string + occurredAt: number; verified: boolean; washFlagged?: boolean +} +type TaxRow = { seq: number; event_key: string; wallet: string; kind: TaxEvent["kind"]; asset: string; amount_stroops: string; basis_stroops: string | null; tx_hash: string | null; occurred_at: number; verified: number; wash_flagged: number } + +export function recordTaxEvent(event: TaxEvent, count = true): boolean { + if (!isFeatureEnabled("phase-177")) return false + if (!StrKey.isValidEd25519PublicKey(event.wallet) || !/^\d{1,38}$/.test(event.amountStroops) || (event.basisStroops != null && !/^\d{1,38}$/.test(event.basisStroops)) || !Number.isSafeInteger(event.occurredAt) || event.occurredAt <= 0 || event.eventKey.length > 256 || !event.eventKey || event.asset.length > 256) throw new Error("INVALID_TAX_EVENT") + if (event.verified && !/^[a-f0-9]{64}$/.test(event.txHash ?? "")) throw new Error("VERIFIED_RECEIPT_REQUIRED") + const db = getDb() + const values = [event.eventKey, event.wallet, event.kind, event.asset, BigInt(event.amountStroops).toString(), event.basisStroops != null ? BigInt(event.basisStroops).toString() : null, event.txHash ?? null, event.occurredAt, event.verified ? 1 : 0, event.washFlagged ? 1 : 0] as const + const result = db.prepare(`INSERT INTO tax_events(event_key,wallet,kind,asset,amount_stroops,basis_stroops,tx_hash,occurred_at,verified,wash_flagged) + VALUES (?,?,?,?,?,?,?,?,?,?) ON CONFLICT(event_key) DO NOTHING`).run(...values) + if (result.changes) { + if (count) incSecurityCounter("tax_event_recorded") + return true + } + else { + const row = db.prepare("SELECT * FROM tax_events WHERE event_key=?").get(event.eventKey) as TaxRow + const previous = [row.event_key,row.wallet,row.kind,row.asset,row.amount_stroops,row.basis_stroops,row.tx_hash,row.occurred_at,row.verified,row.wash_flagged] + if (JSON.stringify(previous) !== JSON.stringify(values)) throw new Error("TAX_EVENT_CONFLICT") + } + return false +} + +export function verifyTaxExport(input: TaxExportRequest, network: string, signature: string, now = Date.now()): boolean { + if (input.exp * 1000 <= now || input.exp * 1000 > now + 300_000 || signature.length > 256) return false + try { return Keypair.fromPublicKey(input.wallet).verify(Buffer.from(`Stellar Signed Message:\n${taxExportMessage(input, network)}`), Buffer.from(signature, "base64")) } + catch { return false } +} + +export function taxExportRateLimited(wallet: string, now = Date.now()): boolean { + const db = getDb(), minute = Math.floor(now / 60_000) + db.prepare("DELETE FROM tax_export_rate WHERE minute < ?").run(minute - 1) + const row = db.prepare(`INSERT INTO tax_export_rate(wallet,minute,count) VALUES (?,?,1) + ON CONFLICT(wallet,minute) DO UPDATE SET count=count+1 RETURNING count`).get(wallet, minute) as { count: number } + return row.count > 60 +} + +/** RFC 4180 quoting plus spreadsheet formula neutralization. Numeric fields are generated separately. */ +export function csvCell(value: string): string { + const safe = /^[\s]*[=+\-@\t\r]/.test(value) ? `'${value}` : value + return `"${safe.replace(/"/g, '""')}"` +} + +export function* taxCsv(input: TaxExportRequest, snapshot: number): Generator { + const db = getDb() + const from = Date.parse(input.from), to = Date.parse(input.to) + let cursorTime = from, cursorSeq = 0 + let hash = createHash("sha256").update(JSON.stringify({ version: 1, wallet: input.wallet, from: input.from, to: input.to, policy: input.policy, snapshot })).digest("hex") + yield "event_id,date_utc,type,asset,amount_phaselq,basis_phaselq,proceeds_phaselq,gain_phaselq,royalty_income_phaselq,tx_hash,flags,lot_policy,recomputation_hash\r\n" + while (true) { + const rows = db.prepare(`SELECT * FROM tax_events WHERE wallet=? AND seq<=? AND occurred_at>=? AND occurred_at? OR (occurred_at=? AND seq>?)) ORDER BY occurred_at,seq LIMIT 50`) + .all(input.wallet, snapshot, from, to, cursorTime, cursorTime, cursorSeq) as TaxRow[] + if (!rows.length) break + for (const row of rows) { + const flags: string[] = [] + if (!row.verified) flags.push("unverified") + if (row.wash_flagged) flags.push("wash_excluded") + let basis: string | null = row.basis_stroops + if (row.kind === "disposal") { + // NFTs are indivisible lots. Latest prior disposal fences the holding cycle; + // both policies coincide for a single token but rank multiple acquisition evidence deterministically. + const previous = db.prepare(`SELECT occurred_at,seq FROM tax_events WHERE wallet=? AND asset=? AND kind='disposal' + AND verified=1 AND wash_flagged=0 AND seq<=? AND (occurred_at? OR (occurred_at=? AND seq>?)) + AND (occurred_at index >= 4 && index <= 8 ? `"${value}"` : csvCell(value)).join(",") + "\r\n" + cursorTime = row.occurred_at; cursorSeq = row.seq + } + } +} diff --git a/lib/viewer-signature.ts b/lib/viewer-signature.ts index 340a4768..5b56bae4 100644 --- a/lib/viewer-signature.ts +++ b/lib/viewer-signature.ts @@ -14,6 +14,12 @@ /** SEP-53 fixed prefix, concatenated with the message before hashing. */ export const SIGNATURE_PREFIX = "Stellar Signed Message:\n" +/** Presentation identifier only. Authorization always requires a verified signature. */ +export function viewerSignatureShort(wallet: string | null | undefined): string { + if (!wallet) return "" + return `${wallet.slice(0, 6)}…${wallet.slice(-4)}` +} + /** Canonical payload signed/proven for a community signal. */ export type SignalProofPayload = { title: string diff --git a/lib/x402-dead-letter.ts b/lib/x402-dead-letter.ts index 9f7dcc14..eff62717 100644 --- a/lib/x402-dead-letter.ts +++ b/lib/x402-dead-letter.ts @@ -138,7 +138,7 @@ function normalizeReasons( }) } -const SECRET_KEY_RE = /(secret|seed|priv|passphrase|password|token|jwt|mnemonic|api[_-]?key)/i +const SECRET_KEY_RE = /(secret|seed|priv|passphrase|password|token|jwt|mnemonic|api[_-]?key|dob|birth|document|passport|ssn|email|subject|verification[_-]?id|legal[_-]?name)/i const MAX_STRING_LEN = 4096 const MAX_DEPTH = 6 diff --git a/middleware.ts b/middleware.ts index 72cd536f..0a6d3a07 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server" +import { isFeatureEnabled } from "@/lib/feature-flags" function getPhaseEnvironment(): string { if (typeof process === "undefined" || !process.env) return "production" @@ -26,7 +27,18 @@ function buildCSP(): string { } export function middleware(request: NextRequest): NextResponse { - const response = NextResponse.next() + let response = NextResponse.next() + if (isFeatureEnabled("phase-175") && request.nextUrl.pathname.startsWith("/link/")) { + const parts = request.nextUrl.pathname.split("/").filter(Boolean) + const target = parts[1] === "collection" ? "/chamber" : parts[1] === "quest" ? "/forge" : "/dashboard" + const rewritten = request.nextUrl.clone() + rewritten.pathname = target + rewritten.search = "" + rewritten.searchParams.set("phase_link", request.nextUrl.pathname + request.nextUrl.search) + if (parts[1] === "collection" && /^[1-9]\d*$/.test(parts[2] ?? "")) rewritten.searchParams.set("collection", parts[2]) + response = NextResponse.rewrite(rewritten) + response.headers.set("Cache-Control", "private, no-store") + } const csp = buildCSP() response.headers.set("Content-Security-Policy", csp) diff --git a/next.config.mjs b/next.config.mjs index 68596e7a..5b1ebb0a 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -36,6 +36,14 @@ const nextConfig = { }, async headers() { return [ + { + source: "/.well-known/:association(assetlinks.json|apple-app-site-association)", + headers: [ + { key: "Content-Type", value: "application/json" }, + { key: "Access-Control-Allow-Origin", value: "*" }, + { key: "Cache-Control", value: "public, max-age=300" }, + ], + }, // Security headers — applied to every route { source: "/(.*)", diff --git a/scripts/check-mobile-integration.mjs b/scripts/check-mobile-integration.mjs new file mode 100644 index 00000000..ae04fbb6 --- /dev/null +++ b/scripts/check-mobile-integration.mjs @@ -0,0 +1,64 @@ +/** Run against a local server with public flags 174/175 enabled. + * Install Playwright outside the repo and set PHASE_PLAYWRIGHT_MODULE to its index.mjs, + * or install it in the caller's environment. No wallet credentials or signing is used. + */ +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +const { chromium } = await import(process.env.PHASE_PLAYWRIGHT_MODULE || 'playwright') +const base = process.env.PHASE_SMOKE_BASE_URL || 'http://127.0.0.1:3100' +const output = process.env.PHASE_SMOKE_OUTPUT || '/tmp/phase-mobile-validation' +await mkdir(output, { recursive: true }) +const browser = await chromium.launch({ executablePath: process.env.PHASE_CHROME_PATH || '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }) +const results = [] +try { + for (const width of [360, 390, 768, 1024]) { + const context = await browser.newContext({ viewport: { width, height: 844 }, isMobile: width < 768, hasTouch: width < 768, reducedMotion: 'reduce' }) + const page = await context.newPage() + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + for (const route of ['/forge', '/chamber']) { + await page.goto(base + route, { waitUntil: 'domcontentloaded', timeout: 120000 }) + await page.locator('[data-mobile-layout]').waitFor() + await page.waitForTimeout(500) + const state = await page.evaluate(() => ({ width: innerWidth, scroll: document.documentElement.scrollWidth })) + if (state.width < state.scroll) throw new Error(`${route} overflows at ${width}`) + await page.screenshot({ path: path.join(output, `${route.slice(1)}-${width}.png`), fullPage: true }) + if (errors.length) throw new Error(errors.join('\n')) + results.push({ route, width, horizontalOverflow: false, pageErrors: 0 }) + } + await context.close() + } + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, reducedMotion: 'reduce' }) + const page = await context.newPage() + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + await page.goto(base + '/chamber', { waitUntil: 'domcontentloaded' }) + const opener = page.getByRole('button', { name: 'Chamber controls', exact: true }) + await opener.click() + const dialog = page.getByRole('dialog') + await dialog.waitFor() + if (!await dialog.evaluate(el => el.contains(document.activeElement))) throw new Error('Sheet focus escaped') + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + await page.waitForFunction(() => document.activeElement?.textContent === 'Chamber controls') + if (!await opener.evaluate(el => el === document.activeElement)) throw new Error('Opener focus was not restored') + await page.goto(base + '/link/quest/quest_connect_wallet', { waitUntil: 'domcontentloaded' }) + await page.getByRole('heading', { name: 'Review incoming link' }).waitFor() + if (!await page.evaluate(() => !!sessionStorage.getItem('phase:inbound-intent:v1'))) throw new Error('Intent was not persisted') + await page.goto(base + '/forge', { waitUntil: 'domcontentloaded' }) + await page.getByRole('heading', { name: 'Review incoming link' }).waitFor() + await page.getByRole('button', { name: 'Cancel', exact: true }).first().click() + const prompt = page.locator('textarea').first() + await prompt.fill('Preserve this draft') + await page.getByRole('button', { name: 'How it works', exact: true }).click() + await page.keyboard.press('Escape') + if (await prompt.inputValue() !== 'Preserve this draft') throw new Error('Draft was lost') + await page.setViewportSize({ width: 390, height: 500 }) + await page.waitForTimeout(200) + if (!await page.locator('[data-mobile-layout]').evaluate(el => Math.abs(el.getBoundingClientRect().height - visualViewport.height) < 2)) throw new Error('Viewport height is stale') + if (errors.length) throw new Error(errors.join('\n')) + results.push({ sheetFocus: true, escape: true, restoredOpener: true, persistedIntent: true, strippedQueryRecovery: true, promptPreserved: true, resizedViewport: true, pageErrors: 0 }) + await context.close() + await writeFile(path.join(output, 'results.json'), JSON.stringify(results, null, 2)) + console.log(JSON.stringify(results, null, 2)) +} finally { await browser.close() } diff --git a/types/stellar-wallets-kit.d.ts b/types/stellar-wallets-kit.d.ts index 73952bd3..ae5419b8 100644 --- a/types/stellar-wallets-kit.d.ts +++ b/types/stellar-wallets-kit.d.ts @@ -56,6 +56,7 @@ declare module '@creit.tech/stellar-wallets-kit' { static getNetwork(): Promise<{ network: string; networkPassphrase: string }>; static sign(xdr: string, options?: any): Promise<{ result: string }>; static signTransaction(xdr: string, options?: any): Promise<{ signedTxXdr: string }>; + static signMessage(message: string, options?: { address?: string; networkPassphrase?: string }): Promise<{ signedMessage: string; signerAddress?: string }>; static signAuthEntry(entry: string, options?: any): Promise<{ signedAuthEntry: string }>; static signBlob(blob: string, options?: any): Promise<{ signedBlob: string }>; static getSupportedWallets(): any[];