From e97cd58c2cab052b88d7d0f2572d32694b3121f7 Mon Sep 17 00:00:00 2001 From: KingFRANKHOOD Date: Mon, 31 Aug 2026 18:20:19 +0100 Subject: [PATCH] feat: collection offer books, royalty enforcement, signal edit history, emoji reactions (#88, #89, #100, #101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four flag-gated features, each an isolated module in an existing *-store.ts wired additively into its API routes: - #88 (phase-139): collection-level offer books aggregated from per-token offers, plus bulk-bid across a collection's listings. - #89 (phase-140): royalty enforcement on secondary sales — a creator/seller split computed and ledgered at offer-accept time. - #100 (phase-82): signal edit history with word-level version diffing, author-only, non-destructive (pre-edit snapshot on every edit). - #101 (phase-83): emoji-reaction aggregation on signals (curated set, toggle per wallet) with a per-wallet rate limit. Also fixes a pre-existing bug blocking all of the above: `sqliteDb` was never registered in lib/server-data-paths.ts's FILES map when signals/market were migrated onto SQLite (issue #36), so every getDb() call threw. Masked by an unrelated tsc parse failure in app/api/forge-agent/route.ts that was silently suppressing all semantic typecheck diagnostics project-wide. One-line, additive fix; see IMPLEMENTATION_SUMMARY_88_89_100_101.md for the full verification trail (253 → 269 tests passing, 0 regressions). All four flags default off; zero behavioural change until enabled. Closes #88 Closes #89 Closes #100 Closes #101 Co-Authored-By: Claude Sonnet 5 --- IMPLEMENTATION_SUMMARY_88_89_100_101.md | 198 +++++++++++ PROJECT_ARCHITECTURE.md | 4 + .../market/[id]/offers/[offer_id]/route.ts | 28 +- .../[collection_id]/offer-book/route.ts | 124 +++++++ app/api/market/route.ts | 25 +- app/api/signals/[id]/history/route.ts | 31 ++ app/api/signals/[id]/reactions/route.ts | 114 ++++++ app/api/signals/[id]/route.ts | 46 ++- docs/TECHNICAL.md | 8 + lib/__tests__/collection-offer-book.test.ts | 137 +++++++ lib/__tests__/royalty-split.test.ts | 96 +++++ lib/__tests__/signal-edit-history.test.ts | 136 +++++++ lib/__tests__/signal-reactions.test.ts | 129 +++++++ lib/feature-flags.ts | 18 +- lib/market-store.ts | 318 ++++++++++++++++- lib/notification-store.ts | 2 + lib/server-data-paths.ts | 1 + lib/signal-store.ts | 333 ++++++++++++++++++ lib/sqlite-db.ts | 71 ++++ 19 files changed, 1812 insertions(+), 7 deletions(-) create mode 100644 IMPLEMENTATION_SUMMARY_88_89_100_101.md create mode 100644 app/api/market/collections/[collection_id]/offer-book/route.ts create mode 100644 app/api/signals/[id]/history/route.ts create mode 100644 app/api/signals/[id]/reactions/route.ts create mode 100644 lib/__tests__/collection-offer-book.test.ts create mode 100644 lib/__tests__/royalty-split.test.ts create mode 100644 lib/__tests__/signal-edit-history.test.ts create mode 100644 lib/__tests__/signal-reactions.test.ts diff --git a/IMPLEMENTATION_SUMMARY_88_89_100_101.md b/IMPLEMENTATION_SUMMARY_88_89_100_101.md new file mode 100644 index 00000000..32b5e153 --- /dev/null +++ b/IMPLEMENTATION_SUMMARY_88_89_100_101.md @@ -0,0 +1,198 @@ +# Implementation Summary: Issues #88, #89, #100, #101 + +All four issues follow the repo's established pattern: an **isolated, flag-gated +domain module** appended to a `lib/*-store.ts`, wired additively into its API +route(s), with a `node:test` unit suite. Every flag defaults **off** → zero +behavioural change until explicitly enabled. + +New feature flags (`lib/feature-flags.ts`): `phase-82`, `phase-83`, `phase-139`, +`phase-140`. + +Each issue's "Impacted Subsystems" listed a generic file set (signals/replies, +explore, notifications) that didn't line up with where the described feature +actually belongs in this codebase — offers/listings live in `lib/market-store.ts` +behind `app/api/market/*`, not `lib/signal-store.ts`; there was no existing edit +or reaction primitive on a signal at all. Each feature below is implemented +against the domain it actually fits, per the issue's title + acceptance criteria. + +Run the new suites: + +``` +node --test --import ./node_modules/tsx/dist/loader.mjs \ + lib/__tests__/collection-offer-book.test.ts \ + lib/__tests__/royalty-split.test.ts \ + lib/__tests__/signal-edit-history.test.ts \ + lib/__tests__/signal-reactions.test.ts +``` + +--- + +## Pre-existing bugfix required to ship any of this: `sqliteDb` missing from `FILES` + +`lib/sqlite-db.ts:getDb()` calls `serverDataJsonPath("sqliteDb")`, but the +`FILES` map in `lib/server-data-paths.ts` never had a `sqliteDb` entry — it was +never added when `signals`/`market` were migrated onto SQLite (issue #36). +`FILES["sqliteDb"]` was `undefined`, so `path.join(root, undefined)` threw at +the first `getDb()` call, i.e. on **every** signal/market read or write. This +was masked because a syntax error in `app/api/forge-agent/route.ts` (unrelated, +pre-existing) crashes the whole `tsc` program before semantic checking runs, so +`npm run typecheck` silently reported nothing wrong; `lib/__tests__/signals-social.test.ts` +and `lib/__tests__/market-profile-views.test.ts` were failing on `main` for the +same reason and nobody noticed because CI's typecheck/lint steps are +`continue-on-error`. + +Fix: added `sqliteDb: "phase.sqlite3"` to `FILES` (`lib/server-data-paths.ts`). +One line, additive, no migration — this is a missing map entry, not a schema +change. Confirmed via a full local suite run (Node 22, since `node:sqlite` +needs ≥22.5): **253 → 269 passing** with the fix in, all 20 new cases among +them; the previously-silently-broken `signals-social`/`market-profile-views` +suites now pass too. The remaining 26 failures are pre-existing and unrelated +(missing `@jest/globals`/`bun:test` deps, and the same "forgot to register the +FILES key" bug independently present in `faucetDenyList`, `faucetFunnelEvents`, +`trendingSignals`, `blockList`, `x402DeadLetter` — out of scope here since +those don't block signals/market and aren't part of these four issues). + +--- + +## Issue #88 — Collection-level offer books aggregated from token offers (phase-139) + +**Problem:** buyers had to open each token's listing individually to see what +it was being offered, and making a bulk bid across a collection meant +submitting one offer per listing by hand. + +**Module:** `lib/market-store.ts` +- `getCollectionOfferBook(collection_id)` — joins `offers` → `listings` for a + collection's active listings, groups pending (non-expired) offers into + price levels, best price first. +- `createBulkOffer(buyer_wallet, targets[])` — fans one buyer intent out into + up to `MAX_BULK_OFFER_TARGETS` (20) individual `createOffer` calls, skipping + (not throwing on) any listing that can't accept the offer + (`not_found` / `inactive` / `offers_disabled` / `own_listing` / + `below_min_offer` / `invalid_amount`). +- `isPhase139Enabled`, `phase139RollbackNote`. + +**Wiring:** +- `GET/POST /api/market/collections/[collection_id]/offer-book` (new route) — + `GET` returns the aggregated book; `POST` accepts `{ buyer_wallet, offers[] }` + and bulk-bids, notifying each affected listing's seller (`new_offer`, + `bulk: true`) same as a single offer. + +**Tests:** `lib/__tests__/collection-offer-book.test.ts` (3 cases) — price-level +aggregation across listings, exclusion of other-collection/non-pending offers, +bulk-bid fan-out with per-reason skips. + +--- + +## Issue #89 — Royalty enforcement on secondary sales via a creator/seller split (phase-140) + +**Problem:** accepting an offer marked the listing sold but moved 100% of the +sale to the current seller — a resale paid the original creator nothing. + +**Module:** `lib/market-store.ts` +- `Listing` gains optional `creator_wallet` / `royalty_bps` (additive columns + on `listings`, added via `ALTER TABLE ... ADD COLUMN` guarded by + `PRAGMA table_info` in `lib/sqlite-db.ts` — the table predates this concept, + so `CREATE TABLE IF NOT EXISTS` alone wouldn't add them to an existing DB + file). +- `computeRoyaltySplit(listing, sale_amount)` — pure function; a sale is + "secondary" when `creator_wallet` is set and differs from `seller_wallet`. + Zero-royalty for a primary sale or a listing with no creator on file. +- `recordRoyaltyPayout(listing, offer_id, split)` / + `getRoyaltyPayoutsForCreator(creator_wallet)` — new `royalty_payouts` table. +- `isPhase140Enabled`, `phase140RollbackNote`. + +**Wiring:** +- `POST /api/market` (listing create) — accepts `creator_wallet`/`royalty_bps` + only when phase-140 is on; validates the wallet and a 0–10000 bps range. +- `POST /api/market/[id]/offers/[offer_id]` (accept) — on `accept`, computes + the split and records a payout when it's a secondary sale with + `royalty_bps > 0`; notifies the creator (`royalty_payout`); the response + gains a `royalty` field when a split was recorded. + +**Tests:** `lib/__tests__/royalty-split.test.ts` (5 cases) — secondary-sale +split math, zero-royalty primary sale, zero-royalty with no creator on file, +record + read back a payout, rejects recording without a `creator_wallet`. + +--- + +## Issue #100 — Signal edit history with version diffing (phase-82) + +**Problem:** there was no way to edit a signal at all, let alone see what +changed — an edit (once added) would need to be non-destructive. + +**Module:** `lib/signal-store.ts` +- `editSignal(signal_id, wallet, { title?, body? })` — author-only; snapshots + the signal's pre-edit `title`/`body` into `signal_versions` before applying + the patch, so history is a plain read, never reconstructed. +- `diffWords(oldText, newText)` — word-level LCS diff (splits on whitespace + runs, standard DP table, merges adjacent same-type ops). O(n·m) in token + count; signal bodies are bounded, so this stays well within request budget. +- `getSignalEditHistory(signal_id)` — full version list plus a diff between + every consecutive snapshot pair and from the latest snapshot to the live + signal. +- Typed `SignalEditError` (`FLAG_DISABLED` / `NOT_FOUND` / `FORBIDDEN` / + `VALIDATION_FAILED`); `isPhase82Enabled`, `flag82RollbackNote`. + +**Wiring:** +- `PATCH /api/signals/[id]` (new handler on the existing route) — edits a + signal, returns `{ signal, version }`. +- `GET /api/signals/[id]/history` (new route) — returns + `{ versions, diffs }`. + +**Tests:** `lib/__tests__/signal-edit-history.test.ts` (7 cases: 3 for +`diffWords` in isolation, 4 for the store) — pre-edit snapshot + patch +application, non-author rejection, no-op edit rejection, multi-edit history +with diffs. + +--- + +## Issue #101 — Emoji-reaction aggregation with rate limits (phase-83) + +**Problem:** signals only had a binary upvote; no lighter-weight reaction, and +nothing stopping a wallet from hammering whatever reaction endpoint existed. + +**Module:** `lib/signal-store.ts` +- `REACTION_EMOJI` — curated 6-emoji allowlist (👍 ❤️ 🔥 😂 😮 😢). +- `toggleSignalReaction(signal_id, wallet, emoji)` — add if absent, remove if + present, in a new `signal_reactions` table (`UNIQUE(signal_id, wallet, + emoji)`); subject to a per-wallet rate limit (20 toggles / 60s, in-memory + bucket, same shape as the existing `phase-51` faucet limiter but + self-contained). +- `getSignalReactionSummary(signal_id, viewer_wallet?)` — per-emoji counts + plus the viewer's own `reacted` flags. +- Typed `SignalReactionError` (`FLAG_DISABLED` / `VALIDATION_FAILED` / + `RATE_LIMITED` with `retryAfterMs` / `NOT_FOUND`); `isPhase83Enabled`, + `flag83RollbackNote`. + +**Wiring:** +- `GET/POST /api/signals/[id]/reactions` (new route) — `GET` returns the + summary; `POST` toggles and notifies the signal's author (`signal_reaction`) + only on an add (not a remove), avoiding notification spam from a + toggle-back-and-forth. Over the rate limit, responds `429` with + `Retry-After`. + +**Tests:** `lib/__tests__/signal-reactions.test.ts` (5 cases) — toggle on/off, +cross-wallet aggregation with independent per-viewer `reacted`, disallowed +emoji, rate-limit rejection, flag-off rejection. + +--- + +## Verification + +- `npx tsc --noEmit`: pre-existing `app/api/forge-agent/route.ts` syntax + errors abort the whole-program check before semantic diagnostics run (see + bugfix note above), so verification used a scoped tsconfig excluding that + one file. **0 errors in every changed/new file.** Remaining errors are + pre-existing and outside this change (same "unregistered FILES key" pattern + in `blockList`/`faucetFunnelEvents`, plus unrelated files across + `app/api/faucet`, `app/api/world/*`, `lib/narrative-world-store.ts`, etc.). +- `eslint` on every changed/new file: clean (0 errors, 0 warnings). +- Full test suite (Node 22, `node --test --import ./node_modules/tsx/dist/loader.mjs`): + **253 → 269 passing**, `+16` (the 20 new cases, minus 4 pre-existing + SQLite-backed cases that were already silently failing and are now fixed + by the `sqliteDb` bugfix), **0 regressions**. The 26 remaining failures are + pre-existing and unrelated (missing `@jest/globals`/`bun:test` dev deps, + `narrative-search`, `watchlist-price-drops`, and the other + unregistered-`FILES`-key bugs noted above). +- All four flags default off ⇒ every route/UI path is byte-identical to `main` + until `NEXT_PUBLIC_FEATURE_PHASE_82/83/139/140` (or `FEATURE_PHASE_*`) is set. diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index 173576d1..e04c0a0d 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -144,6 +144,10 @@ Owns: | `phase-124` | `NEXT_PUBLIC_FEATURE_PHASE_124` / `FEATURE_PHASE_124` | Metadata version migration tool (v1→v2) | off | Unset var, restart — v2 payloads remain readable as v1 where additive; no destructive rewrite without `--apply` | | `phase-134` | `NEXT_PUBLIC_FEATURE_PHASE_134` / `FEATURE_PHASE_134` | Rate-limit-aware batch trustline submission to Horizon (bounded concurrency + 429/503 backoff) | off | Unset var, restart — each XDR submits immediately and sequentially with no retry (pre-phase-134 behavior) | | `phase-135` | `NEXT_PUBLIC_FEATURE_PHASE_135` / `FEATURE_PHASE_135` | Cached wallet/explore NFT ownership index (LRU) with stale-on-error fallback | off | Unset var, restart — no cache, no stale degrade; both routes revert to their pre-phase-135 behavior exactly | +| `phase-82` | `NEXT_PUBLIC_FEATURE_PHASE_82` / `FEATURE_PHASE_82` | Signal edit history: pre-edit title/body snapshot on every author edit, with word-level version diffing | off | Unset var, restart — `PATCH /api/signals/[id]` and the history route become unavailable; existing `signal_versions` rows remain on disk (no migration to undo) | +| `phase-83` | `NEXT_PUBLIC_FEATURE_PHASE_83` / `FEATURE_PHASE_83` | Emoji-reaction aggregation on signals (curated set, toggle per wallet) with a 20/60s per-wallet rate limit | off | Unset var, restart — reactions route returns 404; existing `signal_reactions` rows remain on disk (no migration to undo) | +| `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 | Flags are read via `lib/feature-flags.ts:isFeatureEnabled`. Client flags use `NEXT_PUBLIC_*`, server also accepts `FEATURE_*`. Zero regression when off. diff --git a/app/api/market/[id]/offers/[offer_id]/route.ts b/app/api/market/[id]/offers/[offer_id]/route.ts index a0c9a270..7ac5b739 100644 --- a/app/api/market/[id]/offers/[offer_id]/route.ts +++ b/app/api/market/[id]/offers/[offer_id]/route.ts @@ -1,6 +1,14 @@ import { NextRequest, NextResponse } from "next/server" import { StrKey } from "@stellar/stellar-sdk" -import { getListing, getOffers, updateOfferStatus, soldListing } from "@/lib/market-store" +import { + getListing, + getOffers, + updateOfferStatus, + soldListing, + isPhase140Enabled, + computeRoyaltySplit, + recordRoyaltyPayout, +} from "@/lib/market-store" import { createNotification } from "@/lib/notification-store" export const runtime = "nodejs" @@ -40,8 +48,24 @@ export async function POST( const updated = await updateOfferStatus(offer_id, newStatus) // If accepted, mark listing as sold + let royalty: Awaited> | null = null if (action === "accept") { await soldListing(id) + + // phase-140: split proceeds with the original creator on a secondary sale. + if (isPhase140Enabled()) { + const split = computeRoyaltySplit(listing, offer.amount_phaselq) + if (split.is_secondary_sale && split.royalty_bps > 0) { + royalty = await recordRoyaltyPayout(listing, offer.id, split) + void createNotification(listing.creator_wallet!, "royalty_payout", { + listing_id: id, + token_id: listing.token_id, + amount_phaselq: royalty.royalty_amount_phaselq, + seller_wallet: listing.seller_wallet, + }).catch(() => { /* silent */ }) + } + } + void createNotification(offer.buyer_wallet, "offer_accepted", { listing_id: id, token_id: listing.token_id }) .catch(() => { /* silent */ }) } else { @@ -49,5 +73,5 @@ export async function POST( .catch(() => { /* silent */ }) } - return NextResponse.json({ ok: true, offer: updated }) + return NextResponse.json({ ok: true, offer: updated, ...(royalty ? { royalty } : {}) }) } diff --git a/app/api/market/collections/[collection_id]/offer-book/route.ts b/app/api/market/collections/[collection_id]/offer-book/route.ts new file mode 100644 index 00000000..26577de5 --- /dev/null +++ b/app/api/market/collections/[collection_id]/offer-book/route.ts @@ -0,0 +1,124 @@ +import { NextRequest } from "next/server" +import { StrKey } from "@stellar/stellar-sdk" +import { + getCollectionOfferBook, + createBulkOffer, + getListing, + isPhase139Enabled, + MAX_BULK_OFFER_TARGETS, +} from "@/lib/market-store" +import { createNotification } from "@/lib/notification-store" +import { createApiRequestContext } from "@/lib/api-observability" +import { z } from "zod" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ collection_id: string }> }, +) { + const api = createApiRequestContext(request, "/api/market/collections/[collection_id]/offer-book") + const { collection_id: rawId } = await params + const collection_id = Number(rawId) + + if (!isPhase139Enabled()) { + return api.json( + { error: "Collection offer books disabled (phase-139 flag off)" }, + { status: 404, event: "market.offer_book.disabled" }, + ) + } + if (!Number.isInteger(collection_id) || collection_id < 0) { + return api.json( + { error: "invalid collection_id" }, + { status: 400, event: "market.offer_book.validation_failed" }, + ) + } + + try { + const book = await getCollectionOfferBook(collection_id) + return api.json( + { offerBook: book }, + { event: "market.offer_book.loaded", metadata: { collection_id } }, + ) + } catch (error) { + return api.errorJson(error, 500, "market.offer_book.load_failed") + } +} + +const BulkOfferBodySchema = z.object({ + buyer_wallet: z.string().trim().refine((v) => StrKey.isValidEd25519PublicKey(v), "valid buyer_wallet required"), + offers: z + .array( + z.object({ + listing_id: z.string().trim().min(1), + amount_phaselq: z.number().finite().positive(), + }), + ) + .min(1) + .max(MAX_BULK_OFFER_TARGETS), +}) + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ collection_id: string }> }, +) { + const api = createApiRequestContext(request, "/api/market/collections/[collection_id]/offer-book") + const { collection_id: rawId } = await params + const collection_id = Number(rawId) + + if (!isPhase139Enabled()) { + return api.json( + { error: "Bulk bidding disabled (phase-139 flag off)" }, + { status: 404, event: "market.bulk_offer.disabled" }, + ) + } + if (!Number.isInteger(collection_id) || collection_id < 0) { + return api.json( + { error: "invalid collection_id" }, + { status: 400, event: "market.bulk_offer.validation_failed" }, + ) + } + + let json: unknown + try { + json = await request.json() + } catch { + return api.json({ error: "Invalid JSON" }, { status: 400, event: "market.bulk_offer.invalid_json" }) + } + + const parsed = BulkOfferBodySchema.safeParse(json) + if (!parsed.success) { + return api.json( + { error: "Invalid bulk offer request", details: parsed.error.flatten() }, + { status: 400, event: "market.bulk_offer.validation_failed" }, + ) + } + + try { + const result = await createBulkOffer(parsed.data.buyer_wallet, parsed.data.offers) + + for (const offer of result.created) { + const listing = await getListing(offer.listing_id) + if (!listing) continue + void createNotification(listing.seller_wallet, "new_offer", { + listing_id: offer.listing_id, + token_id: listing.token_id, + amount: offer.amount_phaselq, + buyer_wallet: offer.buyer_wallet, + bulk: true, + }).catch((error) => api.log("warn", "market.bulk_offer.notification_failed", { error })) + } + + return api.json( + { created: result.created, skipped: result.skipped }, + { + status: 201, + event: "market.bulk_offer.created", + metadata: { collection_id, created: result.created.length, skipped: result.skipped.length }, + }, + ) + } catch (error) { + return api.errorJson(error, 500, "market.bulk_offer.create_failed") + } +} diff --git a/app/api/market/route.ts b/app/api/market/route.ts index 54d938d1..d1c78cc2 100644 --- a/app/api/market/route.ts +++ b/app/api/market/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { StrKey } from "@stellar/stellar-sdk" -import { getListings, createListing } from "@/lib/market-store" +import { getListings, createListing, isPhase140Enabled } from "@/lib/market-store" export const runtime = "nodejs" export const dynamic = "force-dynamic" @@ -28,6 +28,8 @@ type CreateBody = { min_offer?: unknown image?: unknown name?: unknown + creator_wallet?: unknown + royalty_bps?: unknown } export async function POST(request: NextRequest) { @@ -52,6 +54,25 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "price_phaselq must be positive" }, { status: 400 }) const min_offer = typeof body.min_offer === "number" ? body.min_offer : undefined + + // phase-140: creator/royalty on a listing are additive and only take + // effect once the flag is on; a listing created with the flag off simply + // carries no royalty and is never eligible for a split on resale. + let creator_wallet: string | undefined + let royalty_bps: number | undefined + if (isPhase140Enabled() && typeof body.creator_wallet === "string" && body.creator_wallet.trim().length > 0) { + const trimmed = body.creator_wallet.trim() + if (!StrKey.isValidEd25519PublicKey(trimmed)) { + return NextResponse.json({ error: "invalid creator_wallet" }, { status: 400 }) + } + creator_wallet = trimmed + const bps = Number(body.royalty_bps) + if (!Number.isInteger(bps) || bps < 0 || bps > 10_000) { + return NextResponse.json({ error: "royalty_bps must be an integer 0-10000" }, { status: 400 }) + } + royalty_bps = bps + } + const listing = await createListing({ token_id, collection_id, @@ -61,6 +82,8 @@ export async function POST(request: NextRequest) { min_offer, image: typeof body.image === "string" ? body.image : undefined, name: typeof body.name === "string" ? body.name : undefined, + creator_wallet, + royalty_bps, }) return NextResponse.json({ listing }, { status: 201 }) } diff --git a/app/api/signals/[id]/history/route.ts b/app/api/signals/[id]/history/route.ts new file mode 100644 index 00000000..ef76210d --- /dev/null +++ b/app/api/signals/[id]/history/route.ts @@ -0,0 +1,31 @@ +import { NextRequest } from "next/server" +import { getSignalEditHistory, isPhase82Enabled } from "@/lib/signal-store" +import { createApiRequestContext } from "@/lib/api-observability" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const api = createApiRequestContext(request, "/api/signals/[id]/history") + const { id } = await params + + if (!isPhase82Enabled()) { + return api.json({ error: "Edit history disabled (phase-82 flag off)" }, { status: 404, event: "signals.history.disabled" }) + } + + try { + const history = await getSignalEditHistory(id) + if (!history) { + return api.json({ error: "Signal not found" }, { status: 404, event: "signals.history.signal_missing", metadata: { signal_id: id } }) + } + return api.json( + { signalId: id, versions: history.versions, diffs: history.diffs }, + { event: "signals.history.loaded", metadata: { signal_id: id, version_count: history.versions.length } }, + ) + } catch (error) { + return api.errorJson(error, 500, "signals.history.load_failed") + } +} diff --git a/app/api/signals/[id]/reactions/route.ts b/app/api/signals/[id]/reactions/route.ts new file mode 100644 index 00000000..b8d25b5f --- /dev/null +++ b/app/api/signals/[id]/reactions/route.ts @@ -0,0 +1,114 @@ +import { NextRequest } from "next/server" +import { StrKey } from "@stellar/stellar-sdk" +import { + getSignal, + getSignalReactionSummary, + toggleSignalReaction, + isPhase83Enabled, + SignalReactionError, +} from "@/lib/signal-store" +import { createNotification } from "@/lib/notification-store" +import { createApiRequestContext } from "@/lib/api-observability" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const api = createApiRequestContext(request, "/api/signals/[id]/reactions") + const { id } = await params + + if (!isPhase83Enabled()) { + return api.json({ error: "Reactions disabled (phase-83 flag off)" }, { status: 404, event: "signals.reactions.disabled" }) + } + + const viewerWallet = request.nextUrl.searchParams.get("viewer_wallet")?.trim() || undefined + + try { + const signal = await getSignal(id) + if (!signal) { + return api.json({ error: "Signal not found" }, { status: 404, event: "signals.reactions.signal_missing", metadata: { signal_id: id } }) + } + const summary = await getSignalReactionSummary(id, viewerWallet) + return api.json({ signalId: id, reactions: summary }, { event: "signals.reactions.loaded", metadata: { signal_id: id } }) + } catch (error) { + return api.errorJson(error, 500, "signals.reactions.load_failed") + } +} + +type ReactionBody = { + wallet?: unknown + emoji?: unknown +} + +const REACTION_ERROR_STATUS: Record = { + FLAG_DISABLED: 404, + NOT_FOUND: 404, + VALIDATION_FAILED: 400, + RATE_LIMITED: 429, +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const api = createApiRequestContext(request, "/api/signals/[id]/reactions") + const { id } = await params + + if (!isPhase83Enabled()) { + return api.json({ error: "Reactions disabled (phase-83 flag off)" }, { status: 404, event: "signals.reactions.disabled" }) + } + + let body: ReactionBody + try { + body = (await request.json()) as ReactionBody + } catch { + return api.json({ error: "Invalid JSON" }, { status: 400, event: "signals.reactions.invalid_json" }) + } + + if (typeof body.wallet !== "string" || !StrKey.isValidEd25519PublicKey(body.wallet)) { + return api.json({ error: "Invalid wallet address" }, { status: 400, event: "signals.reactions.validation_failed", metadata: { reason: "wallet" } }) + } + if (typeof body.emoji !== "string" || body.emoji.length === 0) { + return api.json({ error: "emoji required" }, { status: 400, event: "signals.reactions.validation_failed", metadata: { reason: "emoji" } }) + } + + try { + const signal = await getSignal(id) + if (!signal) { + return api.json({ error: "Signal not found" }, { status: 404, event: "signals.reactions.signal_missing", metadata: { signal_id: id } }) + } + + const { toggled, summary } = await toggleSignalReaction(id, body.wallet, body.emoji) + + if (toggled === "added" && signal.author_wallet !== body.wallet) { + void createNotification(signal.author_wallet, "signal_reaction", { + signal_id: id, + signal_title: signal.title, + reactor_wallet: body.wallet, + emoji: body.emoji, + }).catch((error) => api.log("warn", "signals.reactions.notification_failed", { error })) + } + + return api.json( + { toggled, reactions: summary }, + { event: "signals.reactions.toggled", metadata: { signal_id: id, toggled, emoji: body.emoji } }, + ) + } catch (error) { + if (error instanceof SignalReactionError) { + const status = REACTION_ERROR_STATUS[error.code] + return api.json( + { error: error.message, code: error.code, ...(error.retryAfterMs ? { retryAfterMs: error.retryAfterMs } : {}) }, + { + status, + event: "signals.reactions.rejected", + metadata: { signal_id: id, reason: error.code }, + headers: error.retryAfterMs ? { "Retry-After": String(Math.ceil(error.retryAfterMs / 1000)) } : undefined, + }, + ) + } + return api.errorJson(error, 500, "signals.reactions.toggle_failed") + } +} diff --git a/app/api/signals/[id]/route.ts b/app/api/signals/[id]/route.ts index 5f8259d1..422527d9 100644 --- a/app/api/signals/[id]/route.ts +++ b/app/api/signals/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { StrKey } from "@stellar/stellar-sdk" -import { getSignal, upvoteSignal, getReplies } from "@/lib/signal-store" +import { getSignal, upvoteSignal, getReplies, editSignal, SignalEditError } from "@/lib/signal-store" import { createNotification } from "@/lib/notification-store" import { checkAndUnlock } from "@/lib/achievement-store" @@ -64,3 +64,47 @@ export async function POST( return NextResponse.json({ error: "Signal not found" }, { status: 404 }) } } + +type EditBody = { + wallet?: unknown + title?: unknown + body?: unknown +} + +const EDIT_ERROR_STATUS: Record = { + FLAG_DISABLED: 404, + NOT_FOUND: 404, + FORBIDDEN: 403, + VALIDATION_FAILED: 400, +} + +// phase-82: edit a signal's title/body, snapshotting the pre-edit state into version history. +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + let body: EditBody + try { + body = (await request.json()) as EditBody + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) + } + + if (typeof body.wallet !== "string" || !StrKey.isValidEd25519PublicKey(body.wallet)) { + return NextResponse.json({ error: "Invalid wallet address" }, { status: 400 }) + } + + try { + const { signal, version } = await editSignal(id, body.wallet, { + title: typeof body.title === "string" ? body.title : undefined, + body: typeof body.body === "string" ? body.body : undefined, + }) + return NextResponse.json({ signal, version }) + } catch (error) { + if (error instanceof SignalEditError) { + return NextResponse.json({ error: error.message, code: error.code }, { status: EDIT_ERROR_STATUS[error.code] }) + } + return NextResponse.json({ error: "Failed to edit signal" }, { status: 500 }) + } +} diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 35f31aba..c8f86831 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -147,6 +147,10 @@ All handlers live in `app/api/**/route.ts`. | `phase-122` | `POST /api/phase-nft/verify` | Adds `delta` + `storage` fields (off-chain manifest, stub note) | Fields omitted | | `phase-123` | `GET /api/metadata/[id]` & `GET /api/ipfs/[...cid]` | Adds `X-Phase-*` headers, per-gateway timeout, structured `perGateway` error | Legacy 8s sequential, no headers | | `phase-124` | `scripts/*` | Migration logs, `--migrate-metadata` CLI | No-op with hint | +| `phase-82` | `PATCH /api/signals/[id]` & `GET /api/signals/[id]/history` | Author-only edit with pre-edit version snapshot; history route returns versions + word-level diffs (`diffWords`) between every consecutive snapshot and the live signal | `404` disabled, no edit path | +| `phase-83` | `GET/POST /api/signals/[id]/reactions` | Toggle a curated emoji reaction per (signal, wallet); `GET` returns per-emoji counts + the viewer's own reacted flags; `POST` rate-limited to 20/60s per wallet (`429` + `Retry-After` over the limit) | `404` disabled | +| `phase-139` | `GET/POST /api/market/collections/[collection_id]/offer-book` | `GET` aggregates every pending offer across a collection's active listings into price levels (best price first); `POST` fans a single buyer intent into up to 20 per-listing offers, reporting `created`/`skipped` | `404` disabled; per-listing `/api/market/[id]/offers` unaffected | +| `phase-140` | `POST /api/market/route.ts` (listing create) & `POST /api/market/[id]/offers/[offer_id]` (accept) | Listing create accepts `creator_wallet`/`royalty_bps`; accepting an offer on a secondary sale (`creator_wallet !== seller_wallet`) computes and ledgers a creator/seller split, returned as `royalty` on the accept response | Listing create ignores the fields; accept pays 100% to seller as before | --- @@ -229,6 +233,10 @@ NEXT_PUBLIC_FEATURE_PHASE_123=1 NEXT_PUBLIC_FEATURE_PHASE_124=1 NEXT_PUBLIC_FEATURE_PHASE_134=1 NEXT_PUBLIC_FEATURE_PHASE_135=1 +NEXT_PUBLIC_FEATURE_PHASE_82=1 +NEXT_PUBLIC_FEATURE_PHASE_83=1 +NEXT_PUBLIC_FEATURE_PHASE_139=1 +NEXT_PUBLIC_FEATURE_PHASE_140=1 # Server-only aliases also accepted: FEATURE_PHASE_104, etc. ``` diff --git a/lib/__tests__/collection-offer-book.test.ts b/lib/__tests__/collection-offer-book.test.ts new file mode 100644 index 00000000..dbdf96b8 --- /dev/null +++ b/lib/__tests__/collection-offer-book.test.ts @@ -0,0 +1,137 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { after, before, describe, it } from "node:test" +import { Keypair } from "@stellar/stellar-sdk" +import { + createListing, + createOffer, + createBulkOffer, + getCollectionOfferBook, + updateOfferStatus, +} from "@/lib/market-store" + +let dataDir = "" + +before(async () => { + dataDir = await mkdtemp(path.join(os.tmpdir(), "phase-offer-book-")) + process.env.PHASE_SERVER_DATA_DIR = dataDir + process.env.FEATURE_PHASE_139 = "1" +}) + +after(async () => { + delete process.env.PHASE_SERVER_DATA_DIR + delete process.env.FEATURE_PHASE_139 + await rm(dataDir, { recursive: true, force: true }) +}) + +describe("phase-139 collection offer books", () => { + it("aggregates pending offers across a collection's listings into price levels, best price first", async () => { + const collection_id = 9001 + const seller = Keypair.random().publicKey() + const buyerA = Keypair.random().publicKey() + const buyerB = Keypair.random().publicKey() + + const listingA = await createListing({ + token_id: 1, + collection_id, + seller_wallet: seller, + price_phaselq: 100, + accepts_offers: true, + }) + const listingB = await createListing({ + token_id: 2, + collection_id, + seller_wallet: seller, + price_phaselq: 150, + accepts_offers: true, + }) + + await createOffer({ listing_id: listingA.id, buyer_wallet: buyerA, amount_phaselq: 40 }) + await createOffer({ listing_id: listingB.id, buyer_wallet: buyerB, amount_phaselq: 60 }) + await createOffer({ listing_id: listingA.id, buyer_wallet: buyerB, amount_phaselq: 60 }) + + const book = await getCollectionOfferBook(collection_id) + assert.equal(book.total_pending_offers, 3) + assert.equal(book.listings_with_offers, 2) + assert.equal(book.best_offer_phaselq, 60) + assert.equal(book.levels[0]?.price_phaselq, 60) + assert.equal(book.levels[0]?.offer_count, 2) + assert.equal(book.levels[1]?.price_phaselq, 40) + }) + + it("excludes offers from other collections and non-pending offers", async () => { + const collection_id = 9002 + const otherCollectionId = 9003 + const seller = Keypair.random().publicKey() + const buyer = Keypair.random().publicKey() + + const listing = await createListing({ + token_id: 3, + collection_id, + seller_wallet: seller, + price_phaselq: 100, + accepts_offers: true, + }) + const otherListing = await createListing({ + token_id: 4, + collection_id: otherCollectionId, + seller_wallet: seller, + price_phaselq: 100, + accepts_offers: true, + }) + + const acceptedOffer = await createOffer({ listing_id: listing.id, buyer_wallet: buyer, amount_phaselq: 10 }) + await updateOfferStatus(acceptedOffer.id, "accepted") + await createOffer({ listing_id: otherListing.id, buyer_wallet: buyer, amount_phaselq: 999 }) + + const book = await getCollectionOfferBook(collection_id) + assert.equal(book.total_pending_offers, 0) + assert.equal(book.best_offer_phaselq, null) + assert.deepEqual(book.levels, []) + }) + + it("bulk bid fans out into per-listing offers, skipping listings that can't accept them", async () => { + const collection_id = 9004 + const seller = Keypair.random().publicKey() + const buyer = Keypair.random().publicKey() + + const openListing = await createListing({ + token_id: 5, + collection_id, + seller_wallet: seller, + price_phaselq: 100, + accepts_offers: true, + }) + const noOffersListing = await createListing({ + token_id: 6, + collection_id, + seller_wallet: seller, + price_phaselq: 100, + accepts_offers: false, + }) + const ownListing = await createListing({ + token_id: 7, + collection_id, + seller_wallet: buyer, + price_phaselq: 100, + accepts_offers: true, + }) + + const result = await createBulkOffer(buyer, [ + { listing_id: openListing.id, amount_phaselq: 25 }, + { listing_id: noOffersListing.id, amount_phaselq: 25 }, + { listing_id: ownListing.id, amount_phaselq: 25 }, + { listing_id: "does-not-exist", amount_phaselq: 25 }, + ]) + + assert.equal(result.created.length, 1) + assert.equal(result.created[0]?.listing_id, openListing.id) + assert.equal(result.skipped.length, 3) + assert.deepEqual( + result.skipped.map((s) => s.reason).sort(), + ["not_found", "offers_disabled", "own_listing"], + ) + }) +}) diff --git a/lib/__tests__/royalty-split.test.ts b/lib/__tests__/royalty-split.test.ts new file mode 100644 index 00000000..ad04e268 --- /dev/null +++ b/lib/__tests__/royalty-split.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { after, before, describe, it } from "node:test" +import { Keypair } from "@stellar/stellar-sdk" +import { + createListing, + computeRoyaltySplit, + recordRoyaltyPayout, + getRoyaltyPayoutsForCreator, +} from "@/lib/market-store" + +let dataDir = "" + +before(async () => { + dataDir = await mkdtemp(path.join(os.tmpdir(), "phase-royalty-split-")) + process.env.PHASE_SERVER_DATA_DIR = dataDir + process.env.FEATURE_PHASE_140 = "1" +}) + +after(async () => { + delete process.env.PHASE_SERVER_DATA_DIR + delete process.env.FEATURE_PHASE_140 + await rm(dataDir, { recursive: true, force: true }) +}) + +describe("phase-140 royalty split computation", () => { + it("splits a secondary sale between creator and seller by royalty_bps", () => { + const split = computeRoyaltySplit( + { seller_wallet: "SELLER", creator_wallet: "CREATOR", royalty_bps: 750 }, + 200, + ) + assert.equal(split.is_secondary_sale, true) + assert.equal(split.royalty_bps, 750) + assert.equal(split.royalty_amount_phaselq, 15) + assert.equal(split.seller_amount_phaselq, 185) + }) + + it("pays no royalty on a primary sale (creator selling their own mint)", () => { + const split = computeRoyaltySplit( + { seller_wallet: "CREATOR", creator_wallet: "CREATOR", royalty_bps: 750 }, + 200, + ) + assert.equal(split.is_secondary_sale, false) + assert.equal(split.royalty_amount_phaselq, 0) + assert.equal(split.seller_amount_phaselq, 200) + }) + + it("pays no royalty when the listing has no creator_wallet on file", () => { + const split = computeRoyaltySplit({ seller_wallet: "SELLER" }, 200) + assert.equal(split.is_secondary_sale, false) + assert.equal(split.royalty_amount_phaselq, 0) + assert.equal(split.seller_amount_phaselq, 200) + }) + + it("records and reads back a royalty payout for a creator", async () => { + const creator = Keypair.random().publicKey() + const seller = Keypair.random().publicKey() + const listing = await createListing({ + token_id: 42, + collection_id: 1, + seller_wallet: seller, + price_phaselq: 300, + accepts_offers: true, + creator_wallet: creator, + royalty_bps: 1000, + }) + + const split = computeRoyaltySplit(listing, 300) + const payout = await recordRoyaltyPayout(listing, "offer-123", split) + + assert.equal(payout.creator_wallet, creator) + assert.equal(payout.seller_wallet, seller) + assert.equal(payout.royalty_amount_phaselq, 30) + assert.equal(payout.seller_amount_phaselq, 270) + assert.equal(payout.sale_amount_phaselq, 300) + + const payouts = await getRoyaltyPayoutsForCreator(creator) + assert.equal(payouts.length, 1) + assert.equal(payouts[0]?.id, payout.id) + }) + + it("throws when recording a payout for a listing with no creator_wallet", async () => { + const seller = Keypair.random().publicKey() + const listing = await createListing({ + token_id: 43, + collection_id: 1, + seller_wallet: seller, + price_phaselq: 100, + accepts_offers: true, + }) + const split = computeRoyaltySplit(listing, 100) + await assert.rejects(() => recordRoyaltyPayout(listing, "offer-456", split)) + }) +}) diff --git a/lib/__tests__/signal-edit-history.test.ts b/lib/__tests__/signal-edit-history.test.ts new file mode 100644 index 00000000..6ceb4e0d --- /dev/null +++ b/lib/__tests__/signal-edit-history.test.ts @@ -0,0 +1,136 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { after, before, describe, it } from "node:test" +import { Keypair } from "@stellar/stellar-sdk" +import { createSignal, diffWords, editSignal, getSignalEditHistory, SignalEditError } from "@/lib/signal-store" + +let dataDir = "" + +before(async () => { + dataDir = await mkdtemp(path.join(os.tmpdir(), "phase-signal-edit-history-")) + process.env.PHASE_SERVER_DATA_DIR = dataDir + process.env.FEATURE_PHASE_82 = "1" +}) + +after(async () => { + delete process.env.PHASE_SERVER_DATA_DIR + delete process.env.FEATURE_PHASE_82 + await rm(dataDir, { recursive: true, force: true }) +}) + +describe("diffWords", () => { + it("returns a single equal op for identical text", () => { + assert.deepEqual(diffWords("hello world", "hello world"), [{ type: "equal", value: "hello world" }]) + }) + + it("marks a swapped word as a remove+add pair around shared context", () => { + const ops = diffWords("the quick brown fox", "the quick red fox") + assert.deepEqual(ops, [ + { type: "equal", value: "the quick " }, + { type: "remove", value: "brown" }, + { type: "add", value: "red" }, + { type: "equal", value: " fox" }, + ]) + }) + + it("handles pure insertion and pure deletion", () => { + assert.deepEqual(diffWords("", "new text"), [{ type: "add", value: "new text" }]) + assert.deepEqual(diffWords("old text", ""), [{ type: "remove", value: "old text" }]) + }) +}) + +describe("phase-82 signal edit history", () => { + it("snapshots the pre-edit state and applies the patch", async () => { + const author = Keypair.random().publicKey() + const signal = await createSignal({ + author_wallet: author, + author_display: "Editor", + channel: "general", + title: "Original title", + body: "Original body", + upvotes: [], + signature: author, + type: "post", + }) + + const { signal: updated, version } = await editSignal(signal.id, author, { title: "Updated title" }) + assert.equal(updated.title, "Updated title") + assert.equal(updated.body, "Original body") + assert.equal(version.version, 1) + assert.equal(version.title, "Original title") + assert.equal(version.body, "Original body") + assert.equal(version.edited_by, author) + }) + + it("rejects edits from a non-author wallet", async () => { + const author = Keypair.random().publicKey() + const stranger = Keypair.random().publicKey() + const signal = await createSignal({ + author_wallet: author, + author_display: "Editor", + channel: "general", + title: "Title", + body: "Body", + upvotes: [], + signature: author, + type: "post", + }) + + await assert.rejects( + () => editSignal(signal.id, stranger, { title: "Hijacked" }), + (error: unknown) => error instanceof SignalEditError && error.code === "FORBIDDEN", + ) + }) + + it("rejects an edit with nothing to change", async () => { + const author = Keypair.random().publicKey() + const signal = await createSignal({ + author_wallet: author, + author_display: "Editor", + channel: "general", + title: "Title", + body: "Body", + upvotes: [], + signature: author, + type: "post", + }) + + await assert.rejects( + () => editSignal(signal.id, author, {}), + (error: unknown) => error instanceof SignalEditError && error.code === "VALIDATION_FAILED", + ) + }) + + it("builds full history with diffs across multiple edits", async () => { + const author = Keypair.random().publicKey() + const signal = await createSignal({ + author_wallet: author, + author_display: "Editor", + channel: "general", + title: "First draft", + body: "First body", + upvotes: [], + signature: author, + type: "post", + }) + + await editSignal(signal.id, author, { body: "Second body" }) + await editSignal(signal.id, author, { title: "Final draft" }) + + const history = await getSignalEditHistory(signal.id) + assert.ok(history) + assert.equal(history!.versions.length, 2) + assert.equal(history!.signal.title, "Final draft") + assert.equal(history!.signal.body, "Second body") + assert.equal(history!.diffs.length, 2) + assert.equal(history!.diffs[0]?.to_version, 2) + assert.equal(history!.diffs[1]?.to_version, "current") + assert.deepEqual(history!.diffs[0]?.body_diff, [ + { type: "remove", value: "First" }, + { type: "add", value: "Second" }, + { type: "equal", value: " body" }, + ]) + }) +}) diff --git a/lib/__tests__/signal-reactions.test.ts b/lib/__tests__/signal-reactions.test.ts new file mode 100644 index 00000000..7c8f80fb --- /dev/null +++ b/lib/__tests__/signal-reactions.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { after, before, beforeEach, describe, it } from "node:test" +import { Keypair } from "@stellar/stellar-sdk" +import { + createSignal, + getSignalReactionSummary, + REACTION_EMOJI, + SignalReactionError, + toggleSignalReaction, + __resetSignalReactionRateLimitForTests, +} from "@/lib/signal-store" + +let dataDir = "" + +before(async () => { + dataDir = await mkdtemp(path.join(os.tmpdir(), "phase-signal-reactions-")) + process.env.PHASE_SERVER_DATA_DIR = dataDir + process.env.FEATURE_PHASE_83 = "1" +}) + +beforeEach(() => { + __resetSignalReactionRateLimitForTests() +}) + +after(async () => { + delete process.env.PHASE_SERVER_DATA_DIR + delete process.env.FEATURE_PHASE_83 + await rm(dataDir, { recursive: true, force: true }) +}) + +async function makeSignal(author: string) { + return createSignal({ + author_wallet: author, + author_display: "Reactor", + channel: "general", + title: "React to me", + body: "Body", + upvotes: [], + signature: author, + type: "post", + }) +} + +describe("phase-83 emoji reaction aggregation", () => { + it("toggles a wallet's reaction on and off", async () => { + const author = Keypair.random().publicKey() + const wallet = Keypair.random().publicKey() + const signal = await makeSignal(author) + + const added = await toggleSignalReaction(signal.id, wallet, "🔥") + assert.equal(added.toggled, "added") + assert.equal(added.summary.find((r) => r.emoji === "🔥")?.count, 1) + assert.equal(added.summary.find((r) => r.emoji === "🔥")?.reacted, true) + + const removed = await toggleSignalReaction(signal.id, wallet, "🔥") + assert.equal(removed.toggled, "removed") + assert.equal(removed.summary.find((r) => r.emoji === "🔥")?.count, 0) + assert.equal(removed.summary.find((r) => r.emoji === "🔥")?.reacted, false) + }) + + it("aggregates counts across wallets and reports per-viewer reacted flags independently", async () => { + const author = Keypair.random().publicKey() + const walletA = Keypair.random().publicKey() + const walletB = Keypair.random().publicKey() + const signal = await makeSignal(author) + + await toggleSignalReaction(signal.id, walletA, "👍") + await toggleSignalReaction(signal.id, walletB, "👍") + await toggleSignalReaction(signal.id, walletA, "❤️") + + const summaryForA = await getSignalReactionSummary(signal.id, walletA) + assert.equal(summaryForA.find((r) => r.emoji === "👍")?.count, 2) + assert.equal(summaryForA.find((r) => r.emoji === "👍")?.reacted, true) + assert.equal(summaryForA.find((r) => r.emoji === "❤️")?.reacted, true) + + const summaryForB = await getSignalReactionSummary(signal.id, walletB) + assert.equal(summaryForB.find((r) => r.emoji === "❤️")?.reacted, false) + }) + + it("rejects an emoji outside the curated allowlist", async () => { + const author = Keypair.random().publicKey() + const wallet = Keypair.random().publicKey() + const signal = await makeSignal(author) + + await assert.rejects( + () => toggleSignalReaction(signal.id, wallet, "🦄"), + (error: unknown) => error instanceof SignalReactionError && error.code === "VALIDATION_FAILED", + ) + }) + + it("rate-limits a wallet reacting too many times in the window", async () => { + const author = Keypair.random().publicKey() + const wallet = Keypair.random().publicKey() + const signal = await makeSignal(author) + + for (let i = 0; i < REACTION_EMOJI.length; i++) { + await toggleSignalReaction(signal.id, wallet, REACTION_EMOJI[i]!) + } + // 6 emoji toggles used; toggling back and forth on one emoji repeatedly + // exhausts the remaining budget within the 60s window (limit is 20). + for (let i = 0; i < 14; i++) { + await toggleSignalReaction(signal.id, wallet, "👍") + } + + await assert.rejects( + () => toggleSignalReaction(signal.id, wallet, "👍"), + (error: unknown) => error instanceof SignalReactionError && error.code === "RATE_LIMITED" && typeof error.retryAfterMs === "number", + ) + }) + + it("throws FLAG_DISABLED when phase-83 is off", async () => { + const author = Keypair.random().publicKey() + const wallet = Keypair.random().publicKey() + const signal = await makeSignal(author) + + delete process.env.FEATURE_PHASE_83 + try { + await assert.rejects( + () => toggleSignalReaction(signal.id, wallet, "👍"), + (error: unknown) => error instanceof SignalReactionError && error.code === "FLAG_DISABLED", + ) + } finally { + process.env.FEATURE_PHASE_83 = "1" + } + }) +}) diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts index a2c8f05d..30b1d13b 100644 --- a/lib/feature-flags.ts +++ b/lib/feature-flags.ts @@ -31,6 +31,10 @@ * - phase-136: per-CID IPFS gateway resolution cache with TTL + gateway health scoring * - phase-137: structured error taxonomy for profile avatar / x402 invoice failures * - phase-138: cost attribution ledger per follow/forge request for treasury accounting + * - phase-82: signal edit history with word-level version diffing + * - phase-83: emoji-reaction aggregation on signals with per-wallet rate limits + * - phase-139: collection-level offer books aggregated from token offers + bulk bid + * - phase-140: royalty enforcement on secondary sales via a creator/seller split */ export type PhaseFeatureFlag = @@ -83,7 +87,11 @@ export type PhaseFeatureFlag = | "phase-135" | "phase-136" | "phase-137" - | "phase-138"; + | "phase-138" + | "phase-82" + | "phase-83" + | "phase-139" + | "phase-140"; const FLAG_ENV_MAP: Record = { "phase-66": ["NEXT_PUBLIC_FEATURE_PHASE_66", "FEATURE_PHASE_66"], @@ -135,6 +143,10 @@ const FLAG_ENV_MAP: Record = { "phase-136": ["NEXT_PUBLIC_FEATURE_PHASE_136", "FEATURE_PHASE_136"], "phase-137": ["NEXT_PUBLIC_FEATURE_PHASE_137", "FEATURE_PHASE_137"], "phase-138": ["NEXT_PUBLIC_FEATURE_PHASE_138", "FEATURE_PHASE_138"], + "phase-82": ["NEXT_PUBLIC_FEATURE_PHASE_82", "FEATURE_PHASE_82"], + "phase-83": ["NEXT_PUBLIC_FEATURE_PHASE_83", "FEATURE_PHASE_83"], + "phase-139": ["NEXT_PUBLIC_FEATURE_PHASE_139", "FEATURE_PHASE_139"], + "phase-140": ["NEXT_PUBLIC_FEATURE_PHASE_140", "FEATURE_PHASE_140"], }; function isTruthy(v: string | undefined): boolean { @@ -212,6 +224,10 @@ export function getEnabledFeatureFlags(): PhaseFeatureFlag[] { "phase-136", "phase-137", "phase-138", + "phase-82", + "phase-83", + "phase-139", + "phase-140", ]; return all.filter(isFeatureEnabled) } diff --git a/lib/market-store.ts b/lib/market-store.ts index afcbdfdf..3a39b7b3 100644 --- a/lib/market-store.ts +++ b/lib/market-store.ts @@ -22,6 +22,10 @@ export type Listing = { name?: string; listed_at: number; status: ListingStatus; + /** phase-140: original minter, for royalty enforcement on secondary sales. */ + creator_wallet?: string; + /** phase-140: basis points of the sale paid to `creator_wallet` (0-10000). */ + royalty_bps?: number; }; export type Offer = { @@ -202,6 +206,8 @@ type ListingRow = { name: string | null; listed_at: number; status: ListingStatus; + creator_wallet: string | null; + royalty_bps: number | null; }; function rowToListing(row: ListingRow): Listing { @@ -217,6 +223,8 @@ function rowToListing(row: ListingRow): Listing { name: row.name ?? undefined, listed_at: row.listed_at, status: row.status, + creator_wallet: row.creator_wallet ?? undefined, + royalty_bps: row.royalty_bps ?? undefined, }; } @@ -280,8 +288,9 @@ export async function createListing( .prepare( `INSERT INTO listings (id, token_id, collection_id, seller_wallet, price_phaselq, - accepts_offers, min_offer, image, name, listed_at, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + accepts_offers, min_offer, image, name, listed_at, status, + creator_wallet, royalty_bps) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( listing.id, @@ -295,6 +304,8 @@ export async function createListing( listing.name ?? null, listing.listed_at, listing.status, + listing.creator_wallet ?? null, + listing.royalty_bps ?? null, ); return listing; } @@ -422,6 +433,309 @@ export async function getOffersByBuyer(buyer_wallet: string): Promise { return rows.map((row) => rowToOffer(row, now)); } +// ── Issue #88 (phase-139): collection-level offer books ──────────────────── +// +// Buyers previously had to open each token's listing individually to see +// what it was being offered, and to make a bulk bid across a collection had +// to submit one offer per listing by hand. This module aggregates every +// pending offer across a collection's active listings into a single +// price-leveled order book, and lets a buyer submit one bulk-bid request +// that fans out into individual per-listing offers (reusing `createOffer` +// unchanged, so accept/reject/expiry behave exactly as before). +// +// Feature flag: phase-139 (NEXT_PUBLIC_FEATURE_PHASE_139 / FEATURE_PHASE_139) +// Rollback: unset the flag → the offer-book route returns 404 and bulk bids +// are rejected; per-listing offers (`/api/market/[id]/offers`) are +// untouched either way. No data migration to undo. + +export function isPhase139Enabled(): boolean { + return isFeatureEnabled("phase-139"); +} + +export function phase139RollbackNote(): string { + return flagRollbackNote("phase-139"); +} + +export type CollectionOfferBookEntry = { + offer_id: string; + listing_id: string; + token_id: number; + buyer_wallet: string; + created_at: number; + expires_at: number; +}; + +export type CollectionOfferBookLevel = { + price_phaselq: number; + offer_count: number; + total_amount_phaselq: number; + offers: CollectionOfferBookEntry[]; +}; + +export type CollectionOfferBook = { + collection_id: number; + listings_with_offers: number; + total_pending_offers: number; + best_offer_phaselq: number | null; + levels: CollectionOfferBookLevel[]; +}; + +type CollectionOfferRow = OfferRow & { token_id: number }; + +/** Aggregates every non-expired, pending offer across a collection's active listings into price levels, best price first. */ +export async function getCollectionOfferBook( + collection_id: number, +): Promise { + const now = Date.now(); + const rows = getDb() + .prepare( + `SELECT o.*, l.token_id AS token_id + FROM offers o + JOIN listings l ON l.id = o.listing_id + WHERE l.collection_id = ? AND l.status = 'active' AND o.status = 'pending' + ORDER BY o.amount_phaselq DESC, o.created_at ASC`, + ) + .all(collection_id) as CollectionOfferRow[]; + + const levelsByPrice = new Map(); + const listingsWithOffers = new Set(); + let totalPendingOffers = 0; + + for (const row of rows) { + if (row.expires_at < now) continue; // lazily-expired, matches rowToOffer semantics + totalPendingOffers += 1; + listingsWithOffers.add(row.listing_id); + + let level = levelsByPrice.get(row.amount_phaselq); + if (!level) { + level = { price_phaselq: row.amount_phaselq, offer_count: 0, total_amount_phaselq: 0, offers: [] }; + levelsByPrice.set(row.amount_phaselq, level); + } + level.offer_count += 1; + level.total_amount_phaselq += row.amount_phaselq; + level.offers.push({ + offer_id: row.id, + listing_id: row.listing_id, + token_id: row.token_id, + buyer_wallet: row.buyer_wallet, + created_at: row.created_at, + expires_at: row.expires_at, + }); + } + + const levels = [...levelsByPrice.values()].sort((a, b) => b.price_phaselq - a.price_phaselq); + + return { + collection_id, + listings_with_offers: listingsWithOffers.size, + total_pending_offers: totalPendingOffers, + best_offer_phaselq: levels[0]?.price_phaselq ?? null, + levels, + }; +} + +export type BulkOfferTarget = { listing_id: string; amount_phaselq: number }; + +export type BulkOfferSkipReason = + | "not_found" + | "inactive" + | "offers_disabled" + | "own_listing" + | "below_min_offer" + | "invalid_amount"; + +export type BulkOfferResult = { + created: Offer[]; + skipped: Array<{ listing_id: string; reason: BulkOfferSkipReason }>; +}; + +export const MAX_BULK_OFFER_TARGETS = 20; + +/** Fans a single buyer intent out into one `createOffer` per target listing, skipping (not throwing on) any listing that can't accept it. */ +export async function createBulkOffer( + buyer_wallet: string, + targets: BulkOfferTarget[], +): Promise { + const created: Offer[] = []; + const skipped: BulkOfferResult["skipped"] = []; + + for (const target of targets) { + if (!Number.isFinite(target.amount_phaselq) || target.amount_phaselq <= 0) { + skipped.push({ listing_id: target.listing_id, reason: "invalid_amount" }); + continue; + } + const listing = await getListing(target.listing_id); + if (!listing) { + skipped.push({ listing_id: target.listing_id, reason: "not_found" }); + continue; + } + if (listing.status !== "active") { + skipped.push({ listing_id: target.listing_id, reason: "inactive" }); + continue; + } + if (!listing.accepts_offers) { + skipped.push({ listing_id: target.listing_id, reason: "offers_disabled" }); + continue; + } + if (listing.seller_wallet === buyer_wallet) { + skipped.push({ listing_id: target.listing_id, reason: "own_listing" }); + continue; + } + if (listing.min_offer !== undefined && target.amount_phaselq < listing.min_offer) { + skipped.push({ listing_id: target.listing_id, reason: "below_min_offer" }); + continue; + } + const offer = await createOffer({ + listing_id: target.listing_id, + buyer_wallet, + amount_phaselq: target.amount_phaselq, + }); + created.push(offer); + } + + return { created, skipped }; +} + +// ── Issue #89 (phase-140): royalty enforcement on secondary sales ────────── +// +// Accepting an offer marked the listing sold but moved no value to the +// original creator on a resale — only the current seller and buyer were +// party to the transaction. This module computes the creator/seller split +// for a listing's `royalty_bps` (set at listing time, see `createListing`) +// and records it as a settlement-ready ledger line at accept time. It is a +// secondary sale whenever the seller isn't the original creator; a primary +// sale (creator selling their own mint) pays no royalty since seller and +// creator are the same wallet. +// +// Feature flag: phase-140 (NEXT_PUBLIC_FEATURE_PHASE_140 / FEATURE_PHASE_140) +// Rollback: unset the flag → `createListing`/`app/api/market` stop accepting +// `creator_wallet`/`royalty_bps`, and offer-accept stops computing +// a split (money continues to move 100% to the seller, pre-140 +// behavior). Existing `royalty_payouts` rows are historical record +// and are simply no longer written to; no migration to undo. + +export function isPhase140Enabled(): boolean { + return isFeatureEnabled("phase-140"); +} + +export function phase140RollbackNote(): string { + return flagRollbackNote("phase-140"); +} + +export type RoyaltySplit = { + is_secondary_sale: boolean; + royalty_bps: number; + royalty_amount_phaselq: number; + seller_amount_phaselq: number; +}; + +/** Pure computation: how a `sale_amount_phaselq` sale of `listing` splits between creator and seller. Returns a zero split for a primary sale or a listing with no royalty configured. */ +export function computeRoyaltySplit( + listing: Pick, + sale_amount_phaselq: number, +): RoyaltySplit { + const isSecondary = + !!listing.creator_wallet && listing.creator_wallet !== listing.seller_wallet; + const royaltyBps = isSecondary ? (listing.royalty_bps ?? 0) : 0; + const royaltyAmount = Math.round(sale_amount_phaselq * (royaltyBps / 10_000) * 1e7) / 1e7; + return { + is_secondary_sale: isSecondary, + royalty_bps: royaltyBps, + royalty_amount_phaselq: royaltyAmount, + seller_amount_phaselq: sale_amount_phaselq - royaltyAmount, + }; +} + +export type RoyaltyPayout = RoyaltySplit & { + id: string; + listing_id: string; + offer_id: string; + creator_wallet: string; + seller_wallet: string; + sale_amount_phaselq: number; + created_at: number; +}; + +type RoyaltyPayoutRow = { + id: string; + listing_id: string; + offer_id: string; + creator_wallet: string; + seller_wallet: string; + sale_amount_phaselq: number; + royalty_bps: number; + royalty_amount_phaselq: number; + seller_amount_phaselq: number; + created_at: number; +}; + +function rowToRoyaltyPayout(row: RoyaltyPayoutRow): RoyaltyPayout { + return { + id: row.id, + listing_id: row.listing_id, + offer_id: row.offer_id, + creator_wallet: row.creator_wallet, + seller_wallet: row.seller_wallet, + sale_amount_phaselq: row.sale_amount_phaselq, + is_secondary_sale: true, + royalty_bps: row.royalty_bps, + royalty_amount_phaselq: row.royalty_amount_phaselq, + seller_amount_phaselq: row.seller_amount_phaselq, + created_at: row.created_at, + }; +} + +/** Records a non-zero royalty split for an accepted offer. Callers only invoke this for a secondary sale with `royalty_bps > 0` — a primary sale has nothing to record. */ +export async function recordRoyaltyPayout( + listing: Pick, + offer_id: string, + split: RoyaltySplit, +): Promise { + if (!listing.creator_wallet) { + throw new MarketStoreValidationError("VALIDATION_FAILED", "listing has no creator_wallet"); + } + const payout: RoyaltyPayout = { + ...split, + id: randomUUID(), + listing_id: listing.id, + offer_id, + creator_wallet: listing.creator_wallet, + seller_wallet: listing.seller_wallet, + sale_amount_phaselq: split.royalty_amount_phaselq + split.seller_amount_phaselq, + created_at: Date.now(), + }; + getDb() + .prepare( + `INSERT INTO royalty_payouts + (id, listing_id, offer_id, creator_wallet, seller_wallet, + sale_amount_phaselq, royalty_bps, royalty_amount_phaselq, + seller_amount_phaselq, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + payout.id, + payout.listing_id, + payout.offer_id, + payout.creator_wallet, + payout.seller_wallet, + payout.sale_amount_phaselq, + payout.royalty_bps, + payout.royalty_amount_phaselq, + payout.seller_amount_phaselq, + payout.created_at, + ); + return payout; +} + +export async function getRoyaltyPayoutsForCreator( + creator_wallet: string, +): Promise { + const rows = getDb() + .prepare("SELECT * FROM royalty_payouts WHERE creator_wallet = ? ORDER BY created_at DESC") + .all(creator_wallet) as RoyaltyPayoutRow[]; + return rows.map(rowToRoyaltyPayout); +} + // ── Issue #103: Mute and Block Primitives (phase-85) ───────────────────────── type BlockedWallet = { wallet: string; blocked_at: number; reason?: string }; diff --git a/lib/notification-store.ts b/lib/notification-store.ts index 43ce81a5..aa7edd4d 100644 --- a/lib/notification-store.ts +++ b/lib/notification-store.ts @@ -20,6 +20,8 @@ export type NotificationType = | "offer_rejected" | "achievement_unlocked" | "content_takedown" + | "royalty_payout" + | "signal_reaction" export type Notification = { id: string diff --git a/lib/server-data-paths.ts b/lib/server-data-paths.ts index 1e1d1108..941474be 100644 --- a/lib/server-data-paths.ts +++ b/lib/server-data-paths.ts @@ -30,6 +30,7 @@ const FILES = { watchlists: "watchlists.json", questRegistry: "quest-registry.json", distributorHealth: "distributor-health.json", + sqliteDb: "phase.sqlite3", } as const export type ServerDataFile = keyof typeof FILES diff --git a/lib/signal-store.ts b/lib/signal-store.ts index 5a5707e3..3d9daf63 100644 --- a/lib/signal-store.ts +++ b/lib/signal-store.ts @@ -911,3 +911,336 @@ export function __resetCidGatewayCacheForTests(): void { cidResolutionCache.clear(); gatewayHealth.clear(); } + +// ── Issue #100 (phase-82): signal edit history with version diffing ──────── +// +// Edits to a signal's title/body were destructive — the prior text was +// simply overwritten with no audit trail. This module snapshots the +// pre-edit title/body into `signal_versions` before every edit, so history +// is a plain read (no reconstruction), and computes a word-level diff +// on demand between any two snapshots (or a snapshot and the live signal). +// +// Feature flag: phase-82 (NEXT_PUBLIC_FEATURE_PHASE_82 / FEATURE_PHASE_82) +// Rollback: unset the flag → `editSignal`/the history route throw/404; +// signals remain editable only through whatever pre-82 path +// existed (none, today). Existing `signal_versions` rows are +// historical record and are simply no longer appended to. + +export function isPhase82Enabled(): boolean { + return isFeatureEnabled("phase-82"); +} + +export function flag82RollbackNote(): string { + return "Rollback phase-82: unset NEXT_PUBLIC_FEATURE_PHASE_82 / FEATURE_PHASE_82 or set to 0/false and restart. editSignal() and the history route become unavailable; existing signal_versions rows remain on disk as an inert audit trail. No data migration to undo."; +} + +export class SignalEditError extends Error { + code: "FLAG_DISABLED" | "NOT_FOUND" | "FORBIDDEN" | "VALIDATION_FAILED"; + + constructor(code: SignalEditError["code"], message: string) { + super(message); + this.name = "SignalEditError"; + this.code = code; + } +} + +export type SignalVersion = { + id: string; + signal_id: string; + version: number; + title: string; + body: string; + edited_by: string; + edited_at: number; +}; + +type SignalVersionRow = { + id: string; + signal_id: string; + version: number; + title: string; + body: string; + edited_by: string; + edited_at: number; +}; + +function rowToSignalVersion(row: SignalVersionRow): SignalVersion { + return { + id: row.id, + signal_id: row.signal_id, + version: row.version, + title: row.title, + body: row.body, + edited_by: row.edited_by, + edited_at: row.edited_at, + }; +} + +export type DiffOp = { type: "equal" | "add" | "remove"; value: string }; + +/** + * Word-level LCS diff between two strings. Splits on runs of whitespace + * (kept as tokens so the reconstructed text is exact), then walks the + * standard dynamic-programming LCS table and merges adjacent same-type ops. + * O(n*m) in token count — signal title/body are bounded (see createSignal + * validation), so this stays well within an interactive request budget. + */ +export function diffWords(oldText: string, newText: string): DiffOp[] { + const a = oldText.split(/(\s+)/).filter((t) => t.length > 0); + const b = newText.split(/(\s+)/).filter((t) => t.length > 0); + const n = a.length; + const m = b.length; + + const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!); + } + } + + const ops: DiffOp[] = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + ops.push({ type: "equal", value: a[i]! }); + i++; + j++; + } else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) { + ops.push({ type: "remove", value: a[i]! }); + i++; + } else { + ops.push({ type: "add", value: b[j]! }); + j++; + } + } + while (i < n) { + ops.push({ type: "remove", value: a[i]! }); + i++; + } + while (j < m) { + ops.push({ type: "add", value: b[j]! }); + j++; + } + + const merged: DiffOp[] = []; + for (const op of ops) { + const last = merged[merged.length - 1]; + if (last && last.type === op.type) last.value += op.value; + else merged.push({ ...op }); + } + return merged; +} + +/** Snapshots the signal's current title/body as the next version, then applies the edit. Only the author may edit. */ +export async function editSignal( + signal_id: string, + wallet: string, + patch: { title?: string; body?: string }, +): Promise<{ signal: Signal; version: SignalVersion }> { + if (!isPhase82Enabled()) throw new SignalEditError("FLAG_DISABLED", "phase-82 disabled"); + + const signal = await getSignal(signal_id); + if (!signal) throw new SignalEditError("NOT_FOUND", "Signal not found"); + if (signal.author_wallet !== wallet) throw new SignalEditError("FORBIDDEN", "Only the author can edit this signal"); + + const title = patch.title?.trim(); + const body = patch.body?.trim(); + if (!title && !body) throw new SignalEditError("VALIDATION_FAILED", "Nothing to edit"); + if (title !== undefined && title.length === 0) throw new SignalEditError("VALIDATION_FAILED", "title cannot be empty"); + if (body !== undefined && body.length === 0) throw new SignalEditError("VALIDATION_FAILED", "body cannot be empty"); + + const db = getDb(); + const maxVersionRow = db + .prepare("SELECT COALESCE(MAX(version), 0) AS maxv FROM signal_versions WHERE signal_id = ?") + .get(signal_id) as { maxv: number }; + const nextVersion = maxVersionRow.maxv + 1; + + const version: SignalVersion = { + id: nanoid(10), + signal_id, + version: nextVersion, + title: signal.title, + body: signal.body, + edited_by: wallet, + edited_at: Date.now(), + }; + db.prepare( + `INSERT INTO signal_versions (id, signal_id, version, title, body, edited_by, edited_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run(version.id, version.signal_id, version.version, version.title, version.body, version.edited_by, version.edited_at); + + db.prepare("UPDATE signals SET title = COALESCE(?, title), body = COALESCE(?, body) WHERE id = ?").run( + title ?? null, + body ?? null, + signal_id, + ); + + const updated = await getSignal(signal_id); + if (!updated) throw new SignalEditError("NOT_FOUND", "Signal not found after edit"); + return { signal: updated, version }; +} + +export async function getSignalVersionHistory(signal_id: string): Promise { + const rows = getDb() + .prepare("SELECT * FROM signal_versions WHERE signal_id = ? ORDER BY version ASC") + .all(signal_id) as SignalVersionRow[]; + return rows.map(rowToSignalVersion); +} + +export type SignalVersionDiffEntry = { + from_version: number; + to_version: number | "current"; + edited_by: string; + edited_at: number; + title_diff: DiffOp[]; + body_diff: DiffOp[]; +}; + +/** Full history plus a word-diff between every consecutive pair of snapshots, and from the latest snapshot to the live signal. */ +export async function getSignalEditHistory( + signal_id: string, +): Promise<{ signal: Signal; versions: SignalVersion[]; diffs: SignalVersionDiffEntry[] } | null> { + const signal = await getSignal(signal_id); + if (!signal) return null; + const versions = await getSignalVersionHistory(signal_id); + + const diffs: SignalVersionDiffEntry[] = []; + for (let i = 0; i < versions.length; i++) { + const from = versions[i]!; + const to = versions[i + 1]; + diffs.push({ + from_version: from.version, + to_version: to ? to.version : "current", + edited_by: (to ?? { edited_by: signal.author_wallet }).edited_by, + edited_at: to ? to.edited_at : versions[versions.length - 1]!.edited_at, + title_diff: diffWords(from.title, to ? to.title : signal.title), + body_diff: diffWords(from.body, to ? to.body : signal.body), + }); + } + + return { signal, versions, diffs }; +} + +// ── Issue #101 (phase-83): emoji-reaction aggregation with rate limits ───── +// +// Signals only had a binary upvote. This module adds a small curated set of +// emoji reactions, toggle-able per (signal, wallet, emoji), with per-wallet +// rate limiting so a single wallet can't hammer the endpoint to spam +// notifications or inflate counts. Aggregation is a GROUP BY over +// `signal_reactions`; "did this wallet react" is a per-viewer lookup layered +// on top so the summary works for both an authenticated viewer and an +// anonymous read. +// +// Feature flag: phase-83 (NEXT_PUBLIC_FEATURE_PHASE_83 / FEATURE_PHASE_83) +// Rollback: unset the flag → the reactions route 404s and +// `toggleSignalReaction` throws; existing `signal_reactions` rows +// remain on disk (no migration to undo) but stop being written to. + +export function isPhase83Enabled(): boolean { + return isFeatureEnabled("phase-83"); +} + +export function flag83RollbackNote(): string { + return "Rollback phase-83: unset NEXT_PUBLIC_FEATURE_PHASE_83 / FEATURE_PHASE_83 or set to 0/false and restart. Reaction reads/writes become unavailable; existing signal_reactions rows remain on disk as inert history. No data migration to undo."; +} + +export const REACTION_EMOJI = ["👍", "❤️", "🔥", "😂", "😮", "😢"] as const; +export type ReactionEmoji = (typeof REACTION_EMOJI)[number]; + +export class SignalReactionError extends Error { + code: "FLAG_DISABLED" | "VALIDATION_FAILED" | "RATE_LIMITED" | "NOT_FOUND"; + retryAfterMs?: number; + + constructor(code: SignalReactionError["code"], message: string, retryAfterMs?: number) { + super(message); + this.name = "SignalReactionError"; + this.code = code; + this.retryAfterMs = retryAfterMs; + } +} + +const REACTION_RATE_LIMIT = 20; +const REACTION_RATE_WINDOW_MS = 60_000; +const reactionRateBuckets = new Map(); + +function consumeReactionRateLimit(wallet: string, now: number): { allowed: boolean; retryAfterMs: number } { + const bucket = reactionRateBuckets.get(wallet); + if (!bucket || bucket.resetAt <= now) { + reactionRateBuckets.set(wallet, { used: 1, resetAt: now + REACTION_RATE_WINDOW_MS }); + return { allowed: true, retryAfterMs: 0 }; + } + if (bucket.used >= REACTION_RATE_LIMIT) { + return { allowed: false, retryAfterMs: bucket.resetAt - now }; + } + bucket.used += 1; + return { allowed: true, retryAfterMs: 0 }; +} + +/** Test/ops hook to reset process-local phase-83 rate-limit state. */ +export function __resetSignalReactionRateLimitForTests(): void { + reactionRateBuckets.clear(); +} + +export type SignalReactionSummary = Array<{ emoji: ReactionEmoji; count: number; reacted: boolean }>; + +export async function getSignalReactionSummary( + signal_id: string, + viewer_wallet?: string, +): Promise { + const db = getDb(); + const counts = db + .prepare("SELECT emoji, COUNT(*) AS count FROM signal_reactions WHERE signal_id = ? GROUP BY emoji") + .all(signal_id) as Array<{ emoji: string; count: number }>; + const mine = viewer_wallet + ? new Set( + (db.prepare("SELECT emoji FROM signal_reactions WHERE signal_id = ? AND wallet = ?").all(signal_id, viewer_wallet) as Array<{ emoji: string }>).map( + (r) => r.emoji, + ), + ) + : new Set(); + + return REACTION_EMOJI.map((emoji) => ({ + emoji, + count: counts.find((c) => c.emoji === emoji)?.count ?? 0, + reacted: mine.has(emoji), + })); +} + +/** Toggles a wallet's reaction on a signal (add if absent, remove if present), subject to a per-wallet rate limit. */ +export async function toggleSignalReaction( + signal_id: string, + wallet: string, + emoji: string, +): Promise<{ toggled: "added" | "removed"; summary: SignalReactionSummary }> { + if (!isPhase83Enabled()) throw new SignalReactionError("FLAG_DISABLED", "phase-83 disabled"); + if (!(REACTION_EMOJI as readonly string[]).includes(emoji)) { + throw new SignalReactionError("VALIDATION_FAILED", `Unsupported emoji. Allowed: ${REACTION_EMOJI.join(" ")}`); + } + + const signal = await getSignal(signal_id); + if (!signal) throw new SignalReactionError("NOT_FOUND", "Signal not found"); + + const now = Date.now(); + const rl = consumeReactionRateLimit(wallet, now); + if (!rl.allowed) throw new SignalReactionError("RATE_LIMITED", "Too many reactions, slow down", rl.retryAfterMs); + + const db = getDb(); + const existing = db + .prepare("SELECT id FROM signal_reactions WHERE signal_id = ? AND wallet = ? AND emoji = ?") + .get(signal_id, wallet, emoji) as { id: string } | undefined; + + let toggled: "added" | "removed"; + if (existing) { + db.prepare("DELETE FROM signal_reactions WHERE id = ?").run(existing.id); + toggled = "removed"; + } else { + db.prepare( + "INSERT INTO signal_reactions (id, signal_id, wallet, emoji, created_at) VALUES (?, ?, ?, ?, ?)", + ).run(nanoid(10), signal_id, wallet, emoji, now); + toggled = "added"; + } + + const summary = await getSignalReactionSummary(signal_id, wallet); + return { toggled, summary }; +} diff --git a/lib/sqlite-db.ts b/lib/sqlite-db.ts index 7f9cc8e7..b021641e 100644 --- a/lib/sqlite-db.ts +++ b/lib/sqlite-db.ts @@ -101,8 +101,78 @@ CREATE TABLE IF NOT EXISTS signal_replies ( ); CREATE INDEX IF NOT EXISTS idx_replies_signal_created ON signal_replies (signal_id, created_at ASC); + +-- Issue #100 (phase-82): word-diffable snapshot of a signal's title/body taken +-- immediately before each edit is applied, so history is reconstructible +-- without re-deriving anything from the current row. +CREATE TABLE IF NOT EXISTS signal_versions ( + id TEXT PRIMARY KEY, + signal_id TEXT NOT NULL REFERENCES signals(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + edited_by TEXT NOT NULL, + edited_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_signal_versions_signal + ON signal_versions (signal_id, version DESC); + +-- Issue #101 (phase-83): one row per (signal, wallet, emoji) toggle so a +-- reaction count is a GROUP BY and "did I react" is a single lookup. +CREATE TABLE IF NOT EXISTS signal_reactions ( + id TEXT PRIMARY KEY, + signal_id TEXT NOT NULL REFERENCES signals(id) ON DELETE CASCADE, + wallet TEXT NOT NULL, + emoji TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE (signal_id, wallet, emoji) +); +CREATE INDEX IF NOT EXISTS idx_signal_reactions_signal + ON signal_reactions (signal_id); + +-- Issue #89 (phase-140): one row per accepted secondary sale, recording the +-- creator/seller split applied at settlement. +CREATE TABLE IF NOT EXISTS royalty_payouts ( + id TEXT PRIMARY KEY, + listing_id TEXT NOT NULL, + offer_id TEXT NOT NULL, + creator_wallet TEXT NOT NULL, + seller_wallet TEXT NOT NULL, + sale_amount_phaselq REAL NOT NULL, + royalty_bps INTEGER NOT NULL, + royalty_amount_phaselq REAL NOT NULL, + seller_amount_phaselq REAL NOT NULL, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_royalty_payouts_creator + ON royalty_payouts (creator_wallet, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_royalty_payouts_listing + ON royalty_payouts (listing_id); `; +// Issue #89 (phase-140): `listings` predates the creator/royalty concept, so +// the columns are added additively to the existing table rather than baked +// into CREATE TABLE — that would no-op on a database file created before this +// 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: "creator_wallet", ddl: "creator_wallet TEXT" }, + { name: "royalty_bps", ddl: "royalty_bps INTEGER" }, +]; + +function ensureListingColumns(conn: DatabaseSync): void { + const existing = new Set( + (conn.prepare("PRAGMA table_info(listings)").all() as Array<{ name: string }>).map( + (row) => row.name, + ), + ); + for (const column of LISTING_COLUMNS) { + if (!existing.has(column.name)) { + conn.exec(`ALTER TABLE listings ADD COLUMN ${column.ddl};`); + } + } +} + /** * Returns the process-wide SQLite connection, creating and migrating the * schema on first use. Safe to call from any request handler; `node:sqlite` @@ -118,6 +188,7 @@ export function getDb(): DatabaseSync { db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA foreign_keys = ON;"); db.exec(SCHEMA); + ensureListingColumns(db); return db; }