Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
15 changes: 15 additions & 0 deletions PROJECT_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |




Expand Down Expand Up @@ -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.
7 changes: 7 additions & 0 deletions app/.well-known/apple-app-site-association/route.ts
Original file line number Diff line number Diff line change
@@ -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/*"] }] : [] } })
}
8 changes: 8 additions & 0 deletions app/.well-known/assetlinks.json/route.ts
Original file line number Diff line number Diff line change
@@ -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 } }] : [])
}
4 changes: 2 additions & 2 deletions app/api/claim-bounty/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,11 @@ export async function POST(request: NextRequest): Promise<NextResponse<ClaimBoun
const upstreamUrl = new URL("/api/faucet", request.url)
const upstream = await fetch(upstreamUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...(request.headers.get("authorization") ? { Authorization: request.headers.get("authorization")! } : {}) },
body: JSON.stringify(payload),
cache: "no-store",
})

const upstreamJson = (await upstream.json().catch(() => ({ 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")! } : {}) } })
}
10 changes: 10 additions & 0 deletions app/api/classic-liq/route.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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())
Expand All @@ -253,13 +260,16 @@ 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,
asset: config.asset,
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(
Expand Down
19 changes: 19 additions & 0 deletions app/api/classic-liq/trustline/route.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
19 changes: 19 additions & 0 deletions app/api/compliance/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
75 changes: 39 additions & 36 deletions app/api/faucet/route.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -86,7 +88,7 @@ type WalletClaims = {
dailyAt?: number
quests?: Record<string, number>
/** 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<string, WalletClaims>
Expand Down Expand Up @@ -147,30 +149,28 @@ function claimsFilePath() {
* Wraps file I/O in withFileLock to serialize overlapping POSTs to same wallet.
*/
async function readClaims(): Promise<FaucetClaims> {
return withFileLock(claimsFilePath(), async () => {
try {
const raw = await readFile(claimsFilePath(), "utf8")
const parsed = JSON.parse(raw) as Record<string, number | WalletClaims>
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<string, number | WalletClaims>
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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading