diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c3fb51a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Dependencies +node_modules/ + +# Vercel +.vercel + +# Foundry build/deploy artifacts (repo-root only — anchored so frontend/lib source is never ignored). +# lib/ is deliberately not ignored: Foundry deps live there as tracked submodules (see .gitmodules). +/out/ +/cache/ +/broadcast/ +/foundry.lock + +# Local-only design docs and Claude Code workspace +/.claude/ + +# Local-only streaming dev/testnet scripts (kept out of the repo) +/frontend/scripts/ diff --git a/frontend/app/account/page.tsx b/frontend/app/account/page.tsx index f8abdcc6..3169a3da 100644 --- a/frontend/app/account/page.tsx +++ b/frontend/app/account/page.tsx @@ -12,6 +12,7 @@ import { Header } from '@/components/layout/Header' import { Footer } from '@/components/layout/Footer' import { ConnectButton } from '@/components/wallet/ConnectButton' import { HeroBackground } from '@/components/backgrounds/HeroBackground' +import { StrategyBadge } from '@/components/StrategyBadge' // ── Design tokens ───────────────────────────────────────────────────────────── const MONO = "var(--font-jetbrains-mono), 'JetBrains Mono', monospace" @@ -40,6 +41,7 @@ interface BaseLeaderboard { topMessageOwner?: string | null topFundsAddedRaw: string minimumPriceRaw?: string + strategy?: 'fixed' | 'streaming' } interface SuperfluidLeaderboard extends BaseLeaderboard { platform: 'superfluid' } interface LinkedFile { @@ -174,6 +176,12 @@ function detailUrl(lb: AnyLeaderboard) { return `/markee/${lb.address}` } +// The website verify/integrate/edit management flows only exist for fixed-price website boards; +// streaming boards share the 'website' placement but are managed from their board page. +function isFixedWebsiteBoard(lb: AnyLeaderboard): lb is WebsiteLeaderboard { + return lb.platform === 'website' && lb.strategy !== 'streaming' +} + // ── Platform icon ───────────────────────────────────────────────────────────── function PlatIcon({ lb, size = 20 }: { lb: AnyLeaderboard; size?: number }) { if (lb.platform === 'github') return @@ -317,7 +325,10 @@ function MarkeeCardDash({ lb, archived, onIntegrate, onVerify, onEdit, onArchive
-
{lb.name}
+
+
{lb.name}
+ {lb.strategy === 'streaming' && } +
{sub}
@@ -382,7 +393,7 @@ const SETUP_COLS = '180px 165px 1fr 220px' function SetupStatusPill({ lb }: { lb: AnyLeaderboard }) { const hasFunds = BigInt(lb.topFundsAddedRaw ?? '0') > 0n - const missingVerify = lb.platform === 'website' && ((lb as WebsiteLeaderboard).verifiedUrls?.length ?? 0) === 0 + const missingVerify = isFixedWebsiteBoard(lb) && (lb.verifiedUrls?.length ?? 0) === 0 const needsIntegration = hasFunds && missingVerify const col = needsIntegration ? BLUE : MUTED const label = needsIntegration ? 'Integration Needed' : 'No Messages Yet' @@ -409,7 +420,7 @@ function SetupTable({ markees, onIntegrate, onVerify, onArchive }: { {markees.map(lb => { const hasFunds = BigInt(lb.topFundsAddedRaw ?? '0') > 0n - const missingVerify = lb.platform === 'website' && ((lb as WebsiteLeaderboard).verifiedUrls?.length ?? 0) === 0 + const missingVerify = isFixedWebsiteBoard(lb) && (lb.verifiedUrls?.length ?? 0) === 0 // Awaiting Integration: has funds but no verified URL → Verify Integration // Awaiting Activation: no funds → Buy First Message (all platforms, same blue style) @@ -800,6 +811,7 @@ export default function AccountPage() { const [superfluidBoards, setSuperfluidBoards] = useState([]) const [githubBoards, setGithubBoards] = useState([]) const [websiteBoards, setWebsiteBoards] = useState([]) + const [streamingBoards, setStreamingBoards] = useState([]) const [isLoading, setIsLoading] = useState(false) // Messages @@ -820,10 +832,11 @@ export default function AccountPage() { const fetchAll = useCallback(async (addr: string) => { setIsLoading(true) try { - const [sfRes, ghRes, oiRes] = await Promise.all([ + const [sfRes, ghRes, oiRes, strRes] = await Promise.all([ fetch('/api/superfluid/leaderboards?bust=1', { cache: 'no-store' }), fetch('/api/github/leaderboards?bust=1', { cache: 'no-store' }), fetch('/api/openinternet/leaderboards?bust=1', { cache: 'no-store' }), + fetch('/api/streaming/leaderboards?bust=1', { cache: 'no-store' }), ]) if (sfRes.ok) { const data = await sfRes.json() @@ -853,6 +866,22 @@ export default function AccountPage() { .map((lb: any) => ({ ...lb, platform: 'website' as const })) ) } + if (strRes.ok) { + const data = await strRes.json() + setStreamingBoards( + (data.leaderboards ?? []) + .filter((lb: any) => lb.admin && lb.admin.toLowerCase() === addr.toLowerCase()) + .map((lb: any): AnyLeaderboard => { + if (lb.platform === 'github') { + return { ...lb, platform: 'github', repoFullName: null, repoAvatarUrl: null, repoHtmlUrl: null, filePath: null, linkedFiles: [] } + } + if (lb.platform === 'superfluid') { + return { ...lb, platform: 'superfluid' } + } + return { ...lb, platform: 'website', creator: lb.admin, logoUrl: null, siteUrl: null, verifiedUrl: null, verifiedUrls: [], status: 'pending', isLegacy: false } + }) + ) + } } catch (err) { console.error('[account] fetch error:', err) } finally { @@ -932,13 +961,13 @@ export default function AccountPage() { // Derived board lists const allBoards = useMemo(() => - [...superfluidBoards, ...githubBoards, ...websiteBoards].sort((a, b) => { + [...superfluidBoards, ...githubBoards, ...websiteBoards, ...streamingBoards].sort((a, b) => { const d = BigInt(b.totalFundsRaw) - BigInt(a.totalFundsRaw) return d > 0n ? 1 : d < 0n ? -1 : 0 - }), [superfluidBoards, githubBoards, websiteBoards]) + }), [superfluidBoards, githubBoards, websiteBoards, streamingBoards]) const awaitingVerification = useMemo(() => - allBoards.filter(lb => lb.platform === 'website' && BigInt(lb.topFundsAddedRaw ?? '0') > 0n && ((lb as WebsiteLeaderboard).verifiedUrls?.length ?? 0) === 0) as WebsiteLeaderboard[], [allBoards]) + allBoards.filter(lb => isFixedWebsiteBoard(lb) && BigInt(lb.topFundsAddedRaw ?? '0') > 0n && (lb.verifiedUrls?.length ?? 0) === 0) as WebsiteLeaderboard[], [allBoards]) const awaitingVerificationAddrs = useMemo(() => new Set(awaitingVerification.map(lb => lb.address)), [awaitingVerification]) const activeBoards = useMemo(() => @@ -962,7 +991,7 @@ export default function AccountPage() { // Manage a leaderboard (from active table) — opens the manage modal const handleManage = useCallback((lb: AnyLeaderboard) => { - if (lb.platform === 'website') setManageTarget(lb) + if (isFixedWebsiteBoard(lb)) setManageTarget(lb) else window.open(detailUrl(lb), '_self') }, []) diff --git a/frontend/app/api/account/funded/route.ts b/frontend/app/api/account/funded/route.ts index f79fb4fb..99b6f17b 100644 --- a/frontend/app/api/account/funded/route.ts +++ b/frontend/app/api/account/funded/route.ts @@ -3,7 +3,11 @@ import { NextResponse } from 'next/server' import { createPublicClient, http, parseAbiItem } from 'viem' import { base } from 'viem/chains' +import { internalOrigin, internalHeaders } from '@/lib/internal-origin' import { BASE_MARKEE_EVENTS_FROM_BLOCK } from '@/lib/contracts/addresses' +import { StreamingLeaderboardABI } from '@/lib/contracts/abis' +import { STREAMING_BASE } from '@/lib/superfluid/streaming' +import { fetchBackerPositions, type BackerPosition } from '@/lib/streaming/subgraph' export const dynamic = 'force-dynamic' @@ -35,7 +39,7 @@ const FUNDS_ADDED_EVENT = parseAbiItem( function getClient() { return createPublicClient({ chain: base, - transport: http(process.env.ALCHEMY_BASE_URL ?? 'https://mainnet.base.org', { + transport: http(process.env.NEXT_PUBLIC_BASE_RPC_URL || process.env.ALCHEMY_BASE_URL || 'https://mainnet.base.org', { fetchOptions: { cache: 'no-store' }, }), }) @@ -54,34 +58,45 @@ async function chunkedMulticall( return results } -export async function GET(request: Request) { - const { searchParams, origin } = new URL(request.url) - const owner = searchParams.get('owner')?.toLowerCase() - if (!owner || !/^0x[0-9a-f]{40}$/.test(owner)) { - return NextResponse.json({ error: 'Invalid owner' }, { status: 400 }) - } +interface FundedMessage { + address: string + message: string + name: string + totalFundsAdded: string + totalContributed: string + strategyId: string + strategyName: string + isTop: boolean + topFundsRaw: string +} + +type Board = { + address: string + name: string + topMarkeeAddress: string | null + topFundsAddedRaw: string +} - // Fetch all platform leaderboards +// Lump-sum boards: a backer is whoever emitted FundsAdded on a markee. +async function fixedFunded( + client: ReturnType, + owner: string, + origin: string, + headers: HeadersInit, +): Promise { const [sfData, ghData, oiData] = await Promise.all([ - fetch(`${origin}/api/superfluid/leaderboards`).then(r => r.ok ? r.json() : null).catch(() => null), - fetch(`${origin}/api/github/leaderboards`).then(r => r.ok ? r.json() : null).catch(() => null), - fetch(`${origin}/api/openinternet/leaderboards`).then(r => r.ok ? r.json() : null).catch(() => null), + fetch(`${origin}/api/superfluid/leaderboards`, { headers }).then(r => r.ok ? r.json() : null).catch(() => null), + fetch(`${origin}/api/github/leaderboards`, { headers }).then(r => r.ok ? r.json() : null).catch(() => null), + fetch(`${origin}/api/openinternet/leaderboards`, { headers }).then(r => r.ok ? r.json() : null).catch(() => null), ]) - const leaderboards: Array<{ - address: string - name: string - topMarkeeAddress: string | null - topFundsAddedRaw: string - }> = [ + const leaderboards: Board[] = [ ...(sfData?.leaderboards ?? []), ...(ghData?.leaderboards ?? []), ...(oiData?.leaderboards ?? []), ].filter((lb: any) => lb.markeeCount > 0) - if (leaderboards.length === 0) return NextResponse.json({ funded: [] }) - - const client = getClient() + if (leaderboards.length === 0) return [] // Get all markee addresses from every leaderboard const markeeListResults = await chunkedMulticall( @@ -105,7 +120,7 @@ export async function GET(request: Request) { } } - if (entries.length === 0) return NextResponse.json({ funded: [] }) + if (entries.length === 0) return [] const allMarkeeAddresses = [...new Set(entries.map(e => e.markeeAddress))] @@ -118,7 +133,7 @@ export async function GET(request: Request) { toBlock: 'latest', }).catch(() => []) - if (fundsAddedLogs.length === 0) return NextResponse.json({ funded: [] }) + if (fundsAddedLogs.length === 0) return [] // Aggregate total contributed by user per markee const markeeContribs = new Map() @@ -145,7 +160,7 @@ export async function GET(request: Request) { return markeeOwner !== owner }) - if (externalAddrs.length === 0) return NextResponse.json({ funded: [] }) + if (externalAddrs.length === 0) return [] // Fetch message, name, totalFundsAdded for externally-funded markees const detailResults = await chunkedMulticall( @@ -164,7 +179,7 @@ export async function GET(request: Request) { if (!markeeToLb.has(key)) markeeToLb.set(key, leaderboards[e.lbIndex]) } - const funded = externalAddrs.map((addr, i) => { + return externalAddrs.map((addr, i) => { const b = i * 3 const message = (detailResults[b]?.result as string) ?? '' const name = (detailResults[b + 1]?.result as string) ?? '' @@ -183,6 +198,96 @@ export async function GET(request: Request) { topFundsRaw: lb?.topFundsAddedRaw ?? '0', } }) +} + +// Streaming boards: a backer holds one position per board, recorded on-chain as backerMarkee. +// There are no FundsAdded logs to scan, and the amount put in is the net ETHx streamed. +async function streamingFunded( + client: ReturnType, + owner: string, + origin: string, + headers: HeadersInit, +): Promise { + const data = await fetch(`${origin}/api/streaming/leaderboards`, { headers }) + .then(r => r.ok ? r.json() : null).catch(() => null) + + const boards: Board[] = (data?.leaderboards ?? []).filter((lb: any) => lb.markeeCount > 0) + if (boards.length === 0) return [] + + const backedResults = await chunkedMulticall( + client, + boards.map(lb => ({ + address: lb.address as `0x${string}`, + abi: StreamingLeaderboardABI, + functionName: 'backerMarkee' as const, + args: [owner as `0x${string}`] as const, + })), + ) + + const backed = boards + .map((lb, i) => ({ lb, markee: (backedResults[i]?.result as string | undefined)?.toLowerCase() })) + .filter((b): b is { lb: Board; markee: string } => + !!b.markee && b.markee !== '0x0000000000000000000000000000000000000000') + + if (backed.length === 0) return [] + + const [detailResults, positions] = await Promise.all([ + chunkedMulticall( + client, + backed.flatMap(b => [ + { address: b.markee as `0x${string}`, abi: MARKEE_ABI, functionName: 'message' as const }, + { address: b.markee as `0x${string}`, abi: MARKEE_ABI, functionName: 'name' as const }, + { address: b.markee as `0x${string}`, abi: MARKEE_ABI, functionName: 'owner' as const }, + { address: b.markee as `0x${string}`, abi: MARKEE_ABI, functionName: 'totalFundsAdded' as const }, + ]), + ), + fetchBackerPositions( + owner, + backed.map(b => b.lb.address), + STREAMING_BASE.ethx, + BigInt(Math.floor(Date.now() / 1000)), + ).catch(() => new Map()), + ]) + + return backed + .map((b, i) => { + const o = i * 4 + const markeeOwner = (detailResults[o + 2]?.result as string | undefined)?.toLowerCase() + // Markees the wallet owns belong in "bought", not "funded" + if (markeeOwner === owner) return null + const totalFundsAdded = (detailResults[o + 3]?.result as bigint) ?? 0n + const contributed = positions.get(b.lb.address.toLowerCase())?.contributed ?? 0n + return { + address: b.markee, + message: (detailResults[o]?.result as string) ?? '', + name: (detailResults[o + 1]?.result as string) ?? '', + totalFundsAdded: totalFundsAdded.toString(), + totalContributed: contributed.toString(), + strategyId: b.lb.address, + strategyName: b.lb.name ?? 'Unknown Leaderboard', + isTop: b.lb.topMarkeeAddress?.toLowerCase() === b.markee, + topFundsRaw: b.lb.topFundsAddedRaw ?? '0', + } + }) + .filter((m): m is FundedMessage => m !== null) +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const origin = internalOrigin() + const owner = searchParams.get('owner')?.toLowerCase() + if (!owner || !/^0x[0-9a-f]{40}$/.test(owner)) { + return NextResponse.json({ error: 'Invalid owner' }, { status: 400 }) + } + + const client = getClient() + const headers = internalHeaders() + + // One strategy failing should not blank out the other's positions. + const [fixed, streaming] = await Promise.all([ + fixedFunded(client, owner, origin, headers).catch(() => []), + streamingFunded(client, owner, origin, headers).catch(() => []), + ]) - return NextResponse.json({ funded }) + return NextResponse.json({ funded: [...fixed, ...streaming] }) } diff --git a/frontend/app/api/account/messages/route.ts b/frontend/app/api/account/messages/route.ts index e478b9b8..3b7b64db 100644 --- a/frontend/app/api/account/messages/route.ts +++ b/frontend/app/api/account/messages/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from 'next/server' import { createPublicClient, http } from 'viem' import { base } from 'viem/chains' +import { internalOrigin, internalHeaders } from '@/lib/internal-origin' export const dynamic = 'force-dynamic' @@ -50,17 +51,19 @@ async function chunkedMulticall( } export async function GET(request: Request) { - const { searchParams, origin } = new URL(request.url) + const { searchParams } = new URL(request.url) + const origin = internalOrigin() const owner = searchParams.get('owner')?.toLowerCase() if (!owner || !/^0x[0-9a-f]{40}$/.test(owner)) { return NextResponse.json({ error: 'Invalid owner' }, { status: 400 }) } // Fetch all platform leaderboards (uses their cached responses where available) + const headers = internalHeaders() const [sfData, ghData, oiData] = await Promise.all([ - fetch(`${origin}/api/superfluid/leaderboards`).then(r => r.ok ? r.json() : null).catch(() => null), - fetch(`${origin}/api/github/leaderboards`).then(r => r.ok ? r.json() : null).catch(() => null), - fetch(`${origin}/api/openinternet/leaderboards`).then(r => r.ok ? r.json() : null).catch(() => null), + fetch(`${origin}/api/superfluid/leaderboards`, { headers }).then(r => r.ok ? r.json() : null).catch(() => null), + fetch(`${origin}/api/github/leaderboards`, { headers }).then(r => r.ok ? r.json() : null).catch(() => null), + fetch(`${origin}/api/openinternet/leaderboards`, { headers }).then(r => r.ok ? r.json() : null).catch(() => null), ]) // Only include leaderboards that have at least one markee 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..18158e51 --- /dev/null +++ b/frontend/app/api/cron/streaming-keeper/README.md @@ -0,0 +1,37 @@ +# 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 Vercel cron calls it. 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. The schedule still has to be added to `frontend/vercel.json`. + +Only one signer exists, so a run that outlives its tick would collide on nonces with the next one. The +route takes a KV lock for the duration and returns `skipped: previous run still in flight` instead. + +## Auth & env + +The route authorizes a `Bearer ` or `x-keeper-secret: ` against `CRON_SECRET`, which is +what Vercel cron sends. `KEEPER_PRIVATE_KEY` is the only var this route introduces; everything else is +shared with the rest of the app. + +| Var | Purpose | +|---|---| +| `NEXT_PUBLIC_STREAMING_FACTORY` | Gates the whole feature. Until set, the route no-ops (`skipped: streaming disabled`). | +| `KEEPER_PRIVATE_KEY` | Gas-funded hot wallet that signs `claimTop`/`settle`. | +| `CRON_SECRET` | Shared with the other cron routes; Vercel cron sends it as the Bearer token. | +| `NEXT_PUBLIC_BASE_RPC_URL` / `ALCHEMY_BASE_URL` | Base RPC, same precedence as the streaming reads. | +| `STREAMING_FROM_BLOCK` | Set it to the factory deploy block: without it the `BackerUpdated` scan only looks back 50k blocks, and a backer whose last stream change predates that window is missed by `settle`. Shared with `/api/streaming/leaderboards`, which reads it `bounded` (clamped to the 50k window) because it is a request-path read; the keeper is the only unbounded consumer, since scan cost grows as the deploy block recedes. | + +## 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'` +- 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..fcbfe64e --- /dev/null +++ b/frontend/app/api/cron/streaming-keeper/route.ts @@ -0,0 +1,96 @@ +/** + * Streaming keeper — heals on-chain ranking lag and flushes RevNet settlement. + * + * Vercel cron calls it on a schedule (a periodic poll, not an event trigger: the decay/decrease + * that staled the title fires no tx and no event), sending the platform's cron secret: + * + * GET /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. + * + * KEEPER_PRIVATE_KEY (hot wallet) is the only var this route owns; auth, RPC and the log-scan + * start reuse CRON_SECRET, NEXT_PUBLIC_BASE_RPC_URL/ALCHEMY_BASE_URL and STREAMING_FROM_BLOCK. + * 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 { kv } from '@vercel/kv' +import { runKeeper } from '@/lib/streaming/keeper' +import { STREAMING_FACTORY, STREAMING_ENABLED } from '@/lib/contracts/addresses' + +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +// One signer, one nonce sequence: overlapping runs would collide. +const LOCK_KEY = 'streaming:keeper:lock' +const LOCK_TTL = maxDuration + 30 + +function authorized(req: NextRequest): boolean { + const secret = process.env.CRON_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' }) + } + + // The factory lives on whatever chain NEXT_PUBLIC_BASE_RPC_URL points at (the same RPC the client + // streaming hooks read), so prefer it and fall back to Alchemy. + const rpc = process.env.NEXT_PUBLIC_BASE_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) }) + } + + const locked = dryRun || await kv.set(LOCK_KEY, Date.now(), { nx: true, ex: LOCK_TTL }) === 'OK' + if (!locked) return NextResponse.json({ ok: true, skipped: 'previous run still in flight' }) + + try { + const report = await runKeeper({ + publicClient, + walletClient, + account, + factory: STREAMING_FACTORY as Address, + 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 }) + } finally { + if (!dryRun) await kv.del(LOCK_KEY) + } +} + +export async function GET(req: NextRequest) { + return handle(req) +} + +export async function POST(req: NextRequest) { + return handle(req) +} diff --git a/frontend/app/api/ecosystem/leaderboards/route.ts b/frontend/app/api/ecosystem/leaderboards/route.ts index 0bd11a31..a97c6e7a 100644 --- a/frontend/app/api/ecosystem/leaderboards/route.ts +++ b/frontend/app/api/ecosystem/leaderboards/route.ts @@ -11,6 +11,9 @@ import { NextResponse } from 'next/server' import { kv } from '@vercel/kv' import { formatEther } from 'viem' +import { imputeEffectiveRate } from '@/lib/strategy' +import { STREAMING_ENABLED } from '@/lib/contracts/addresses' +import { internalOrigin, internalHeaders } from '@/lib/internal-origin' export const dynamic = 'force-dynamic' @@ -44,20 +47,17 @@ export async function GET(request: Request) { if (cached) return NextResponse.json(cached, { headers: NO_CACHE }) } - const origin = new URL(request.url).origin + const origin = internalOrigin() const bustParam = bust ? '?bust=1' : '' // Add bypass header so internal fetches work on protected preview deployments - const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET - const internalHeaders: HeadersInit = bypassSecret - ? { 'x-vercel-protection-bypass': bypassSecret } - : {} + const headers = internalHeaders() // Fetch all three platform APIs in parallel const [oiRes, githubRes, sfRes] = await Promise.all([ - fetch(`${origin}/api/openinternet/leaderboards${bustParam}`, { cache: 'no-store', headers: internalHeaders }), - fetch(`${origin}/api/github/leaderboards${bustParam}`, { cache: 'no-store', headers: internalHeaders }), - fetch(`${origin}/api/superfluid/leaderboards${bustParam}`, { cache: 'no-store', headers: internalHeaders }), + fetch(`${origin}/api/openinternet/leaderboards${bustParam}`, { cache: 'no-store', headers }), + fetch(`${origin}/api/github/leaderboards${bustParam}`, { cache: 'no-store', headers }), + fetch(`${origin}/api/superfluid/leaderboards${bustParam}`, { cache: 'no-store', headers }), ]) const [oiData, githubData, sfData] = await Promise.all([ @@ -66,6 +66,14 @@ export async function GET(request: Request) { sfRes.ok ? sfRes.json() : { leaderboards: [] }, ]) + // Streaming boards (vertical-agnostic) are already tagged with their platform + strategy by the + // streaming API. Only fetched when the streaming factory is configured. + const streamData = STREAMING_ENABLED + ? await fetch(`${origin}/api/streaming/leaderboards${bustParam}`, { cache: 'no-store', headers }) + .then(r => r.ok ? r.json() : { leaderboards: [] }) + .catch(() => ({ leaderboards: [] })) + : { leaderboards: [] } + // Tag each with platform identifier const oiLeaderboards = (oiData.leaderboards ?? []).map((l: any) => ({ ...l, @@ -102,14 +110,26 @@ export async function GET(request: Request) { } }) - const leaderboards = [...oiLeaderboards, ...githubLeaderboards, ...sfLeaderboards] - .map((l: any) => ({ ...l, gamed: GAMED_ADDRESSES.has(l.address?.toLowerCase()) })) - - // Sort descending by total funds + const streamLeaderboards = (streamData.leaderboards ?? []) + + // Every row carries a strategy (fixed-price unless the streaming API tagged it) and an + // effectiveRateRaw (wei/sec) yardstick: streaming reads it on-chain, fixed-price imputes it from + // cumulative funds so both strategies rank on one axis. + const leaderboards = [...oiLeaderboards, ...githubLeaderboards, ...sfLeaderboards, ...streamLeaderboards] + .map((l: any) => { + const strategy = l.strategy === 'streaming' ? 'streaming' : 'fixed' + const effectiveRateRaw = strategy === 'streaming' + ? (l.effectiveRateRaw ?? '0') + : imputeEffectiveRate(BigInt(l.totalFundsRaw ?? '0')).toString() + return { ...l, strategy, effectiveRateRaw, gamed: GAMED_ADDRESSES.has(l.address?.toLowerCase()) } + }) + + // Sort descending by cumulative funds — fixed boards' lump-sum total, streaming boards' total + // streamed-in (getLogs). One cumulative-$ axis across strategies. leaderboards.sort((a: any, b: any) => { - const aWei = BigInt(a.totalFundsRaw ?? '0') - const bWei = BigInt(b.totalFundsRaw ?? '0') - return bWei > aWei ? 1 : bWei < aWei ? -1 : 0 + const aTotal = BigInt(a.totalFundsRaw ?? '0') + const bTotal = BigInt(b.totalFundsRaw ?? '0') + return bTotal > aTotal ? 1 : bTotal < aTotal ? -1 : 0 }) // Compute aggregate total across all platforms, excluding gamed leaderboards diff --git a/frontend/app/api/github/update-markee-file/route.ts b/frontend/app/api/github/update-markee-file/route.ts index ed1648d3..a93b7384 100644 --- a/frontend/app/api/github/update-markee-file/route.ts +++ b/frontend/app/api/github/update-markee-file/route.ts @@ -233,7 +233,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `Chain read failed: ${String(err)}` }, { status: 500 }) } - const leaderboardUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/ecosystem/platforms/github/${normalizedAddress}` + const leaderboardUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/markee/${normalizedAddress}` const markeeBlock = topMessage ? buildMarkeeBlock(normalizedAddress, topMessage, topOwnerName, nextBuyPriceEth, leaderboardUrl) : buildEmptyBlock(normalizedAddress, nextBuyPriceEth, leaderboardUrl) diff --git a/frontend/app/api/markee/history/route.ts b/frontend/app/api/markee/history/route.ts index b61ca68e..a9576152 100644 --- a/frontend/app/api/markee/history/route.ts +++ b/frontend/app/api/markee/history/route.ts @@ -27,10 +27,12 @@ const LEADERBOARD_NAME_UPDATED = parseAbiItem( ) function getClient() { + // Prefer the RPC the rest of the app reads from, so this server-side scan sees the same chain + // the client-side hooks do; fall back to Alchemy/default. return createPublicClient({ chain: base, transport: http( - process.env.ALCHEMY_BASE_URL ?? 'https://mainnet.base.org', + process.env.NEXT_PUBLIC_BASE_RPC_URL || process.env.ALCHEMY_BASE_URL || 'https://mainnet.base.org', { fetchOptions: { cache: 'no-store' } }, ), }) diff --git a/frontend/app/api/openinternet/leaderboards/route.ts b/frontend/app/api/openinternet/leaderboards/route.ts index d8e3a865..865ef333 100644 --- a/frontend/app/api/openinternet/leaderboards/route.ts +++ b/frontend/app/api/openinternet/leaderboards/route.ts @@ -12,6 +12,7 @@ import { NextResponse } from 'next/server' import { createPublicClient, http, formatEther } from 'viem' import { base } from 'viem/chains' import { kv } from '@vercel/kv' +import { LeaderboardFactoryABI, LeaderboardV11ABI, MarkeeABI } from '@/lib/contracts/abis' export const dynamic = 'force-dynamic' @@ -29,42 +30,6 @@ const NO_CACHE = { 'Access-Control-Allow-Headers': 'Content-Type', } -// ─── ABIs ───────────────────────────────────────────────────────────────────── - -const FACTORY_ABI = [ - { - inputs: [ - { name: 'offset', type: 'uint256' }, - { name: 'limit', type: 'uint256' }, - ], - name: 'getLeaderboards', - outputs: [{ name: 'result', type: 'address[]' }], - stateMutability: 'view', - type: 'function', - }, -] as const - -const LEADERBOARD_ABI = [ - { inputs: [], name: 'leaderboardName', outputs: [{ name: '', type: 'string' }], stateMutability: 'view', type: 'function' }, - { inputs: [], name: 'totalLeaderboardFunds', outputs: [{ name: 'total', type: 'uint256' }], stateMutability: 'view', type: 'function' }, - { inputs: [], name: 'markeeCount', outputs: [{ name: '', type: 'uint256' }], stateMutability: 'view', type: 'function' }, - { inputs: [], name: 'minimumPrice', outputs: [{ name: '', type: 'uint256' }], stateMutability: 'view', type: 'function' }, - { inputs: [], name: 'admin', outputs: [{ name: '', type: 'address' }], stateMutability: 'view', type: 'function' }, - { - inputs: [{ name: 'limit', type: 'uint256' }], - name: 'getTopMarkees', - outputs: [{ name: 'topAddresses', type: 'address[]' }, { name: 'topFunds', type: 'uint256[]' }], - stateMutability: 'view', - type: 'function', - }, -] as const - -const MARKEE_ABI = [ - { inputs: [], name: 'message', outputs: [{ name: '', type: 'string' }], stateMutability: 'view', type: 'function' }, - { inputs: [], name: 'name', outputs: [{ name: '', type: 'string' }], stateMutability: 'view', type: 'function' }, - { inputs: [], name: 'owner', outputs: [{ name: '', type: 'address' }], stateMutability: 'view', type: 'function' }, -] as const - // ─── Hardcoded partner meta ─────────────────────────────────────────────────── // These 4 leaderboards were migrated from v0.1 TopDawg contracts to v1.1. // They live in the legacy OI factory but have no KV meta, so we hardcode @@ -186,19 +151,19 @@ export async function GET(request: Request) { const addresses = await client.readContract({ address: OI_FACTORY_ADDRESSES[0], - abi: FACTORY_ABI, + abi: LeaderboardFactoryABI, functionName: 'getLeaderboards', args: [0n, 1000n], }).then(r => r as `0x${string}`[]).catch(() => [] as `0x${string}`[]) // Multicall for OI factory leaderboard metadata const metaCalls = addresses.flatMap(addr => [ - { address: addr, abi: LEADERBOARD_ABI, functionName: 'leaderboardName' as const }, - { address: addr, abi: LEADERBOARD_ABI, functionName: 'totalLeaderboardFunds' as const }, - { address: addr, abi: LEADERBOARD_ABI, functionName: 'markeeCount' as const }, - { address: addr, abi: LEADERBOARD_ABI, functionName: 'minimumPrice' as const }, - { address: addr, abi: LEADERBOARD_ABI, functionName: 'admin' as const }, - { address: addr, abi: LEADERBOARD_ABI, functionName: 'getTopMarkees' as const, args: [1n] }, + { address: addr, abi: LeaderboardV11ABI, functionName: 'leaderboardName' as const }, + { address: addr, abi: LeaderboardV11ABI, functionName: 'totalLeaderboardFunds' as const }, + { address: addr, abi: LeaderboardV11ABI, functionName: 'markeeCount' as const }, + { address: addr, abi: LeaderboardV11ABI, functionName: 'minimumPrice' as const }, + { address: addr, abi: LeaderboardV11ABI, functionName: 'admin' as const }, + { address: addr, abi: LeaderboardV11ABI, functionName: 'getTopMarkees' as const, args: [1n] }, ]) const metaResults = metaCalls.length > 0 @@ -213,9 +178,9 @@ export async function GET(request: Request) { const markeeCalls = topMarkeeAddresses.flatMap(addr => addr ? [ - { address: addr, abi: MARKEE_ABI, functionName: 'message' as const }, - { address: addr, abi: MARKEE_ABI, functionName: 'name' as const }, - { address: addr, abi: MARKEE_ABI, functionName: 'owner' as const }, + { address: addr, abi: MarkeeABI, functionName: 'message' as const }, + { address: addr, abi: MarkeeABI, functionName: 'name' as const }, + { address: addr, abi: MarkeeABI, functionName: 'owner' as const }, ] : [] ) diff --git a/frontend/app/api/streaming/leaderboards/route.ts b/frontend/app/api/streaming/leaderboards/route.ts new file mode 100644 index 00000000..883ea972 --- /dev/null +++ b/frontend/app/api/streaming/leaderboards/route.ts @@ -0,0 +1,182 @@ +// app/api/streaming/leaderboards/route.ts +// +// Enumerates streaming-priced boards (one vertical-agnostic StreamingLeaderboardFactory) and returns +// them normalized to the same row shape as the fixed-price vertical APIs, tagged strategy: 'streaming'. +// totalFundsRaw is what the board raised (subgraph inflow net of GDA refunds); effectiveRateRaw is the +// current top wei/sec for the $/mo label. +// Inert (returns []) until NEXT_PUBLIC_STREAMING_FACTORY is configured. + +import { NextResponse } from 'next/server' +import { createPublicClient, http, formatEther } from 'viem' +import { base } from 'viem/chains' +import { kv } from '@vercel/kv' +import { STREAMING_FACTORY, STREAMING_ENABLED } from '@/lib/contracts/addresses' +import { getStreamingBoardMeta } from '@/lib/streaming/boardMeta' +import { fetchBoardTotals } from '@/lib/streaming/subgraph' +import { STREAMING_BASE } from '@/lib/superfluid/streaming' +import type { Vertical } from '@/lib/strategy' +import { LeaderboardFactoryABI, StreamingLeaderboardABI, MarkeeABI } from '@/lib/contracts/abis' + +export const dynamic = 'force-dynamic' + +const CACHE_KEY = 'cache:streaming:leaderboards' +const CACHE_TTL = 60 // seconds +// Served when the totals source fails: stale beats wrong for the headline number. +const LAST_GOOD_KEY = 'cache:streaming:leaderboards:lastgood' +const LAST_GOOD_TTL = 7 * 24 * 60 * 60 +const NO_CACHE = { 'Cache-Control': 'no-store, no-cache, must-revalidate' } + +// Placement -> the `platform` value the vertical listings use. Untagged boards fall back to website. +const VERTICAL_TO_PLATFORM: Record = { + openinternet: 'website', + github: 'github', + superfluid: 'superfluid', +} + +function getClient() { + // The streaming factory lives on whatever chain NEXT_PUBLIC_STREAMING_FACTORY was deployed to, + // which is the chain NEXT_PUBLIC_BASE_RPC_URL points at (the same RPC every client-side streaming + // hook reads). Prefer it so this server-side read sees the same factory; fall back to Alchemy/default. + return createPublicClient({ + chain: base, + transport: http( + process.env.NEXT_PUBLIC_BASE_RPC_URL || process.env.ALCHEMY_BASE_URL || 'https://mainnet.base.org', + { batch: true, fetchOptions: { cache: 'no-store' } }, + ), + }) +} + +export async function GET(request: Request) { + if (!STREAMING_ENABLED) { + return NextResponse.json({ leaderboards: [] }, { headers: NO_CACHE }) + } + + try { + const bust = new URL(request.url).searchParams.get('bust') === '1' + if (!bust) { + const cached = await kv.get(CACHE_KEY) + if (cached) return NextResponse.json(cached, { headers: NO_CACHE }) + } + + const client = getClient() + const factory = STREAMING_FACTORY as `0x${string}` + + const addresses = await client.readContract({ + address: factory, + abi: LeaderboardFactoryABI, + functionName: 'getLeaderboards', + args: [0n, 1000n], + }) as `0x${string}`[] + + if (addresses.length === 0) { + const empty = { leaderboards: [] } + await kv.set(CACHE_KEY, empty, { ex: CACHE_TTL }) + return NextResponse.json(empty, { headers: NO_CACHE }) + } + + const latestBlock = await client.getBlockNumber() + const streamedAt = Number((await client.getBlock({ blockNumber: latestBlock })).timestamp) + const ETHX = STREAMING_BASE.ethx as `0x${string}` + + const CHUNK_SIZE = 50 + async function chunkedMulticall(contracts: Parameters[0]['contracts']) { + const chunks = [] + for (let i = 0; i < contracts.length; i += CHUNK_SIZE) { + chunks.push(contracts.slice(i, i + CHUNK_SIZE) as Parameters[0]['contracts']) + } + const results = await Promise.all(chunks.map(chunk => client.multicall({ contracts: chunk }))) + return results.flat() + } + + const metaCalls = addresses.flatMap(addr => [ + { address: addr, abi: StreamingLeaderboardABI, functionName: 'leaderboardName' as const }, + { address: addr, abi: StreamingLeaderboardABI, functionName: 'totalLeaderboardFunds' as const }, + { address: addr, abi: StreamingLeaderboardABI, functionName: 'markeeCount' as const }, + { address: addr, abi: StreamingLeaderboardABI, functionName: 'beneficiaryAddress' as const }, + { address: addr, abi: StreamingLeaderboardABI, functionName: 'admin' as const }, + { address: addr, abi: StreamingLeaderboardABI, functionName: 'getTopMarkees' as const, args: [1n] }, + ]) + const CALLS_PER_BOARD = 6 + const metaResults = await chunkedMulticall(metaCalls as Parameters[0]['contracts']) + + const topMarkeeAddresses: (`0x${string}` | null)[] = addresses.map((_, i) => { + const topResult = metaResults[i * CALLS_PER_BOARD + 5]?.result as [string[], bigint[]] | undefined + return (topResult?.[0]?.[0] ?? null) as `0x${string}` | null + }) + + const markeeCalls = topMarkeeAddresses.flatMap(addr => + addr ? [ + { address: addr, abi: MarkeeABI, functionName: 'message' as const }, + { address: addr, abi: MarkeeABI, functionName: 'name' as const }, + ] : [] + ) + const [markeeResults, metas, totalsByBoard] = await Promise.all([ + markeeCalls.length > 0 + ? chunkedMulticall(markeeCalls as Parameters[0]['contracts']) + : Promise.resolve([]), + Promise.all(addresses.map(a => getStreamingBoardMeta(a))), + fetchBoardTotals(addresses, ETHX, BigInt(streamedAt)), + ]) + + let markeeCallIndex = 0 + const leaderboards = addresses.map((addr, i) => { + const b = i * CALLS_PER_BOARD + const name = (metaResults[b]?.result as string) ?? addr + const totals = totalsByBoard.get(addr.toLowerCase()) + const raised = totals?.raised ?? 0n + const raisedRate = totals?.raisedRate ?? 0n + + const markeeCount = (metaResults[b + 2]?.result as bigint) ?? 0n + const beneficiary = (metaResults[b + 3]?.result as string) ?? '' + const admin = (metaResults[b + 4]?.result as string) ?? '' + const topResult = metaResults[b + 5]?.result as [string[], bigint[]] | undefined + const topRate = topResult?.[1]?.[0] ?? 0n + + let topMessage: string | null = null + let topMessageOwner: string | null = null + if (topMarkeeAddresses[i]) { + topMessage = (markeeResults[markeeCallIndex]?.result as string) || null + topMessageOwner = (markeeResults[markeeCallIndex + 1]?.result as string) || null + markeeCallIndex += 2 + } + + const vertical = metas[i]?.vertical ?? 'openinternet' + + return { + address: addr, + name, + platform: VERTICAL_TO_PLATFORM[vertical], + strategy: 'streaming' as const, + // Inflow minus GDA refunds: non-#1 backers get refunded, so gross would overstate the raise. + totalFunds: formatEther(raised), + totalFundsRaw: raised.toString(), + // Measured at `streamedAt`; the client ticks forward at this rate. + streamedRateRaw: raisedRate.toString(), + streamedAt, + markeeCount: Number(markeeCount), + beneficiary, + admin, + // effectiveRateRaw = current top wei/sec (the $/mo "price to change"); topFundsAddedRaw reuses it + // as the activity signal (topFundsAddedRaw > 0 && topMessage) the listings filter on. + effectiveRateRaw: topRate.toString(), + topRateRaw: topRate.toString(), + topFundsAddedRaw: topRate.toString(), + topMessage, + topMessageOwner, + topMarkeeAddress: topMarkeeAddresses[i] ?? null, + } + }) + + const payload = { leaderboards } + await Promise.all([ + kv.set(CACHE_KEY, payload, { ex: CACHE_TTL }), + kv.set(LAST_GOOD_KEY, payload, { ex: LAST_GOOD_TTL }), + ]) + return NextResponse.json(payload, { headers: NO_CACHE }) + } catch (err) { + console.error('[streaming/leaderboards] error:', err) + const lastGood = await kv.get<{ leaderboards: unknown[] }>(LAST_GOOD_KEY).catch(() => null) + if (lastGood) return NextResponse.json({ ...lastGood, stale: true }, { headers: NO_CACHE }) + return NextResponse.json({ leaderboards: [] }, { headers: NO_CACHE }) + } +} diff --git a/frontend/app/api/streaming/register/route.ts b/frontend/app/api/streaming/register/route.ts new file mode 100644 index 00000000..c61693c7 --- /dev/null +++ b/frontend/app/api/streaming/register/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getStreamingBoardMeta, setStreamingBoardMeta } from '@/lib/streaming/boardMeta' +import type { Vertical } from '@/lib/strategy' + +const VERTICALS: Vertical[] = ['openinternet', 'github', 'superfluid'] + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => null) + const { address, vertical, name } = (body ?? {}) as { address?: string; vertical?: string; name?: string } + + if (!address || !/^0x[0-9a-fA-F]{40}$/.test(address)) + return NextResponse.json({ error: 'Invalid address' }, { status: 400 }) + if (!vertical || !VERTICALS.includes(vertical as Vertical)) + return NextResponse.json({ error: 'Invalid vertical' }, { status: 400 }) + + await setStreamingBoardMeta(address, { vertical: vertical as Vertical, name: name?.slice(0, 120) }) + return NextResponse.json({ success: true }) + } catch (err) { + console.error('[streaming/register] error:', err) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +export async function GET(request: NextRequest) { + const address = new URL(request.url).searchParams.get('address') + if (!address || !/^0x[0-9a-fA-F]{40}$/.test(address)) + return NextResponse.json({ error: 'Invalid address' }, { status: 400 }) + const meta = await getStreamingBoardMeta(address) + return NextResponse.json({ meta }) +} diff --git a/frontend/app/api/superfluid/leaderboards/route.ts b/frontend/app/api/superfluid/leaderboards/route.ts index 117970c0..40ec66f0 100644 --- a/frontend/app/api/superfluid/leaderboards/route.ts +++ b/frontend/app/api/superfluid/leaderboards/route.ts @@ -4,6 +4,7 @@ import { createPublicClient, http, formatEther } from 'viem' import { base } from 'viem/chains' import { kv } from '@vercel/kv' import type { BoostedMarkee } from '@/app/api/superfluid/boosted/route' +import { LeaderboardFactoryABI, LeaderboardV11ABI, MarkeeABI } from '@/lib/contracts/abis' const BOOSTED_KEY = 'superfluid:s6:boosted' const BASELINE_PREFIX = 'superfluid:s6:baseline:' @@ -37,40 +38,6 @@ const GAMED_LEADERBOARD_ADDRESSES = new Set([ '0xc936a036b7727865a696398de30f616be98e266b', // Sup (2 ETH) ]) -const FACTORY_ABI = [ - { - inputs: [ - { name: 'offset', type: 'uint256' }, - { name: 'limit', type: 'uint256' }, - ], - name: 'getLeaderboards', - outputs: [{ name: 'result', type: 'address[]' }], - stateMutability: 'view', - type: 'function', - }, -] as const - -const LEADERBOARD_ABI = [ - { inputs: [], name: 'leaderboardName', outputs: [{ name: '', type: 'string' }], stateMutability: 'view', type: 'function' }, - { inputs: [], name: 'totalLeaderboardFunds', outputs: [{ name: 'total', type: 'uint256' }], stateMutability: 'view', type: 'function' }, - { inputs: [], name: 'markeeCount', outputs: [{ name: '', type: 'uint256' }], stateMutability: 'view', type: 'function' }, - { inputs: [], name: 'minimumPrice', outputs: [{ name: '', type: 'uint256' }], stateMutability: 'view', type: 'function' }, - { inputs: [], name: 'admin', outputs: [{ name: '', type: 'address' }], stateMutability: 'view', type: 'function' }, - { inputs: [], name: 'beneficiaryAddress', outputs: [{ name: '', type: 'address' }], stateMutability: 'view', type: 'function' }, - { - inputs: [{ name: 'limit', type: 'uint256' }], - name: 'getTopMarkees', - outputs: [{ name: 'topAddresses', type: 'address[]' }, { name: 'topFunds', type: 'uint256[]' }], - stateMutability: 'view', - type: 'function', - }, -] as const - -const MARKEE_ABI = [ - { inputs: [], name: 'message', outputs: [{ name: '', type: 'string' }], stateMutability: 'view', type: 'function' }, - { inputs: [], name: 'name', outputs: [{ name: '', type: 'string' }], stateMutability: 'view', type: 'function' }, -] as const - // ─── Client ─────────────────────────────────────────────────────────────────── function getClient() { @@ -151,7 +118,7 @@ export async function GET(request: Request) { const addresses = await client.readContract({ address: SUPERFLUID_FACTORY_ADDRESS, - abi: FACTORY_ABI, + abi: LeaderboardFactoryABI, functionName: 'getLeaderboards', args: [0n, 1000n], }) as `0x${string}`[] @@ -176,13 +143,13 @@ export async function GET(request: Request) { // 2. Multicall — fetch metadata for each leaderboard (7 calls per address) const metaCalls = addresses.flatMap(addr => [ - { address: addr, abi: LEADERBOARD_ABI, functionName: 'leaderboardName' as const }, // b+0 - { address: addr, abi: LEADERBOARD_ABI, functionName: 'totalLeaderboardFunds' as const }, // b+1 - { address: addr, abi: LEADERBOARD_ABI, functionName: 'markeeCount' as const }, // b+2 - { address: addr, abi: LEADERBOARD_ABI, functionName: 'minimumPrice' as const }, // b+3 - { address: addr, abi: LEADERBOARD_ABI, functionName: 'admin' as const }, // b+4 - { address: addr, abi: LEADERBOARD_ABI, functionName: 'beneficiaryAddress' as const }, // b+5 - { address: addr, abi: LEADERBOARD_ABI, functionName: 'getTopMarkees' as const, args: [1n] }, // b+6 + { address: addr, abi: LeaderboardV11ABI, functionName: 'leaderboardName' as const }, // b+0 + { address: addr, abi: LeaderboardV11ABI, functionName: 'totalLeaderboardFunds' as const }, // b+1 + { address: addr, abi: LeaderboardV11ABI, functionName: 'markeeCount' as const }, // b+2 + { address: addr, abi: LeaderboardV11ABI, functionName: 'minimumPrice' as const }, // b+3 + { address: addr, abi: LeaderboardV11ABI, functionName: 'admin' as const }, // b+4 + { address: addr, abi: LeaderboardV11ABI, functionName: 'beneficiaryAddress' as const }, // b+5 + { address: addr, abi: LeaderboardV11ABI, functionName: 'getTopMarkees' as const, args: [1n] }, // b+6 ]) const metaResults = await chunkedMulticall(metaCalls as Parameters[0]['contracts']) @@ -196,8 +163,8 @@ export async function GET(request: Request) { const markeeCalls = topMarkeeAddresses.flatMap(addr => addr ? [ - { address: addr, abi: MARKEE_ABI, functionName: 'message' as const }, - { address: addr, abi: MARKEE_ABI, functionName: 'name' as const }, + { address: addr, abi: MarkeeABI, functionName: 'message' as const }, + { address: addr, abi: MarkeeABI, functionName: 'name' as const }, ] : [] ) diff --git a/frontend/app/api/views/route.ts b/frontend/app/api/views/route.ts index 9d671ef2..d26a6bd1 100644 --- a/frontend/app/api/views/route.ts +++ b/frontend/app/api/views/route.ts @@ -105,6 +105,20 @@ export async function POST(req: NextRequest) { const address = body.address.toLowerCase().trim() const msgHash = hashMessage(body.message) + // Only production deployments mutate the shared view store. Previews and local dev read the same + // store through (so counts look real) but never increment it, keeping preview traffic and bots + // out of the production totals. + if (process.env.VERCEL_ENV !== 'production') { + const [totalViews, messageViews] = await kv.mget( + `views:total:${address}`, + `views:msg:${address}:${msgHash}`, + ) + return NextResponse.json( + { totalViews: totalViews ?? 0, messageViews: messageViews ?? 0, counted: false }, + { headers: corsHeaders(origin) }, + ) + } + // Rate limit: 1 view per IP per markee per hour const ip = getClientIp(req) const dedupeKey = `dedup:${ip}:${address}` diff --git a/frontend/app/create-a-markee/page.tsx b/frontend/app/create-a-markee/page.tsx index 9c0790c3..040680ff 100644 --- a/frontend/app/create-a-markee/page.tsx +++ b/frontend/app/create-a-markee/page.tsx @@ -2,12 +2,14 @@ import { useState, useEffect, useMemo, Suspense } from 'react' import { useSearchParams, useRouter } from 'next/navigation' -import { useWriteContract, useWaitForTransactionReceipt, useAccount } from 'wagmi' +import { useWriteContract, useWaitForTransactionReceipt, useAccount, useSwitchChain } from 'wagmi' import { ConnectButton } from '@/components/wallet/ConnectButton' 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, CANONICAL_CHAIN } from '@/lib/contracts/addresses' +import { STRATEGIES, type Strategy, type Vertical } from '@/lib/strategy' import { formatTransactionError, logTransactionError } from '@/lib/transactionErrors' const C = { @@ -17,10 +19,22 @@ 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 + +// (strategy x vertical) -> factory. Fixed price has a factory per vertical; streaming is +// vertical-agnostic (one factory, vertical stored as board metadata), so all verticals map to it. +const STREAMING_FACTORY_ADDR = (STREAMING_FACTORY || ZERO_ADDRESS) as `0x${string}` +const FACTORIES: Record> = { + fixed: { + openinternet: '0xFD488A0fE8D4Fa99B4A6016EA9C49a860A553F7c', + github: '0xdF2A716452a3960619cDdDCDe4E10eACcFFDa0A2', + superfluid: '0xC497187AAa35C26b0008B43C10A6F6300b7eBcad', + }, + streaming: { + openinternet: STREAMING_FACTORY_ADDR, + github: STREAMING_FACTORY_ADDR, + superfluid: STREAMING_FACTORY_ADDR, + }, } const FACTORY_ABI = [{ @@ -37,24 +51,24 @@ const FACTORY_ABI = [{ type: 'function', }] as const -type PlatformKey = 'openinternet' | 'github' | 'superfluid' - -interface Platform { - key: PlatformKey +interface VerticalInfo { + key: Vertical name: string tagline: string color: string summary: string + icon: string steps: string[] requiresConnect?: 'github' } -const PLATFORMS: Platform[] = [ +const VERTICALS: VerticalInfo[] = [ { key: 'openinternet', name: 'Website', tagline: 'Any site you own', color: C.pink, + icon: 'globe', summary: 'Add a Markee sign to any website you manage with a highly flexible LLM-guided integration.', steps: ['Set up your Markee', 'Deploy Markee', 'Embed & Activate'], }, @@ -63,6 +77,7 @@ const PLATFORMS: Platform[] = [ name: 'GitHub Repo', tagline: 'README, docs, any markdown', color: C.text, + icon: 'github', summary: 'Drop a Markee sign into any markdown file in your repo. Perfect for READMEs, docs and skill.md files.', steps: ['Connect GitHub', 'Set up your Markee', 'Deploy Markee', 'Embed & Activate'], requiresConnect: 'github', @@ -72,12 +87,15 @@ const PLATFORMS: Platform[] = [ name: 'Superfluid Project', tagline: 'Earn SUP incentives', color: C.green, + icon: 'zap', 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'], }, ] -type StepKey = 'choose' | 'connect' | 'setup' | 'review' | 'activate' +const STRATEGY_KEYS: Strategy[] = ['fixed', 'streaming'] + +type StepKey = 'strategy' | 'vertical' | 'connect' | 'setup' | 'review' | 'activate' interface GhUser { connected: boolean; login?: string; avatarUrl?: string } interface Repo { fullName: string; name: string; owner: string } @@ -88,6 +106,7 @@ function PlatGlyph({ icon, size = 24, color }: { icon: string; size?: number; co if (icon === 'globe') return if (icon === 'github') return if (icon === 'zap') return + if (icon === 'tag') return return } @@ -156,44 +175,90 @@ 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' } +// ── Selection card (shared by strategy + vertical choosers) ────────────────── +function ChoiceCard({ icon, color, name, tagline, summary, on, disabled, badge, onSelect }: { + icon: string; color: string; name: string; tagline: string; summary: string + on: boolean; disabled?: boolean; badge?: string; onSelect: () => void +}) { return ( -
- {PLATFORMS.map(p => { - const on = selected === p.key + + ) +} + +// ── ChooseStrategy ────────────────────────────────────────────────────────── +function ChooseStrategy({ selected, onSelect }: { selected: Strategy | null; onSelect: (s: Strategy) => void }) { + return ( +
+ {STRATEGY_KEYS.map(key => { + const meta = STRATEGIES[key] + const disabled = key === 'streaming' && !STREAMING_ENABLED return ( - + onSelect(key)} + /> ) })}
) } +// ── ChooseVertical ────────────────────────────────────────────────────────── +function ChooseVertical({ selected, onSelect }: { selected: Vertical | null; onSelect: (k: Vertical) => void }) { + return ( +
+ {VERTICALS.map(v => ( + onSelect(v.key)} + /> + ))} +
+ ) +} + // ── ConnectGitHub ───────────────────────────────────────────────────────── -function ConnectGitHub({ ghUser, onDisconnect }: { ghUser: GhUser; onDisconnect?: () => void }) { +function ConnectGitHub({ ghUser, strategy, onDisconnect }: { ghUser: GhUser; strategy: Strategy; onDisconnect?: () => void }) { + const returnTo = encodeURIComponent(`/create-a-markee?vertical=github&strategy=${strategy}`) return (
@@ -218,7 +283,7 @@ function ConnectGitHub({ ghUser, onDisconnect }: { ghUser: GhUser; onDisconnect? ) : (

Connect your GitHub account so we can verify your repo and confirm the Markee integration via the GitHub API.

- ; setValue: (k: string, v: string) => void - platformKey: PlatformKey + vertical: Vertical }) { const mono = 'var(--font-jetbrains-mono)' const fieldBase: React.CSSProperties = { width: '100%', background: C.bg, border: `1px solid ${C.border}`, borderRadius: 8, padding: '13px 14px', color: C.text, fontSize: 15, outline: 'none', boxSizing: 'border-box' } @@ -346,13 +411,13 @@ function WebsiteSetupFields({ values, setValue, platformKey }: { return (
- {platformKey === 'openinternet' && ( + {vertical === 'openinternet' && ( )} - {platformKey === 'superfluid' && ( + {vertical === 'superfluid' && (
- {!isSuperfluid && step(n++, isGithub ? 'Add the Markee delimiters to your markdown file' : 'Embed on your site', + {!skipEmbed && step(n++, isGithub ? 'Add the Markee delimiters to your markdown file' : 'Embed on your site',

{isGithub @@ -496,7 +566,7 @@ function ActivationGuide({ platform, leaderboardAddress, selectedFile, name }: { leaderboard={{ address: leaderboardAddress, name: name ?? leaderboardAddress }} /> - {!isSuperfluid && step(n++, 'Verify Integration', + {!skipEmbed && step(n++, 'Verify Integration',

{isGithub ? 'Click once your delimiter is committed.' : 'Click once your embed is live on the site.'} @@ -518,10 +588,12 @@ function ActivationGuide({ platform, leaderboardAddress, selectedFile, name }: {

)} - {step(isSuperfluid ? 1 : n, 'Activate your Markee', + {step(skipEmbed ? 1 : n, 'Activate your Markee',

- Buy the first message to activate your Markee. + {isStreaming + ? 'Add a message and open a stream to claim the #1 spot on your board.' + : 'Buy the first message to activate your Markee.'}

- View your Markee → + {isStreaming ? 'Open your board →' : 'View your Markee →'}
@@ -542,9 +614,11 @@ function ActivationGuide({ platform, leaderboardAddress, selectedFile, name }: { function CreateWizardInner() { const searchParams = useSearchParams() const router = useRouter() - const { isConnected } = useAccount() + const { isConnected, chain } = useAccount() + const { switchChain } = useSwitchChain() - const [platformKey, setPlatformKey] = useState(null) + const [strategy, setStrategy] = useState(null) + const [vertical, setVertical] = useState(null) const [step, setStep] = useState(0) const [values, setValuesRaw] = useState>({}) const [ghUser, setGhUser] = useState({ connected: false }) @@ -557,10 +631,27 @@ function CreateWizardInner() { const { writeContract, data: hash, isPending, error: writeError, reset: resetWrite } = useWriteContract() const { isLoading: isConfirming, isSuccess, data: receipt } = useWaitForTransactionReceipt({ hash }) - // Deep-link via ?platform= + // Deep-link via ?strategy= / ?vertical= (and legacy ?platform= where streaming was a "platform"). useEffect(() => { - const p = searchParams.get('platform') as PlatformKey | null - if (p && PLATFORMS.find(pl => pl.key === p)) { setPlatformKey(p); setStep(1) } + const verticals = VERTICALS.map(v => v.key) + const strategyParam = searchParams.get('strategy') as Strategy | null + const verticalParam = searchParams.get('vertical') as Vertical | null + const platformParam = searchParams.get('platform') + const resolvedStrategy: Strategy = + strategyParam === 'streaming' && STREAMING_ENABLED ? 'streaming' : 'fixed' + + if (verticalParam && verticals.includes(verticalParam)) { + setStrategy(resolvedStrategy) + setVertical(verticalParam) + setStep(2) + return + } + if (platformParam === 'streaming' && STREAMING_ENABLED) { + setStrategy('streaming'); setStep(1); return + } + if (platformParam && verticals.includes(platformParam as Vertical)) { + setStrategy('fixed'); setVertical(platformParam as Vertical); setStep(2) + } }, []) // eslint-disable-line react-hooks/exhaustive-deps // Check GitHub on mount @@ -586,8 +677,8 @@ function CreateWizardInner() { // Handle confirmed tx useEffect(() => { - if (!isSuccess || !receipt || !platformKey) return - const factoryAddr = FACTORIES[platformKey].toLowerCase() + if (!isSuccess || !receipt || !strategy || !vertical) return + const factoryAddr = FACTORIES[strategy][vertical].toLowerCase() let found: string | null = null for (const log of receipt.logs) { if (log.address.toLowerCase() === factoryAddr && log.topics[1]) { @@ -597,12 +688,20 @@ function CreateWizardInner() { } if (!found) return setNewLeaderboardAddress(found) - if (platformKey === 'github' && selectedRepo && selectedFile) { + if (vertical === 'github' && selectedRepo && selectedFile) { fetch('/api/github/register-markee', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ leaderboardAddress: found, repoFullName: selectedRepo, filePath: selectedFile }), }).catch(() => {}) } + // Streaming boards are vertical-agnostic on-chain; tag the placement off-chain so it surfaces on + // the right vertical listing. + if (strategy === 'streaming') { + fetch('/api/streaming/register', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: found, vertical }), + }).catch(() => {}) + } setStep(s => s + 1) }, [isSuccess, receipt]) // eslint-disable-line react-hooks/exhaustive-deps @@ -612,25 +711,26 @@ function CreateWizardInner() { const setValue = (k: string, v: string) => setValuesRaw(prev => ({ ...prev, [k]: v })) - const platform = platformKey ? PLATFORMS.find(p => p.key === platformKey)! : null + const vInfo = vertical ? VERTICALS.find(v => v.key === vertical)! : null const stepKeys: StepKey[] = useMemo(() => { - if (!platform) return ['choose'] - return platform.requiresConnect === 'github' - ? ['choose', 'connect', 'setup', 'review', 'activate'] - : ['choose', 'setup', 'review', 'activate'] - }, [platform?.key]) // eslint-disable-line react-hooks/exhaustive-deps + if (!strategy) return ['strategy'] + if (!vInfo) return ['strategy', 'vertical'] + return vInfo.requiresConnect === 'github' + ? ['strategy', 'vertical', 'connect', 'setup', 'review', 'activate'] + : ['strategy', 'vertical', 'setup', 'review', 'activate'] + }, [strategy, vInfo?.key]) // eslint-disable-line react-hooks/exhaustive-deps - const stepKey = stepKeys[step] ?? 'choose' + const stepKey = stepKeys[step] ?? 'strategy' const fieldsComplete = useMemo(() => { - if (!platform) return false + if (!vInfo) return false const b = /^0x[0-9a-fA-F]{40}$/.test(values.beneficiary ?? '') - if (platform.key === 'openinternet') return !!(values.siteName?.trim() && b) - if (platform.key === 'github') return !!(selectedRepo && selectedFile && b) - if (platform.key === 'superfluid') return !!(values.projectName?.trim() && b) + if (vInfo.key === 'openinternet') return !!(values.siteName?.trim() && b) + if (vInfo.key === 'github') return !!(selectedRepo && selectedFile && b) + if (vInfo.key === 'superfluid') return !!(values.projectName?.trim() && b) return false - }, [platform, values, selectedRepo, selectedFile]) + }, [vInfo, values, selectedRepo, selectedFile]) const go = (d: number) => setStep(s => Math.max(0, Math.min(s + d, stepKeys.length - 1))) @@ -645,20 +745,26 @@ function CreateWizardInner() { const handleDeploy = () => { setTxError(null) - if (!platformKey) return + if (!strategy || !vertical) return + if (chain?.id !== CANONICAL_CHAIN.id) { + setTxError(`Wrong network. Switch your wallet to ${CANONICAL_CHAIN.name}, then deploy again.`) + switchChain?.({ chainId: CANONICAL_CHAIN.id }) + return + } const bene = values.beneficiary?.trim() ?? '' if (!/^0x[0-9a-fA-F]{40}$/.test(bene)) { setTxError('Enter a valid beneficiary address.'); return } const name = - platformKey === 'openinternet' ? (values.siteName?.trim() ?? 'My Website') : - platformKey === 'github' ? (selectedRepo?.split('/').pop() ?? 'My Repo') : - (values.projectName?.trim() ?? 'My Project') + vertical === 'openinternet' ? (values.siteName?.trim() || 'My Website') : + vertical === 'github' ? (selectedRepo?.split('/').pop() ?? 'My Repo') : + (values.projectName?.trim() || 'My Project') resetWrite() try { writeContract({ - address: FACTORIES[platformKey], + address: FACTORIES[strategy][vertical], abi: FACTORY_ABI, functionName: 'createLeaderboard', args: [bene as `0x${string}`, name], + chainId: CANONICAL_CHAIN.id, }) } catch (err) { logTransactionError(err, 'CreateMarkeeWizard.createLeaderboard') @@ -672,16 +778,28 @@ function CreateWizardInner() {
← Raise Funding

- {step === 0 ? 'Choose a Platform' : 'Create a Markee'} + {stepKey === 'strategy' ? 'Choose a pricing strategy' : stepKey === 'vertical' ? 'Choose where it lives' : 'Create a Markee'}

- {step > 0 && platform && ( - + {step >= 2 && vInfo && ( + )} - {stepKey === 'choose' && ( - router.back()} backLabel="Cancel" onNext={() => go(1)} nextDisabled={!platformKey}> - + {stepKey === 'strategy' && ( + router.back()} backLabel="Cancel" onNext={() => go(1)} nextDisabled={!strategy} + > + + + )} + + {stepKey === 'vertical' && ( + go(-1)} onNext={() => go(1)} nextDisabled={!vertical} + > + )} @@ -691,17 +809,17 @@ function CreateWizardInner() { sub="We use your connection to add the Markee delimiter and confirm it via the GitHub API." onBack={() => go(-1)} onNext={() => go(1)} nextDisabled={!ghUser.connected} > - + )} - {stepKey === 'setup' && platform && ( + {stepKey === 'setup' && vInfo && ( go(-1)} onNext={() => go(1)} nextDisabled={!fieldsComplete} > - {platform.key === 'github' ? ( + {vInfo.key === 'github' ? (
) : ( - + )}
)} - {stepKey === 'review' && platform && ( + {stepKey === 'review' && vInfo && strategy && ( )} - {stepKey === 'activate' && platform && newLeaderboardAddress && ( + {stepKey === 'activate' && vInfo && strategy && newLeaderboardAddress && (
- +
{/* CURRENT MESSAGE */} -
-
+
+ +
{lb.topMessage || No message yet}
@@ -154,7 +166,11 @@ function TableRow({ {/* PRICE TO CHANGE */}
- {hasTopFunds ? ( + {isStreaming ? ( + + Stream → + + ) : hasTopFunds ? ( + {isStreaming ? ( + + ) : ( + + )}
) @@ -380,6 +435,8 @@ export default function MarketplacePage() { const [ecoStats, setEcoStats] = useState({ markees: 0, messages: 0, usd: 0 }) const [buyModal, setBuyModal] = useState(null) + const [streamCreate, setStreamCreate] = useState(null) + const [streamTarget, setStreamTarget] = useState<{ board: `0x${string}`; markee: StreamTarget } | null>(null) const [search, setSearch] = useState('') const [factory, setFactory] = useState('all') @@ -459,10 +516,11 @@ export default function MarketplacePage() { const bp = priceToOvertake(b) return (ap > bp ? 1 : ap < bp ? -1 : 0) * dir } - // raised (default) - const af = BigInt(a.totalFundsRaw || '0') - const bf = BigInt(b.totalFundsRaw || '0') - return (af > bf ? 1 : af < bf ? -1 : 0) * dir + // default (raised) — rank by cumulative funds: fixed boards' lump-sum total, streaming boards' + // total streamed-in. One cumulative-$ axis so the "Total raised" column and its sort are honest. + const at = BigInt(a.totalFundsRaw || '0') + const bt = BigInt(b.totalFundsRaw || '0') + return (at > bt ? 1 : at < bt ? -1 : 0) * dir }) }, [filtered, sortKey, sortDir, viewsMap]) @@ -481,8 +539,8 @@ export default function MarketplacePage() { // Top leaderboard for featured hero (by totalFunds, must have a message) const featured = useMemo(() => leaderboards.filter(lb => lb.topMessage).sort((a, b) => { - const af = BigInt(a.totalFundsRaw || '0'), bf = BigInt(b.totalFundsRaw || '0') - return bf > af ? 1 : bf < af ? -1 : 0 + const at = BigInt(a.totalFundsRaw || '0'), bt = BigInt(b.totalFundsRaw || '0') + return bt > at ? 1 : bt < at ? -1 : 0 })[0] ?? null, [leaderboards]) const featuredViews = featured?.topMarkeeAddress @@ -618,6 +676,7 @@ export default function MarketplacePage() { views={viewsMap.get((lb.topMarkeeAddress || '').toLowerCase()) ?? 0} ethPrice={ethPrice} onBuy={() => setBuyModal(lb)} + onStream={() => setStreamCreate(lb)} /> )) )} @@ -647,6 +706,28 @@ export default function MarketplacePage() { onSuccess={() => setBuyModal(null)} /> )} + + {streamCreate && ( + setStreamCreate(null)} + onCreated={(addr, message, name) => { + const board = streamCreate.address as `0x${string}` + setStreamCreate(null) + setStreamTarget({ board, markee: { address: addr, message, name } }) + }} + /> + )} + + {streamTarget && ( + setStreamTarget(null)} + onSuccess={() => setStreamTarget(null)} + /> + )}
) } diff --git a/frontend/app/owners/page.tsx b/frontend/app/owners/page.tsx index 492e0f3d..a48bf23f 100644 --- a/frontend/app/owners/page.tsx +++ b/frontend/app/owners/page.tsx @@ -68,7 +68,7 @@ function PhasesViz() {
{isCur && ACTIVE}
Phase {p.idx} · S{p.stage}
-
{p.rate.toLocaleString()}
+
{p.rate.toLocaleString('en-US')}
MARKEE / ETH
{past ? 'Ended' : isCur ? 'Ends' : 'From'} {fmtDate(p.end)}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 579c31fc..b6d4980d 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -10,12 +10,12 @@ import { HeroBackground } from '@/components/backgrounds/HeroBackground' import { useFixedMarkees } from '@/lib/contracts/useFixedMarkees' import { useFixedViews } from '@/hooks/useFixedViews' import { useEthPrice } from '@/hooks/useEthPrice' -import { V13_LEADERBOARDS } from '@/lib/contracts/addresses' import { formatUsd } from '@/lib/utils' import { FixedPriceModal } from '@/components/modals/FixedPriceModal' import type { FixedMarkee } from '@/lib/contracts/useFixedMarkees' import { RevnetBuyWidget } from '@/components/widgets/RevnetBuyWidget' import { BuyMessageModal } from '@/components/modals/BuyMessageModal' +import { StrategyBadge } from '@/components/StrategyBadge' const MONO = "var(--font-jetbrains-mono), 'JetBrains Mono', monospace" const MARKETPLACE_TEASER_COLS = '190px 110px 1fr 74px 120px' @@ -340,8 +340,8 @@ export default function Home() { if (!data) return const leaderboards: { address: string; platform: string; topFundsAddedRaw: string; totalFundsRaw: string; markeeCount: number; isLegacy?: boolean; [key: string]: any }[] = data.leaderboards ?? [] const active = leaderboards.filter(lb => BigInt(lb.topFundsAddedRaw ?? '0') > 0n) - // Top 5 by totalFundsRaw across all platforms (API already sorts descending); exclude gamed leaderboards - setTop5Eco(leaderboards.filter(lb => !lb.gamed && BigInt(lb.totalFundsRaw ?? '0') > 0n).slice(0, 5)) + // Top 5 by effectiveRate across all strategies (API already sorts descending); exclude gamed + setTop5Eco(leaderboards.filter(lb => !lb.gamed && BigInt(lb.effectiveRateRaw ?? '0') > 0n).slice(0, 5)) setTop5EcoLoading(false) const messages = active.reduce( (sum, lb) => sum + (lb.isLegacy ? lb.markeeCount : Math.max(0, lb.markeeCount - 1)), @@ -511,6 +511,7 @@ export default function Home() {
)) : top5Eco.map((lb) => { + const isStreaming = lb.strategy === 'streaming' const totalEth = parseFloat(formatEther(BigInt(lb.totalFundsRaw ?? '0'))) const totalUsd = ethPrice ? Math.round(totalEth * ethPrice) : null const topFundsAdded = BigInt(lb.topFundsAddedRaw ?? '0') @@ -574,9 +575,10 @@ export default function Home() { {/* Current message */} -
+
+
{lb.topMessage || '—'} @@ -591,7 +593,16 @@ export default function Home() { {/* Price to change */}
- {priceToOvertakeEth != null ? ( + {isStreaming ? ( + + Stream → + + ) : priceToOvertakeEth != null ? ( +
+
+ + {canStream && ( + + )} +
+ + {/* Stats */} +
+
+ + {messageCount?.toString() ?? '—'} + messages +
+
+ + + {topMarkee ? formatRate(topMarkee.rate) : '—'} + + top rate +
+
+ + + {topViews !== null ? formatViews(topViews) : '—'} + + views +
+
+ + {/* Live total streamed — ticks up in real time at the board's aggregate inflow rate */} + {hasFlow && ( +
+
+ Total streamed to this board +
+
+ + + {streamedEth.toFixed(6)} + + ETHx + {ethPrice && ( + + ≈ {formatUsd(streamedEth * ethPrice)} + + )} +
+
+ )} +
+ + + {/* Top message spotlight */} + {topMarkee && ( +
+
+
+
+ #1 +
+
+
Top Message
+

+ {topMarkee.message || No message set} +

+
+ {topMarkee.name && by {topMarkee.name}} + {formatRate(topMarkee.rate)} + {canStream && ( + + )} +
+
+
+
+
+ )} + + {/* All Messages */} +
+
+
+

Leaderboard

+ {canStream && ( + + )} +
+ + {isLoading ? ( +
+ {[1, 2, 3, 4, 5].map(i => )} +
+ ) : markees.length === 0 ? ( +
+ +

No backed messages yet

+

Add a message and open a stream to claim the top spot.

+ {canStream && ( + + )} +
+ ) : ( +
+ {markees.map((markee, idx) => ( + backMarkee(markee) : undefined} + /> + ))} +
+ )} +
+
+ +
+ + {target && ( + setTarget(null)} + onSuccess={refetch} + /> + )} + + {createOpen && ( + setCreateOpen(false)} + onCreated={(addr, message, name) => { + setCreateOpen(false) + refetch() + setTarget({ address: addr, message, name }) + }} + /> + )} +
+ ) +} + +// ── Markee row ───────────────────────────────────────────────────────────────── + +function MarkeeRow({ + markee, + rank, + onBack, +}: { + markee: StreamingMarkee + rank: number + onBack?: () => void +}) { + const { address } = useAccount() + const isOwner = address && markee.owner.toLowerCase() === address.toLowerCase() + + const rankColors: Record = { + 1: 'text-[#FFD700] border-[#FFD700]/40 bg-[#FFD700]/10', + 2: 'text-[#C0C0C0] border-[#C0C0C0]/40 bg-[#C0C0C0]/10', + 3: 'text-[#CD7F32] border-[#CD7F32]/40 bg-[#CD7F32]/10', + } + const rankStyle = rankColors[rank] ?? 'text-[#8A8FBF] border-[#8A8FBF]/20 bg-[#8A8FBF]/5' + + return ( +
+
+ {rank} +
+
+

+ {markee.message || No message} +

+
+ {markee.name && {markee.name}} + {isOwner && ( + + yours + + )} +
+
+
+ {formatRate(markee.rate)} + {onBack && ( + + )} +
+
+ ) +} diff --git a/frontend/components/modals/CreateMessageModal.tsx b/frontend/components/modals/CreateMessageModal.tsx new file mode 100644 index 00000000..e70aa4c4 --- /dev/null +++ b/frontend/components/modals/CreateMessageModal.tsx @@ -0,0 +1,165 @@ +'use client' + +import { useState, useEffect } from 'react' +import { decodeEventLog, type Address } from 'viem' +import { useAccount, useWriteContract, useWaitForTransactionReceipt, useSwitchChain } from 'wagmi' +import { formatTransactionError, logTransactionError } from '@/lib/transactionErrors' +import { StreamingLeaderboardABI } from '@/lib/contracts/abis' +import { CANONICAL_CHAIN } from '@/lib/contracts/addresses' + +// ── Theme tokens (shared with StreamModal) ───────────────────────────────────── +const BG = '#060A2A' +const BG2 = '#0A0F3D' +const PINK = '#F897FE' +const BORDER = 'rgba(138,143,191,0.2)' +const MUTED = '#8A8FBF' +const TEXT = '#EDEEFF' +const MONO = "var(--font-jetbrains-mono), 'JetBrains Mono', monospace" + +const MARKEE_CREATED_ABI = [ + { + type: 'event', + name: 'MarkeeCreated', + inputs: [ + { name: 'markeeAddress', type: 'address', indexed: true }, + { name: 'owner', type: 'address', indexed: true }, + { name: 'message', type: 'string', indexed: false }, + { name: 'name', type: 'string', indexed: false }, + ], + }, +] as const + +// Free createMarkee on a streaming board, then hands the new markee back so the caller can chain +// into StreamModal to back it. Shared by the board detail page and the marketplace row. +export function CreateMessageModal({ + board, + onClose, + onCreated, +}: { + board: Address + onClose: () => void + onCreated: (address: Address, message: string, name: string) => void +}) { + const { chain } = useAccount() + const { switchChain } = useSwitchChain() + const isCorrectChain = chain?.id === CANONICAL_CHAIN.id + + const [message, setMessage] = useState('') + const [name, setName] = useState('') + const [error, setError] = useState(null) + + const { writeContract, data: hash, isPending, error: writeError, reset } = useWriteContract() + const { isLoading: isConfirming, isSuccess, data: receipt } = useWaitForTransactionReceipt({ hash }) + + useEffect(() => { + if (!isSuccess || !receipt) return + for (const log of receipt.logs) { + if (log.address.toLowerCase() !== board.toLowerCase()) continue + try { + const ev = decodeEventLog({ abi: MARKEE_CREATED_ABI, data: log.data, topics: log.topics }) + if (ev.eventName === 'MarkeeCreated') { + onCreated(ev.args.markeeAddress, ev.args.message, ev.args.name) + return + } + } catch { + // not the MarkeeCreated event, keep scanning + } + } + setError('Created, but could not read the new message address. Refresh the leaderboard.') + }, [isSuccess, receipt, board, onCreated]) + + useEffect(() => { + if (writeError) logTransactionError(writeError, 'CreateMessageModal') + }, [writeError]) + + const submit = () => { + setError(null) + if (!message.trim()) { setError('Enter a message.'); return } + reset() + try { + writeContract({ + address: board, + abi: StreamingLeaderboardABI, + functionName: 'createMarkee', + args: [message, name], + chainId: CANONICAL_CHAIN.id, + }) + } catch (err) { + logTransactionError(err, 'CreateMessageModal.createMarkee') + setError(formatTransactionError(err)) + } + } + + const busy = isPending || isConfirming || isSuccess + const transactionError = error || (writeError ? formatTransactionError(writeError) : null) + + return ( +
+
e.stopPropagation()} + style={{ + width: '100%', maxWidth: 480, background: BG2, borderRadius: 16, border: `1px solid ${BORDER}`, + boxShadow: '0 24px 80px rgba(0,0,0,0.5)', color: TEXT, overflow: 'hidden', + fontFamily: 'Manrope, system-ui, sans-serif', + }} + > +
+ Add a message + +
+ +
+ {!isCorrectChain ? ( +
+

Switch to {CANONICAL_CHAIN.name} to add a message.

+ +
+ ) : ( + <> +