Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
57d4db8
feat(streaming): streaming pricing strategy frontend
tnrdd Jul 1, 2026
5abdfed
fix(csp): allow the configured NEXT_PUBLIC_BASE_RPC_URL origin in con…
tnrdd Jul 1, 2026
15435bd
feat(streaming): strategy-first create flow + unified effectiveRate b…
tnrdd Jul 1, 2026
9dc8b89
refactor(abis): dedup repeated contract ABIs into lib/contracts/abis.ts
tnrdd Jul 1, 2026
1ed794f
feat(marketplace): show streaming boards' flow rate ($/mo) instead of…
tnrdd Jul 1, 2026
58238ff
feat(streaming): rank streaming boards by live cumulative ETHx streamed
tnrdd Jul 1, 2026
f5ab97e
feat(views): production-only view writes + streaming board view tracking
tnrdd Jul 1, 2026
ea3c39f
chore(git): ignore foundry artifacts, node_modules, and local .claude…
tnrdd Jul 1, 2026
0437a1b
chore(streaming): keep dev/testnet scripts local, drop from repo
tnrdd Jul 1, 2026
4c90021
fix(chain): pin writes to Base, block wrong-network sends
tnrdd Jul 2, 2026
6abbb35
fix(fork): route embedded-wallet writes through the fork RPC (env-gated)
tnrdd Jul 2, 2026
7906f8b
fix(ssr): pin en-US locale on server-rendered numbers
tnrdd Jul 2, 2026
aa16cea
fix(wallet): keep the active wagmi wallet on a Privy-linked wallet
tnrdd Jul 2, 2026
c028245
feat(streaming): replace the permit signature with an approve transac…
tnrdd Jul 2, 2026
55e35db
feat(account): list created streaming boards under My Markees
tnrdd Jul 2, 2026
4ab70b6
Merge origin/main into feat/streaming-frontend
tnrdd Jul 8, 2026
e930ce7
fix: send post-create, buy, and published board links to unified /markee
tnrdd Jul 9, 2026
6738734
fix: derive internal fetch origin from deployment env, not request Host
tnrdd Jul 9, 2026
c78dc33
refactor: read the cooperative leaderboard from V13_LEADERBOARDS again
tnrdd Jul 9, 2026
41112c7
chore(git): stop ignoring lib/, where Foundry deps live as submodules
tnrdd Jul 9, 2026
84a7e09
feat(streaming): connect the GDA refund pool in the open-stream batch
tnrdd Jul 15, 2026
0b51569
refactor(keeper): reuse existing env vars, add overlap lock and paging
tnrdd Jul 22, 2026
6fa2b91
chore(streaming): remove the fork-only chain override
tnrdd Jul 22, 2026
51d0925
fix(streaming): source board totals from the subgraph, net of refunds
tnrdd Jul 22, 2026
8b1e496
chore(cron): run the streaming keeper every 10 minutes
tnrdd Jul 22, 2026
788583d
Merge remote-tracking branch 'origin/dev' into feat/streaming-frontend
tnrdd Jul 22, 2026
13ffaa7
fix(streaming): surface wallet errors in the stream and message modals
tnrdd Jul 22, 2026
445741b
fix(streaming): read markee history through the configured Base RPC
tnrdd Jul 22, 2026
93464ee
feat(streaming): list streaming positions on the account dashboard
tnrdd Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
45 changes: 37 additions & 8 deletions frontend/app/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -40,6 +41,7 @@ interface BaseLeaderboard {
topMessageOwner?: string | null
topFundsAddedRaw: string
minimumPriceRaw?: string
strategy?: 'fixed' | 'streaming'
}
interface SuperfluidLeaderboard extends BaseLeaderboard { platform: 'superfluid' }
interface LinkedFile {
Expand Down Expand Up @@ -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 <Github size={size} style={{ color: TEXT2 }} />
Expand Down Expand Up @@ -317,7 +325,10 @@ function MarkeeCardDash({ lb, archived, onIntegrate, onVerify, onEdit, onArchive
<PlatIcon lb={lb} size={22} />
</div>
<div style={{ minWidth: 0 }}>
<div style={{ color: TEXT, fontWeight: 700, fontSize: 16, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{lb.name}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<div style={{ color: TEXT, fontWeight: 700, fontSize: 16, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{lb.name}</div>
{lb.strategy === 'streaming' && <StrategyBadge strategy="streaming" size="xs" />}
</div>
<div style={{ color: MUTED, fontSize: 12, fontFamily: MONO }}>{sub}</div>
</div>
</div>
Expand Down Expand Up @@ -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'
Expand All @@ -409,7 +420,7 @@ function SetupTable({ markees, onIntegrate, onVerify, onArchive }: {
</div>
{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)
Expand Down Expand Up @@ -800,6 +811,7 @@ export default function AccountPage() {
const [superfluidBoards, setSuperfluidBoards] = useState<SuperfluidLeaderboard[]>([])
const [githubBoards, setGithubBoards] = useState<GithubLeaderboard[]>([])
const [websiteBoards, setWebsiteBoards] = useState<WebsiteLeaderboard[]>([])
const [streamingBoards, setStreamingBoards] = useState<AnyLeaderboard[]>([])
const [isLoading, setIsLoading] = useState(false)

// Messages
Expand All @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(() =>
Expand All @@ -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')
}, [])

Expand Down
155 changes: 130 additions & 25 deletions frontend/app/api/account/funded/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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' },
}),
})
Expand All @@ -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<typeof getClient>,
owner: string,
origin: string,
headers: HeadersInit,
): Promise<FundedMessage[]> {
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(
Expand All @@ -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))]

Expand All @@ -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<string, bigint>()
Expand All @@ -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(
Expand All @@ -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) ?? ''
Expand All @@ -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<typeof getClient>,
owner: string,
origin: string,
headers: HeadersInit,
): Promise<FundedMessage[]> {
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<string, BackerPosition>()),
])

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] })
}
11 changes: 7 additions & 4 deletions frontend/app/api/account/messages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

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