From 57d4db8c64f18d03c4e822740850d252c0b6994b Mon Sep 17 00:00:00 2001 From: tnrdd Date: Wed, 1 Jul 2026 10:26:09 +0200 Subject: [PATCH 01/27] feat(streaming): streaming pricing strategy frontend StreamModal (permit + single-tx host.batchCall), streaming boards listing + leaderboard pages ranked by effectiveRate, useStreamingBoards/useStreamingMarkees hooks, the superfluid encoding helper + StreamingLeaderboardABI, gated create / raise-funding entries, and the keeper cron route. The whole feature self-gates on NEXT_PUBLIC_STREAMING_FACTORY (STREAMING_ENABLED) and stays hidden until set. Fork-test harnesses under scripts/. --- .../app/api/cron/streaming-keeper/README.md | 34 ++ .../app/api/cron/streaming-keeper/route.ts | 85 +++ frontend/app/create-a-markee/page.tsx | 52 +- .../platforms/streaming/[address]/page.tsx | 463 ++++++++++++++++ .../ecosystem/platforms/streaming/page.tsx | 168 ++++++ frontend/app/page.tsx | 1 - frontend/app/raise-funding/page.tsx | 13 +- frontend/components/modals/StreamModal.tsx | 445 ++++++++++++++++ frontend/lib/contracts/abis.ts | 39 ++ frontend/lib/contracts/addresses.ts | 8 +- frontend/lib/contracts/useMarkees.ts | 4 +- frontend/lib/contracts/useStreamingBoards.ts | 100 ++++ frontend/lib/contracts/useStreamingMarkees.ts | 115 ++++ frontend/lib/streaming/keeper.ts | 167 ++++++ frontend/lib/superfluid/streaming.ts | 272 ++++++++++ frontend/package-lock.json | 504 ++++++++++++++++++ frontend/package.json | 1 + frontend/scripts/keeper-fork.mts | 50 ++ frontend/scripts/keeper-logic-check.mts | 101 ++++ frontend/scripts/open-stream-fork.mts | 86 +++ frontend/scripts/verify-streaming-read.mts | 65 +++ 21 files changed, 2755 insertions(+), 18 deletions(-) create mode 100644 frontend/app/api/cron/streaming-keeper/README.md create mode 100644 frontend/app/api/cron/streaming-keeper/route.ts create mode 100644 frontend/app/ecosystem/platforms/streaming/[address]/page.tsx create mode 100644 frontend/app/ecosystem/platforms/streaming/page.tsx create mode 100644 frontend/components/modals/StreamModal.tsx create mode 100644 frontend/lib/contracts/useStreamingBoards.ts create mode 100644 frontend/lib/contracts/useStreamingMarkees.ts create mode 100644 frontend/lib/streaming/keeper.ts create mode 100644 frontend/lib/superfluid/streaming.ts create mode 100644 frontend/scripts/keeper-fork.mts create mode 100644 frontend/scripts/keeper-logic-check.mts create mode 100644 frontend/scripts/open-stream-fork.mts create mode 100644 frontend/scripts/verify-streaming-read.mts diff --git a/frontend/app/api/cron/streaming-keeper/README.md b/frontend/app/api/cron/streaming-keeper/README.md new file mode 100644 index 00000000..1fd53530 --- /dev/null +++ b/frontend/app/api/cron/streaming-keeper/README.md @@ -0,0 +1,34 @@ +# Streaming keeper + +This route heals streaming boards: it calls `claimTop` when a board's live #1 (`getTopMarkees[0]`, +ranked by `effectiveRate`) has drifted from the enforced `topMarkee` (a decay or a stream decrease the +SuperApp inflow callbacks can't auto-heal), and `settle` to flush each backer's accrued RevNet share. +Both calls are permissionless and money-safe, so the signer is a throwaway gas-funded hot wallet with no +on-chain privileges. + +## Trigger + +The route is just `runKeeper()` behind an authenticated HTTP call, so testing never needs a scheduler +(see below). In production an automated job calls it on a schedule — not wired yet. It must be a +**periodic poll**, not an event/alert trigger: the decay/decrease that makes a title stale fires no +transaction and no event, so only a poll catches it. + +## Auth & env + +The route authorizes a `Bearer ` or `x-keeper-secret: ` against `KEEPER_TRIGGER_SECRET`. + +| Var | Purpose | +|---|---| +| `NEXT_PUBLIC_STREAMING_FACTORY` | Gates the whole feature. Until set, the route no-ops (`skipped: streaming disabled`). | +| `KEEPER_TRIGGER_SECRET` | Shared secret the trigger sends (Bearer or `x-keeper-secret`). | +| `KEEPER_PRIVATE_KEY` | Gas-funded hot wallet that signs `claimTop`/`settle`. | +| `KEEPER_RPC_URL` | Base RPC (falls back to `ALCHEMY_BASE_URL`). | +| `KEEPER_FROM_BLOCK` | Optional. `BackerUpdated` log-scan start for `settle` (bounds the lookback). | + +## Testing without the scheduler + +- `?dryRun=1` reads + plans but signs nothing (no `KEEPER_PRIVATE_KEY` needed): + `curl -H 'Authorization: Bearer ' 'https:///api/cron/streaming-keeper?dryRun=1'` +- Decision logic: `npx tsx scripts/keeper-logic-check.mts` (deterministic, mock client). +- Against a fork board: `FACTORY=0x.. LIVE=1 npx tsx scripts/keeper-fork.mts`. +- On-chain heal: `test/StreamingLeaderboard.t.sol::test_getTopMarkees_reflectsLiveRanking_beforeClaimTopHeals`. diff --git a/frontend/app/api/cron/streaming-keeper/route.ts b/frontend/app/api/cron/streaming-keeper/route.ts new file mode 100644 index 00000000..d362c298 --- /dev/null +++ b/frontend/app/api/cron/streaming-keeper/route.ts @@ -0,0 +1,85 @@ +/** + * Streaming keeper — heals on-chain ranking lag and flushes RevNet settlement. + * + * An automated job calls it on a schedule in production (a periodic poll, not an event trigger: + * the decay/decrease that staled the title fires no tx and no event). It POSTs with the secret: + * + * POST /api/cron/streaming-keeper + * Authorization: Bearer + * + * For each streaming board the factory knows about it calls claimTop when the live #1 + * (getTopMarkees[0]) has drifted from the enforced topMarkee (a decay/decrease the inflow + * callbacks can't auto-heal), and settle() to flush each backer's accrued RevNet share. Both + * are permissionless and money-safe, so the signer is a throwaway gas-funded hot wallet. + * + * Env: KEEPER_TRIGGER_SECRET (auth), KEEPER_PRIVATE_KEY (hot wallet), KEEPER_RPC_URL + * (falls back to ALCHEMY_BASE_URL), optional KEEPER_FROM_BLOCK (settle log-scan start). + * Inert unless NEXT_PUBLIC_STREAMING_FACTORY is set (STREAMING_ENABLED). + */ + +import { NextRequest, NextResponse } from 'next/server' +import { createPublicClient, createWalletClient, http, type Address } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { base } from 'viem/chains' +import { runKeeper } from '@/lib/streaming/keeper' +import { STREAMING_FACTORY, STREAMING_ENABLED } from '@/lib/contracts/addresses' + +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +function authorized(req: NextRequest): boolean { + const secret = process.env.KEEPER_TRIGGER_SECRET + if (!secret) return false + const bearer = req.headers.get('authorization')?.replace(/^Bearer\s+/i, '') + return bearer === secret || req.headers.get('x-keeper-secret') === secret +} + +async function handle(req: NextRequest) { + if (!authorized(req)) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + if (!STREAMING_ENABLED) { + return NextResponse.json({ ok: true, skipped: 'streaming disabled' }) + } + + const rpc = process.env.KEEPER_RPC_URL ?? process.env.ALCHEMY_BASE_URL + if (!rpc) return NextResponse.json({ error: 'no rpc configured' }, { status: 500 }) + + const key = process.env.KEEPER_PRIVATE_KEY as `0x${string}` | undefined + const dryRun = req.nextUrl.searchParams.get('dryRun') === '1' + + const publicClient = createPublicClient({ chain: base, transport: http(rpc) }) + let walletClient: ReturnType | undefined + let account: Address | undefined + + if (!dryRun) { + if (!key) return NextResponse.json({ error: 'no signer configured' }, { status: 500 }) + const signer = privateKeyToAccount(key) + account = signer.address + walletClient = createWalletClient({ account: signer, chain: base, transport: http(rpc) }) + } + + try { + const report = await runKeeper({ + publicClient, + walletClient, + account, + factory: STREAMING_FACTORY as Address, + fromBlock: process.env.KEEPER_FROM_BLOCK ? BigInt(process.env.KEEPER_FROM_BLOCK) : 0n, + log: (m) => console.log('[streaming-keeper]', m), + }) + return NextResponse.json({ ok: true, dryRun, ...report }) + } catch (e) { + const detail = e instanceof Error ? e.message.split('\n')[0] : String(e) + console.error('[streaming-keeper] run failed:', detail) + return NextResponse.json({ error: detail }, { status: 500 }) + } +} + +export async function GET(req: NextRequest) { + return handle(req) +} + +export async function POST(req: NextRequest) { + return handle(req) +} diff --git a/frontend/app/create-a-markee/page.tsx b/frontend/app/create-a-markee/page.tsx index 6e2665dd..f981ba85 100644 --- a/frontend/app/create-a-markee/page.tsx +++ b/frontend/app/create-a-markee/page.tsx @@ -8,6 +8,7 @@ import Link from 'next/link' import { Check, Loader2 } from 'lucide-react' import { Header } from '@/components/layout/Header' import { IntegrationModal } from '@/components/modals/IntegrationModal' +import { STREAMING_FACTORY, STREAMING_ENABLED } from '@/lib/contracts/addresses' const C = { bg: '#060A2A', bg2: '#0A0F3D', @@ -16,10 +17,13 @@ const C = { border: 'rgba(138,143,191,0.2)', borderHover: 'rgba(248,151,254,0.4)', } -const FACTORIES = { - openinternet: '0xFD488A0fE8D4Fa99B4A6016EA9C49a860A553F7c' as const, - github: '0xdF2A716452a3960619cDdDCDe4E10eACcFFDa0A2' as const, - superfluid: '0xC497187AAa35C26b0008B43C10A6F6300b7eBcad' as const, +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const + +const FACTORIES: Record = { + openinternet: '0xFD488A0fE8D4Fa99B4A6016EA9C49a860A553F7c', + github: '0xdF2A716452a3960619cDdDCDe4E10eACcFFDa0A2', + superfluid: '0xC497187AAa35C26b0008B43C10A6F6300b7eBcad', + streaming: (STREAMING_FACTORY || ZERO_ADDRESS) as `0x${string}`, } const FACTORY_ABI = [{ @@ -36,7 +40,7 @@ const FACTORY_ABI = [{ type: 'function', }] as const -type PlatformKey = 'openinternet' | 'github' | 'superfluid' +type PlatformKey = 'openinternet' | 'github' | 'superfluid' | 'streaming' interface Platform { key: PlatformKey @@ -74,6 +78,15 @@ const PLATFORMS: Platform[] = [ summary: 'Create a Markee sign for your Superfluid project and earn SUP rewards for every message bought.', steps: ['Set up your Markee', 'Deploy Markee', 'Activate'], }, + // Gated on a configured streaming factory (NEXT_PUBLIC_STREAMING_FACTORY); hidden until deployed. + ...(STREAMING_ENABLED ? [{ + key: 'streaming', + name: 'Streaming Board', + tagline: 'Stream to hold #1', + color: C.blue, + summary: 'Create a board where backers stream ETH by the second to hold the top message. Highest active rate wins.', + steps: ['Set up your board', 'Deploy board', 'Activate'], + } satisfies Platform] : []), ] type StepKey = 'choose' | 'connect' | 'setup' | 'review' | 'activate' @@ -157,7 +170,7 @@ function StepShell({ title, sub, children, onBack, onNext, nextLabel, nextDisabl // ── ChoosePlatform ────────────────────────────────────────────────────────── function ChoosePlatform({ selected, onSelect }: { selected: PlatformKey | null; onSelect: (k: PlatformKey) => void }) { - const icons: Record = { openinternet: 'globe', github: 'github', superfluid: 'zap' } + const icons: Record = { openinternet: 'globe', github: 'github', superfluid: 'zap', streaming: 'zap' } return (
{PLATFORMS.map(p => { @@ -357,6 +370,12 @@ function WebsiteSetupFields({ values, setValue, platformKey }: { setValue('projectName', e.target.value)} placeholder="My Stream" style={fieldBase} /> )} + {platformKey === 'streaming' && ( + + )}