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
8 changes: 4 additions & 4 deletions PROJECT_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,10 @@ is a fixed-size digest of the canonical payload, keeping it under wallet
| `phase-139` | `NEXT_PUBLIC_FEATURE_PHASE_139` / `FEATURE_PHASE_139` | Collection-level offer books aggregated from per-token offers, plus bulk-bid across a collection's listings | off | Unset var, restart — offer-book/bulk-bid route returns 404; per-listing offers (`/api/market/[id]/offers`) are unaffected either way |
| `phase-140` | `NEXT_PUBLIC_FEATURE_PHASE_140` / `FEATURE_PHASE_140` | Royalty enforcement on secondary sales: creator/seller split computed and ledgered at offer-accept time | off | Unset var, restart — listing creation stops accepting `creator_wallet`/`royalty_bps`; offer-accept stops computing a split (100% to seller, pre-140 behavior); existing `royalty_payouts` rows are historical record |
| `phase-141` | `NEXT_PUBLIC_FEATURE_PHASE_141` / `FEATURE_PHASE_141` | Yjs CRDT collaborative draft for signal lore — concurrent edits merge automatically instead of clobbering, promoted to committed lore via `PUT` (`from_draft: true`) under the normal `If-Match` guard | off | Unset var, restart — the CRDT routes return 404 and the draft panel is hidden; `PUT /api/signals/[id]` still works on its own as an `If-Match`-guarded full replacement; existing `signal_crdt_*` rows are inert scratch state (no migration to undo) |
| `phase-146` | `NEXT_PUBLIC_FEATURE_PHASE_146` / `FEATURE_PHASE_146` | Treasury buyback and burn from marketplace secondary fees | off | Unset var, restart — buyback route returns 404; existing buyback events remain in SQLite |
| `phase-147` | `NEXT_PUBLIC_FEATURE_PHASE_147` / `FEATURE_PHASE_147` | Staked FORGE tier-based fee discounts on x402 endpoints and settlement | off | Unset var, restart — tier discount route returns 404 and discount calculation reverts to 0% |
| `phase-148` | `NEXT_PUBLIC_FEATURE_PHASE_148` / `FEATURE_PHASE_148` | Bonding curves for open edition drops with supply-priced continuous minting and reserve accounting | off | Unset var, restart — bonding curve route returns 404; existing curve records remain in SQLite |
| `phase-149` | `NEXT_PUBLIC_FEATURE_PHASE_149` / `FEATURE_PHASE_149` | Prediction markets on drop outcomes with resolve-to-creator bonus | off | Unset var, restart — prediction market route returns 404; existing markets remain in SQLite |
| `phase-142` | `NEXT_PUBLIC_FEATURE_PHASE_142` / `FEATURE_PHASE_142` | AI thumbnail and crop generation with focal-point-aware variants for responsive IPFS display | off | Unset var, restart — IPFS route serves raw images without variant transform headers, client uses full master |
| `phase-143` | `NEXT_PUBLIC_FEATURE_PHASE_143` / `FEATURE_PHASE_143` | AI-driven churn and winback with dormant detection, tailored reactivation hooks, and experiment cohorts | off | Unset var, restart — winback route returns 404, experiment history in sidecar stays inert |
| `phase-144` | `NEXT_PUBLIC_FEATURE_PHASE_144` / `FEATURE_PHASE_144` | ve-PHASELQ time-locked governance escrow with pro-rata treasury fee-share distribution | off | Unset var, restart — governance ve-lock route returns 404, SQLite ve rows remain inert |
| `phase-145` | `NEXT_PUBLIC_FEATURE_PHASE_145` / `FEATURE_PHASE_145` | Gauge voting for quest emissions with ve-weight directing collection quest budgets | off | Unset var, restart — gauge route returns 404, SQLite vote rows remain inert |

Flags are read via `lib/feature-flags.ts:isFeatureEnabled`. Client flags use `NEXT_PUBLIC_*`, server also accepts `FEATURE_*`. Zero regression when off.

Expand Down
108 changes: 108 additions & 0 deletions app/api/governance/gauge/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { type NextRequest, NextResponse } from "next/server";
import { isFeatureEnabled } from "@/lib/feature-flags";
import {
castGaugeVote,
readWalletGaugeVotes,
readEpochAggregates,
distributeEpochEmissions,
getCollectionQuestBudget,
getGaugeEpochId,
} from "@/lib/gauge-voting";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
} as const;

export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: CORS });
}

export async function GET(request: NextRequest) {
if (!isFeatureEnabled("phase-145")) {
return NextResponse.json(
{ error: "phase-145 flag disabled (set NEXT_PUBLIC_FEATURE_PHASE_145=1)" },
{ status: 404, headers: CORS },
);
}

const { searchParams } = new URL(request.url);
const epochParam = searchParams.get("epoch");
const epoch = epochParam ? parseInt(epochParam, 10) : getGaugeEpochId();

const wallet = searchParams.get("wallet")?.trim();
const collectionIdParam = searchParams.get("collection_id");

const aggregates = readEpochAggregates(epoch);
const walletVotes = wallet ? readWalletGaugeVotes(wallet, epoch) : [];

let collectionBudget = null;
if (collectionIdParam) {
const colId = parseInt(collectionIdParam, 10);
const b = getCollectionQuestBudget(colId, epoch);
collectionBudget = {
collectionId: colId,
allocatedBudgetStroops: b.totalBudgetStroops.toString(),
veWeight: b.veWeight,
};
}

return NextResponse.json(
{
enabled: true,
epoch,
currentEpoch: getGaugeEpochId(),
aggregates,
walletVotes,
collectionBudget,
},
{ status: 200, headers: CORS },
);
}

export async function POST(request: NextRequest) {
if (!isFeatureEnabled("phase-145")) {
return NextResponse.json(
{ error: "phase-145 flag disabled (set NEXT_PUBLIC_FEATURE_PHASE_145=1)" },
{ status: 404, headers: CORS },
);
}

let body: any;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400, headers: CORS });
}

const action = body.action?.trim() ?? "vote";

if (action === "distribute_emissions") {
const epoch = typeof body.epoch === "number" ? body.epoch : getGaugeEpochId();
const totalStroops = typeof body.totalEmissionStroops === "string" ? BigInt(body.totalEmissionStroops) : 0n;
if (totalStroops <= 0n) {
return NextResponse.json({ error: "totalEmissionStroops must be positive" }, { status: 400, headers: CORS });
}
const dist = distributeEpochEmissions(epoch, totalStroops);
return NextResponse.json({ ok: true, distribution: dist }, { status: 200, headers: CORS });
}

if (action === "vote") {
const wallet = body.wallet?.trim();
if (!wallet) {
return NextResponse.json({ error: "Missing wallet field in body" }, { status: 400, headers: CORS });
}
const votes = Array.isArray(body.votes) ? body.votes : [];
const res = castGaugeVote(wallet, votes);
if (!res.ok) {
return NextResponse.json({ error: res.error }, { status: 400, headers: CORS });
}
return NextResponse.json({ ok: true, votes: res.records }, { status: 200, headers: CORS });
}

return NextResponse.json({ error: "Unknown action. Expected 'vote' or 'distribute_emissions'" }, { status: 400, headers: CORS });
}
121 changes: 121 additions & 0 deletions app/api/governance/ve-lock/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { type NextRequest, NextResponse } from "next/server";
import { isFeatureEnabled } from "@/lib/feature-flags";
import {
readVeLock,
createOrUpdateVeLock,
calculateClaimableFees,
claimFees,
withdrawVeLock,
distributeTreasuryFees,
MIN_LOCK_WEEKS,
MAX_LOCK_WEEKS,
} from "@/lib/ve-phaselq";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
} as const;

export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: CORS });
}

export async function GET(request: NextRequest) {
if (!isFeatureEnabled("phase-144")) {
return NextResponse.json(
{ error: "phase-144 flag disabled (set NEXT_PUBLIC_FEATURE_PHASE_144=1)" },
{ status: 404, headers: CORS },
);
}

const { searchParams } = new URL(request.url);
const wallet = searchParams.get("wallet")?.trim();
if (!wallet) {
return NextResponse.json({ error: "Missing wallet parameter" }, { status: 400, headers: CORS });
}

const lock = readVeLock(wallet);
const claimable = calculateClaimableFees(wallet);

return NextResponse.json(
{
enabled: true,
wallet,
lock: lock ?? null,
claimableFeesStroops: claimable.claimableStroops.toString(),
epochsCount: claimable.epochsCount,
minLockWeeks: MIN_LOCK_WEEKS,
maxLockWeeks: MAX_LOCK_WEEKS,
},
{ status: 200, headers: CORS },
);
}

export async function POST(request: NextRequest) {
if (!isFeatureEnabled("phase-144")) {
return NextResponse.json(
{ error: "phase-144 flag disabled (set NEXT_PUBLIC_FEATURE_PHASE_144=1)" },
{ status: 404, headers: CORS },
);
}

let body: any;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400, headers: CORS });
}

const action = body.action?.trim();
const wallet = body.wallet?.trim();

if (action === "distribute_fees") {
const epochId = typeof body.epochId === "number" ? body.epochId : 0;
const feeStroops = typeof body.feeStroops === "string" ? BigInt(body.feeStroops) : 0n;
if (feeStroops <= 0n) {
return NextResponse.json({ error: "feeStroops must be positive" }, { status: 400, headers: CORS });
}
const dist = distributeTreasuryFees(epochId, feeStroops);
return NextResponse.json({ ok: true, distribution: dist }, { status: 200, headers: CORS });
}

if (!wallet) {
return NextResponse.json({ error: "Missing wallet parameter" }, { status: 400, headers: CORS });
}

if (action === "claim") {
const claimRes = claimFees(wallet);
if (!claimRes.ok) {
return NextResponse.json({ error: claimRes.error }, { status: 400, headers: CORS });
}
return NextResponse.json({ ok: true, ...claimRes }, { status: 200, headers: CORS });

Check failure on line 95 in app/api/governance/ve-lock/route.ts

View workflow job for this annotation

GitHub Actions / Next.js build & lint

'ok' is specified more than once, so this usage will be overwritten.
}

if (action === "withdraw") {
const withdrawRes = withdrawVeLock(wallet);
if (!withdrawRes.ok) {
return NextResponse.json({ error: withdrawRes.error }, { status: 400, headers: CORS });
}
return NextResponse.json({ ok: true, ...withdrawRes }, { status: 200, headers: CORS });

Check failure on line 103 in app/api/governance/ve-lock/route.ts

View workflow job for this annotation

GitHub Actions / Next.js build & lint

'ok' is specified more than once, so this usage will be overwritten.
}

if (action === "lock") {
const amountStroops = typeof body.amountStroops === "string" ? BigInt(body.amountStroops) : 0n;
const lockWeeks = typeof body.lockWeeks === "number" ? body.lockWeeks : 0;

const lockRes = createOrUpdateVeLock(wallet, amountStroops, lockWeeks);
if (!lockRes.ok) {
return NextResponse.json({ error: lockRes.error }, { status: 400, headers: CORS });
}
return NextResponse.json({ ok: true, lock: lockRes.lock }, { status: 200, headers: CORS });
}

return NextResponse.json(
{ error: "Unknown action. Expected 'lock', 'claim', 'withdraw', or 'distribute_fees'" },
{ status: 400, headers: CORS },
);
}
19 changes: 19 additions & 0 deletions app/api/ipfs/[...cid]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
// the 1-year shared cache.
const { verifyCID, cacheControlForCid } = await import("@/lib/cid-verification")
const requestedCid = parsed.data[0] ?? ""
const verification = verifyCID(requestedCid, result.bytes)

Check failure on line 53 in app/api/ipfs/[...cid]/route.ts

View workflow job for this annotation

GitHub Actions / Next.js build & lint

Argument of type 'ArrayBuffer' is not assignable to parameter of type 'Buffer<ArrayBufferLike> | Uint8Array<ArrayBufferLike>'.
const gated = request.headers.get("x-phase-gated") === "1"

if (!verification.ok && verification.code === "CID_MISMATCH") {
Expand All @@ -60,6 +60,24 @@
)
}

const { isFeatureEnabled } = await import("@/lib/feature-flags")
let variantHeaders: Record<string, string> = {}

if (isFeatureEnabled("phase-142")) {
const { parseTransformParams, calculateFocalCrop, STANDARD_IMAGE_VARIANTS } = await import("@/lib/focal-crop")
const transform = parseTransformParams(request.nextUrl.searchParams)
if (transform.variant || (transform.width && transform.height)) {
const aspect = (transform.width && transform.height)
? transform.width / transform.height
: (transform.variant ? STANDARD_IMAGE_VARIANTS[transform.variant].aspect : 1.0)
const crop = calculateFocalCrop({ width: 2048, height: 2048 }, aspect, transform.focal)
variantHeaders = {
"X-Phase-Image-Variant": transform.variant ?? "custom",
"X-Phase-Focal-Crop": `${crop.x},${crop.y},${crop.width},${crop.height}`,
}
}
}

return new NextResponse(result.bytes, {
status: 200,
headers: {
Expand All @@ -69,6 +87,7 @@
Vary: "Authorization",
"X-Phase-Cid-Verified": verification.ok ? "1" : "0",
...(isPhase123Enabled() ? { "X-Phase-Gateway": result.gateway, "X-Phase-Latency-Ms": String(result.latencyMs) } : {}),
...variantHeaders,
},
})
}
Expand Down
109 changes: 109 additions & 0 deletions app/api/notifications/winback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { type NextRequest, NextResponse } from "next/server";
import { isFeatureEnabled } from "@/lib/feature-flags";
import { evaluateWinbackCampaign, recordWinbackDispatch, getWinbackStore } from "@/lib/churn-winback";
import { addNotification } from "@/lib/notification-store";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
} as const;

export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: CORS });
}

export async function GET(request: NextRequest) {
if (!isFeatureEnabled("phase-143")) {
return NextResponse.json(
{ error: "phase-143 flag disabled (set NEXT_PUBLIC_FEATURE_PHASE_143=1)" },
{ status: 404, headers: CORS },
);
}

const { searchParams } = new URL(request.url);
const wallet = searchParams.get("wallet")?.trim();
if (!wallet) {
return NextResponse.json({ error: "Missing wallet parameter" }, { status: 400, headers: CORS });
}

const lastActiveStr = searchParams.get("last_active");
const lastActiveMs = lastActiveStr ? parseInt(lastActiveStr, 10) : Date.now() - 20 * 24 * 60 * 60 * 1000;
const experimentId = searchParams.get("experiment_id")?.trim() ?? "winback_v1";

const store = await getWinbackStore();
const prior = store.entries[wallet.toLowerCase()];

const evaluation = evaluateWinbackCampaign(
wallet,
{
lastActiveMs,
lastNotifiedMs: prior?.dispatchedAt,
},
experimentId,
);

return NextResponse.json({ enabled: true, evaluation, priorDispatch: prior ?? null }, { status: 200, headers: CORS });
}

export async function POST(request: NextRequest) {
if (!isFeatureEnabled("phase-143")) {
return NextResponse.json(
{ error: "phase-143 flag disabled (set NEXT_PUBLIC_FEATURE_PHASE_143=1)" },
{ status: 404, headers: CORS },
);
}

let body: any;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400, headers: CORS });
}

const wallet = body.wallet?.trim();
if (!wallet) {
return NextResponse.json({ error: "Missing wallet field in body" }, { status: 400, headers: CORS });
}

const lastActiveMs = typeof body.lastActiveMs === "number" ? body.lastActiveMs : Date.now() - 20 * 24 * 60 * 60 * 1000;
const experimentId = body.experimentId?.trim() ?? "winback_v1";
const store = await getWinbackStore();
const prior = store.entries[wallet.toLowerCase()];

const evaluation = evaluateWinbackCampaign(
wallet,
{
lastActiveMs,
lastNotifiedMs: prior?.dispatchedAt,
activity: body.activity,
},
experimentId,
);

let dispatched = false;
if (body.dispatch && evaluation.eligible && evaluation.cohort !== "control") {
await recordWinbackDispatch(wallet, evaluation.cohort, evaluation.hook, experimentId);
try {
await addNotification({
recipientWallet: wallet,
type: "system",
title: evaluation.title,
body: evaluation.body,
data: {
cohort: evaluation.cohort,
hook: evaluation.hook,
experimentId,
},
});
dispatched = true;
} catch {
// In-app notification creation non-blocking
}
}

return NextResponse.json({ ok: true, evaluation, dispatched }, { status: 200, headers: CORS });
}
Loading
Loading